Skip to content

[M1-ECS-06] ECS guardrails: G-R3 entity-count thresholds, G-R4 per-frame churn - #22

Merged
offdev merged 1 commit into
masterfrom
m1-ecs-06-ecs-guardrails
Sep 14, 2026
Merged

offdev merged 1 commit into
masterfrom
m1-ecs-06-ecs-guardrails

Conversation

@offdev

@offdev offdev commented Sep 14, 2026

Copy link
Copy Markdown
Owner

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.

  • G-R3 entity-count thresholds. World::create() emits one structured Warn exactly when the live entity count reaches 25% / 50% / 100% of the declared scene budget (integer thresholds capacity * 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.
  • G-R4 per-frame component churn. The per-frame add/remove churn (successful addComponent calls, including in-place overwrites; row-detaching removeComponent calls; no-op removes and destroy/clear detaches are not counted) warns when it strictly exceeds World::Options::churnPerFrameBudget (new option, default laige::kDefaultChurnPerFrameBudget = 256; 0 disables the guardrail), at most once per frame. Event: ecs/churn_per_frame.
  • Profiler feed (M1-PROF-01). World::beginFrame() drives the per-frame windows (the game loop of M1-LOOP-01 will wire it); World::guardrailStats() returns the GuardrailStats snapshot (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 per beginFrame(). 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 EcsGuardrails suite, CTest entry ecs_guardrails (tests/laige-sim/ecs_guardrails_tests.cpp):

  • thresholds fire exactly at the documented percentages (25/50/100 for capacity 8), no duplicates, BudgetExhausted at 100% without a repeated warn;
  • once-per-level-per-frame dedup (same-frame down-cross + up-cross does not re-warn; a new frame does);
  • degenerate thresholds (capacity 2 → 50/100 only; capacity 1 → 100 only; capacity 0 → nothing);
  • guardrails fire even without frame bookkeeping (warn-once-per-lifetime degradation);
  • churn warns exactly when the budget is strictly exceeded, once per frame, counter reset at frame start, no-op removes uncounted, budget 0 disables;
  • NFR-13.3 grammar: message splits into exactly 5 non-empty fields, {code} names the event, {doc_anchor} is docs/api/entity.md#guardrails; debug builds carry the advice field (the churn advice contains "spawn/despawn system"), release builds do not;
  • guardrailStats() profiler-feed values across a frame boundary;
  • zero-alloc window below all thresholds (non-sanitizer trees; sanitizer trees cover it via leak-free runs).

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.json regenerated with the 12 new public symbols (kDefaultChurnPerFrameBudget, GuardrailStats + fields, World::Options::churnPerFrameBudget, World::beginFrame, World::guardrailStats); api-real-tree is green.

Verification (all 39 tests green in each tree)

Tree Result
build (Debug, GCC) 39/39 pass, incl. ecs_guardrails
build-asan (ASan+UBSan) 39/39 pass
build-release (NDEBUG paths) 39/39 pass
build-clang (Clang, Debug) 39/39 pass
build-tsan (TSan) 39/39 pass (one unfiltered-sim flake on the very first run, right after the build finished; 3+ reruns clean — the timing-sensitive churn assertion measures ~81 ns/row vs the 200 floor)
build-shared (NFR-8.9) sim suites pass

No new error codes, no new dependencies, no ENGINE-RULE-EXCEPTION blocks.

…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
offdev merged commit 675fb94 into master Sep 14, 2026
10 checks passed
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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant