[M1-ECS-06] ECS guardrails: G-R3 entity-count thresholds, G-R4 per-frame churn - #22
Merged
Merged
Conversation
…ame churn
PRD §9.3 G-R3/G-R4; roadmap/M1-heartbeat.md step M1-ECS-06.
G-R3: create() warns exactly when the live count reaches 25/50/100% of
the declared scene budget (integer thresholds capacity*pct/100; a level
whose threshold is 0 never fires), at most once per level per frame.
Events: ecs/entity_budget_{25,50,100}.
G-R4: per-frame component churn (successful addComponent calls,
including in-place overwrites, plus row-detaching removeComponent calls)
warns when it strictly exceeds Options::churnPerFrameBudget
(default kDefaultChurnPerFrameBudget=256; 0 disables). Event:
ecs/churn_per_frame.
Both: O(1) integer bookkeeping, no allocation on the hot path; NFR-13.3
5-field message grammar, build-stable; debug builds carry the PRD §9.3
advice as an extra structured field (the G-R4 advice is 'move the churn
to a spawn/despawn system'). beginFrame() drives the per-frame windows
(M1-LOOP-01 will wire it); guardrailStats() is the M1-PROF-01 feed.
Tests: new EcsGuardrails suite (ctest -R ecs_guardrails) — exact
threshold firing, once-per-level-per-frame dedup, degenerate
thresholds, churn budget exceed/strictness, per-frame reset, no-op
removes uncounted, budget-0 disable, NFR-13.3 grammar parse, profiler
feed, zero-alloc below-threshold window. Two existing logging tests
updated to count their events by name: the guardrail warns are now
legitimate contents of their windows.
laige-api.json regenerated (12 new public symbols).
Verified: ctest green in build (Debug), build-asan, build-release,
build-clang, build-tsan, build-shared; api-real-tree green.
offdev
added a commit
that referenced
this pull request
Sep 14, 2026
* [M1-ECS-07] ECS stress + memory accounting test
Roadmap step M1-ECS-07 (roadmap/M1-heartbeat.md), scope only.
Stress test (new EcsStress suite, CTest entry ecs_stress):
10k entities at the 100% scene budget, 6 registered component types,
10k frames of add/remove churn. Each frame: beginFrame() drives the
G-R3/G-R4 guardrails, 64 seeded Tag adds + 64 Tag removes (cyclic
permutation, default 256 churn budget never exceeded), then one
each<Pos, Tag> iteration (Read, Read) with visit count + 64-bit
FNV-1a checksum. A 700-frame warm-up (one full 625-frame cohort
period + margin) lets every archetype's columns reach their high
water before the measured window.
Verified properties (the step's Scope):
- no leaks: green ctest -R ecs_stress in the ASan tree (leak check),
40/40 on build-asan
- pool high-water stable: zero totalReservations/totalArchetypeGrowth
delta across the 10k-frame window (high water: 9 archetypes,
24592 reserved rows, 549024 bytes)
- iteration within the documented cost (query.h): window-wide
ns-per-visit throughput floor (600 ns; measured 58.7 ns g++ /
72.3 ns clang++ on the -O0 Debug trees)
- zero-allocation window (test-only operator-new counter,
non-sanitizer trees; sanitizer trees: leak-free run + delta)
- guardrails silent in the window (churnWarns delta 0; the 100%
entity-budget warn fired once at setup and never re-crosses)
Memory accounting (PRD 8.1 base memory, accounted bytes): 110000
entity bookkeeping bytes + 549024 reserved row bytes at the 100%-full
scene; machine-greppable ecs-stress {window,iteration,memory} lines on
every ctest run. Baseline recorded in
docs/benchmarks/baselines/m1-ecs-stress.md (AGENTS 12 fields; not a
budgets.json workload - no measured field updated).
Determinism spot check: the window/memory lines are byte-identical
across g++ 16.2.1 and clang++ 22.1.8 (pure integer workload).
Local verify: ctest -R ecs_stress green on build (Debug g++),
build-asan (required Verify; 37.7s leak-free), build-release,
build-clang, build-tsan, build-shared; full laige-sim suite 40/40 on
build/build-asan/build-clang; zero new warnings under NFR-8.10.
Progress board: M1 5 -> 7 (M1-ECS-06 row lands in the same PR; it
merged without updating the board or change log).
* [M1-ECS-07] roadmap change log + baseline commit field
Add the M1-ECS-07 change log row (commit 2995ec7) and the
retroactive M1-ECS-06 row (675fb94 / PR #22 — merged without
updating the board or change log; board counts now reflect the
checked boxes: M1 5 -> 7, total 27 -> 29). Fill the baseline
file's measured-commit field (2995ec7).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this step implements
Roadmap step M1-ECS-06 (roadmap/M1-heartbeat.md): the engine-enforced guardrails G-R3 and G-R4 (PRD §9.3) for the ECS entity storage.
World::create()emits one structuredWarnexactly when the live entity count reaches 25% / 50% / 100% of the declared scene budget (integer thresholdscapacity * pct / 100; a level whose threshold computes to 0 never fires). A level warns at most once per frame — dipping below and re-crossing within the same frame does not re-warn. Events:ecs/entity_budget_25|50|100.addComponentcalls, including in-place overwrites; row-detachingremoveComponentcalls; no-op removes anddestroy/cleardetaches are not counted) warns when it strictly exceedsWorld::Options::churnPerFrameBudget(new option, defaultlaige::kDefaultChurnPerFrameBudget = 256;0disables the guardrail), at most once per frame. Event:ecs/churn_per_frame.World::beginFrame()drives the per-frame windows (the game loop of M1-LOOP-01 will wire it);World::guardrailStats()returns theGuardrailStatssnapshot (level/warn counts, per-frame churn + budget, warn counters).Message grammar and debug advice
Every guardrail warn's message text is the NFR-13.3 5-field error-grammar line (
{code} | {what} | {why} | {fix} | {doc_anchor}), identical in every build. Per PRD §9.3 ("warn (debug: with advice)"), debug builds add the advice as an extra structured field — never as message text, so the grammar stays build-stable. The G-R4 advice is the PRD's: move the churn to a spawn/despawn system (the same advice the iteration-legality rejection points to, docs/api/query.md).Hot-path cost
Three threshold comparisons per
create(), one counter increment plus one comparison per counted add/remove, a handful of stores perbeginFrame(). Pure integer bookkeeping, no allocation (PERF-003); the warn paths are cold and their fields construct only when the event is enabled (LOG-003). A zero-allocation test pins the below-threshold window (EcsGuardrails.GuardrailChecksAllocateNothingBelowTheThresholds).Tests
New
EcsGuardrailssuite, CTest entryecs_guardrails(tests/laige-sim/ecs_guardrails_tests.cpp):BudgetExhaustedat 100% without a repeated warn;{code}names the event,{doc_anchor}isdocs/api/entity.md#guardrails; debug builds carry theadvicefield (the churn advice contains "spawn/despawn system"), release builds do not;guardrailStats()profiler-feed values across a frame boundary;Two pre-existing logging tests (
WorldLogging.StaleAccessWarnsOnce,ArchetypeLogging.ArchetypeBudgetWarnsOnce) updated to count their events by event name: the new guardrail warns are legitimate contents of their sink windows (a capacity-1 world crosses 100%; the 300-capacity scenario crosses 25%/50% and the default churn budget).laige-api.jsonregenerated with the 12 new public symbols (kDefaultChurnPerFrameBudget,GuardrailStats+ fields,World::Options::churnPerFrameBudget,World::beginFrame,World::guardrailStats);api-real-treeis green.Verification (all 39 tests green in each tree)
build(Debug, GCC)ecs_guardrailsbuild-asan(ASan+UBSan)build-release(NDEBUG paths)build-clang(Clang, Debug)build-tsan(TSan)build-shared(NFR-8.9)No new error codes, no new dependencies, no
ENGINE-RULE-EXCEPTIONblocks.