diff --git a/docs/README.md b/docs/README.md index 7d011ff..e2102e0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,7 +5,8 @@ Documentation index and navigation (DOC-001). The engine is at **M1** has started (M1-ECS-01: the entity handle and world entity storage; M1-ECS-02: the component type registry; M1-ECS-03: archetype SoA component storage; M1-ECS-04: the query API + iteration legality; -M1-ECS-05: the deterministic iteration contract). +M1-ECS-05: the deterministic iteration contract; M1-ECS-06: the +ECS guardrails G-R3/G-R4). Every section of the AGENTS §13 `docs/` tree exists; each entry below links what is written and the "not yet written" section marks what is still to land. @@ -31,7 +32,10 @@ still to land. - [Entity handle and world entity storage](api/entity.md) — `laige::Entity` (32-bit id+generation handle) and `laige::World` - entity storage (M1-ECS-01; `laige-sim`). + entity storage, including the M1-ECS-06 guardrails: the G-R3 + entity-count thresholds (`beginFrame()`, `guardrailStats()`, + `ecs/entity_budget_{25,50,100}`) and the G-R4 per-frame churn + budget (`ecs/churn_per_frame`) (M1-ECS-01/06; `laige-sim`). - [Component type registry](api/component_registry.md) — `ComponentTypeId`, `LAIGE_COMPONENT`, `World::registerComponent` (M1-ECS-02; `laige-sim`). diff --git a/docs/api/entity.md b/docs/api/entity.md index b02cea5..e5bb653 100644 --- a/docs/api/entity.md +++ b/docs/api/entity.md @@ -53,6 +53,8 @@ worlds passes `isValid()` in both (the same cross-pool caveat as | `isValid(e)` | Generation-checked liveness; no side effects | O(1) | | `capacity()` / `entityCount()` | Declared budget / live count (the G-R3 numerator) | O(1) | | `stats()` | `EntityStats` accounting snapshot (G-R3 and M1-PROF-01 feed) | O(1), no allocation | +| `beginFrame()` (M1-ECS-06) | Mark the frame start: resets the per-frame churn counters and the once-per-frame guardrail warn flags (G-R3/G-R4); no log, no return value. Driven once per frame by the owning loop (M1-LOOP-01); read the just-completed frame's counters with `guardrailStats()` before the next call | O(1), no allocation | +| `guardrailStats()` (M1-ECS-06) | `GuardrailStats` guardrail snapshot: the G-R3 level/warn counts, the G-R4 per-frame churn + its budget, the warn counters (M1-PROF-01 feed) | O(1), no allocation, no side effects | | `clear()` | Destroy every live entity (shutdown path, CONC-006); each live row is detached (M1-ECS-03); every handle goes stale; capacity unchanged; world immediately reusable. Returns `Status`: under a live `each` iteration, `InvalidArgument` when a matched archetype holds live rows (query.md "Iteration legality") | O(capacity) scan + detaches, no allocation, idempotent | Move-only (O(1) pointer swap — the archetype tables move with it, so @@ -86,6 +88,101 @@ per-(subsystem, event, severity) rate limit implements the "warn-once" semantics (LOG-004: the first event emits, repeats are counted and summarized). +## Guardrails (G-R3, G-R4) (M1-ECS-06) + +The engine-enforced guardrails of PRD §9.3 for the entity storage. +Both are structured `Warn` events under the subsystem `ecs`, with +counters pulled by the profiler (M1-PROF-01) through +`guardrailStats()`. Both are O(1) integer bookkeeping on the hot +path — no allocation (PERF-003); the warn paths are cold (a budget +being crossed) and their fields construct only when the event is +enabled (LOG-003). + +### G-R3 — entity-count thresholds + +`create()` warns exactly when the live count **reaches** 25% / 50% +/ 100% of the declared scene budget — integer thresholds +`capacity * pct / 100`: + +| Level | Event | Fires when (capacity 2000 example) | +|---|---|---| +| 25% | `ecs/entity_budget_25` | `entityCount == 500` | +| 50% | `ecs/entity_budget_50` | `entityCount == 1000` | +| 100% | `ecs/entity_budget_100` | `entityCount == 2000` (the last successful `create()`; the next one fails with `BudgetExhausted`) | + +- A level whose threshold computes to **0 never fires** (the live + count is 0 only before the first `create()`): e.g. capacity 2 + warns only at 50% (1) and 100% (2); capacity 1 warns only at 100%. +- **Once per level per frame**: dipping below a threshold and + re-crossing it within the same frame does not re-warn. Frame + boundaries are driven by `beginFrame()`; if the world is never + frame-driven, the guardrail degrades to warn-once-per-lifetime + (documented, never silent). The facade's rate limit (LOG-004) + additionally collapses cross-frame repeats within its window. +- The warn carries the fields `entity_count`, `capacity`, `level` + (25/50/100). + +### G-R4 — per-frame component churn + +The per-frame **churn** counts, per frame (`beginFrame()` to +`beginFrame()`): + +- every successful `addComponent` — including in-place + overwrites (the `ArchetypeStats::totalAdds` semantics); and +- every `removeComponent` that actually detaches a row. + +No-op removes (the entity lacks the component), `destroy()`/`clear()` +detaches, and rejected calls (`BudgetExhausted`, invalid handle) are +**not** counted. When the per-frame total **strictly exceeds** +`World::Options::churnPerFrameBudget`, one `ecs/churn_per_frame` +warn fires for that frame (the warn carries the fields +`frame_churn`, `churn_budget`). + +- **Budget:** `Options::churnPerFrameBudget`, default + `laige::kDefaultChurnPerFrameBudget = 256` — at the M1 reference + scene (10k entities, PRD §8.1) that is ~2.6% of the scene per + frame: steady-state gameplay stays far below it, and a sustained + breach indicates unbatched spawn/despawn churn on the hot path. + Churn-heavy scenes raise it through typed configuration (API-006); + **0 disables** the guardrail (documented). + +### Message grammar (NFR-13.3) and the debug advice field + +Every guardrail warn's **message text** is the 5-field NFR-13.3 +error-grammar line, identical in every build (machine-parseable, +stable): + +```text +{code} | {what} | {why} | {fix} | {doc_anchor} +``` + +e.g. + +```text +entity_budget_100 | live entities reached 100% of the declared scene budget | the scene budget is full; the next create() fails with BudgetExhausted | destroy entities before spawning more, or raise the scene budget through typed configuration | docs/api/entity.md#guardrails +``` + +Per PRD §9.3 ("warn (debug: with advice)"), **debug builds** add the +advice as an extra structured `advice` FIELD — never as message +text, so the 5-field 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, query.md). + +### `GuardrailStats` (the profiler feed) + +| Field | Meaning | +|---|---| +| `capacity` | the declared scene budget (G-R3 denominator) | +| `entityCount` | live entities right now (G-R3 numerator) | +| `entityBudgetLevel` | 0/25/50/100 — the highest percentage the **peak** live count reached since construction | +| `entityBudgetWarns[3]` | per-level warn counts (index 0 = 25%, 1 = 50%, 2 = 100%) | +| `frameChurn` | adds + removes since the last `beginFrame()` | +| `churnPerFrameBudget` | the configured G-R4 budget (0 = disabled) | +| `churnWarns` | churn warnings issued since construction | + +`beginFrame()` resets `frameChurn` (and the warn flags); the warn +counters and `entityBudgetLevel` are since-construction. + ## Stale-handle behavior matrix (FR-12.3, S-9) | Operation | Debug build | Release build | @@ -109,6 +206,12 @@ stale handle assert in debug and degrade in release. M1-ECS-03). `EntityStats` reports `capacity × 11` / `inUse × 11` bytes; the archetype column blocks are accounted separately in `ArchetypeStats` (archetype.md). +- **Guardrail cost (M1-ECS-06):** `create()` adds three threshold + comparisons; each counted add/remove adds one counter increment + plus one comparison; `beginFrame()` is a handful of stores. Pure + integer bookkeeping, no allocation — a zero-allocation test pins + the below-threshold window (`ecs_guardrails` suite, + `GuardrailChecksAllocateNothingBelowTheThresholds`). - **Zero-alloc enforcement:** the standing assertion lands with M1-ALLOC-01; until then the step is verified by ASan + the `stats()` accounting (M1 milestone rules). @@ -140,6 +243,11 @@ auto w = laige::World::create(laige::World::Options{2000}); // scene budget (G- if (w.isError()) { /* capacity above the 16-bit id space: configuration bug */ } laige::World& world = std::move(w).takeValue(); +// Per frame (the game loop, M1-LOOP-01): restart the guardrail windows. +world.beginFrame(); // G-R3/G-R4 (M1-ECS-06): reset per-frame churn + warn flags +// ...at frame end, before the next beginFrame(): +// const auto g = world.guardrailStats(); // the profiler feed (M1-PROF-01) + // Hot path (per tick): no allocation. auto r = world.create(); if (r.isError()) { @@ -186,5 +294,8 @@ if (!world.isValid(handle)) { /* stale — drop it, log if unexpected */ } - **M1-ECS-05 (done):** deterministic iteration (archetype order, entity id order — PRD §10.3) over the visit order M1-ECS-04 pins — see [iteration_order.md](iteration_order.md). -- **M1-ECS-06:** the G-R3 warn thresholds (25%/50%/100% of the - declared budget) pull `stats()`. +- **M1-ECS-06 (done):** the G-R3 entity-count thresholds and the + G-R4 per-frame churn guardrail — `beginFrame()`, + `guardrailStats()`, the `ecs/entity_budget_{25,50,100}` and + `ecs/churn_per_frame` warns (the "Guardrails" section above); + suite `ctest -R ecs_guardrails`. diff --git a/laige-api.json b/laige-api.json index 406a68c..8bf52c8 100644 --- a/laige-api.json +++ b/laige-api.json @@ -422,47 +422,59 @@ {"name": "laige::ComponentInfo::alignment", "kind": "variable", "header": "src/laige-sim/include/laige/sim/component.h", "line": 144, "signature": "std::uint32_t alignment{}", "summary": null, "budget": null, "experimental": false}, {"name": "laige::kMaxComponentTypes", "kind": "variable", "header": "src/laige-sim/include/laige/sim/component.h", "line": 149, "signature": "inline constexpr std::uint32_t kMaxComponentTypes = 256", "summary": "The engine-level cap on component types per world (CORE-005). See the preamble for the rationale and the ADR path to raise it.", "budget": null, "experimental": false}, {"name": "LAIGE_COMPONENT", "kind": "macro", "header": "src/laige-sim/include/laige/sim/component.h", "line": 196, "signature": "#define LAIGE_COMPONENT(Type)", "summary": "Mark T as a Laige component (FR-1.2; S-8 data-carrier case).", "budget": null, "experimental": false}, - {"name": "laige::Entity", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 137, "signature": "struct Entity", "summary": "The 32-bit entity handle (FR-1.2): a 16-bit slot id plus a 16-bit generation (CPP-007). See the header preamble for the full handle contract.", "budget": null, "experimental": false}, - {"name": "laige::Entity::id", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 138, "signature": "std::uint16_t id{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::Entity::generation", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 139, "signature": "std::uint16_t generation{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::Entity::kMaxEntityId", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 141, "signature": "static constexpr std::uint32_t kMaxEntityId = 0xFFFFu", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::Entity::kMaxEntities", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 142, "signature": "static constexpr std::uint32_t kMaxEntities = 0x10000u", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::operator==", "kind": "function", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 150, "signature": "inline bool operator==(Entity a, Entity b) noexcept", "summary": "Handle comparison compares the (id, generation) pair.", "budget": null, "experimental": false}, - {"name": "laige::operator!=", "kind": "function", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 153, "signature": "inline bool operator!=(Entity a, Entity b) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::EntityStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 167, "signature": "struct EntityStats", "summary": "One world's entity accounting snapshot (FR-11.1/FR-11.4, G-R3 feed; mirrors the M0-CORE-05 PoolStats shape). A plain value the M1 profiler (M1-PROF-01) and the G-R3 guardrail (M1-ECS-06) pull:", "budget": null, "experimental": false}, - {"name": "laige::EntityStats::capacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 168, "signature": "std::uint32_t capacity{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::EntityStats::inUse", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 169, "signature": "std::uint32_t inUse{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::EntityStats::peakInUse", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 170, "signature": "std::uint32_t peakInUse{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::EntityStats::totalCreated", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 171, "signature": "std::uint64_t totalCreated{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::EntityStats::bytesCapacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 172, "signature": "std::size_t bytesCapacity{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::EntityStats::bytesInUse", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 173, "signature": "std::size_t bytesInUse{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::World", "kind": "class", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 245, "signature": "class World", "summary": "The entity storage behind laige::Entity handles (M1-ECS-01).", "budget": null, "experimental": false}, - {"name": "laige::World::Options", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 252, "signature": "struct Options", "summary": "The declared scene budget (G-R3), fixed at construction (API-006). 0 is legal: every create() fails. Values above Entity::kMaxEntities are rejected at construction — the 16-bit id space cannot address them (API-008: the invalid state stays unrepresentable).", "budget": null, "experimental": false}, - {"name": "laige::World::Options::capacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 253, "signature": "std::uint32_t capacity{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::World::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 259, "signature": "[[nodiscard]] static Result create(Options options) noexcept", "summary": "Construction (setup path: the storage's only backing allocations). capacity > Entity::kMaxEntities -> ErrorCode::InvalidArgument (a handle-space configuration error; the world is not created).", "budget": null, "experimental": false}, - {"name": "laige::World::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 264, "signature": "[[nodiscard]] Result create() noexcept", "summary": "Create one entity. O(1), no allocation. Beyond the budget: ErrorCode::BudgetExhausted (the world never grows silently, S-2). Slot assignment is LIFO recycling — deterministic (see preamble).", "budget": null, "experimental": false}, - {"name": "laige::World::destroy", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 274, "signature": "[[nodiscard]] Status destroy(Entity entity) noexcept", "summary": "Destroy one live entity and return its slot to the free list. O(1) for a component-less entity; when the entity is in an archetype, its row is detached first — O(tail rows * row-stride) bytes moved, still no allocation (M1-ECS-03; archetype.h). The slot's generation is bumped, so every stale handle to it fails isValid() (CPP-007). Stale/invalid handle: debug -> assert (S-9); release -> ErrorCode::InvalidArgument + one rate-limited warn (FR-12.3: never silent).", "budget": null, "experimental": false}, - {"name": "laige::World::check", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 281, "signature": "[[nodiscard]] Status check(Entity entity) const noexcept", "summary": "Access validation — the check every entity access performs (M1-ECS-03's component access builds on this). O(1), no allocation. Stale/invalid handle: ErrorCode::InvalidArgument + one rate-limited warn in every build (queries degrade safely, never silent); live: an ok Status.", "budget": null, "experimental": false}, - {"name": "laige::World::isValid", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 284, "signature": "[[nodiscard]] bool isValid(Entity entity) const noexcept", "summary": "Generation-checked liveness (CPP-007). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::World::capacity", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 287, "signature": "[[nodiscard]] std::uint32_t capacity() const noexcept", "summary": "The declared scene budget (World::Options::capacity).", "budget": null, "experimental": false}, - {"name": "laige::World::entityCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 291, "signature": "[[nodiscard]] std::uint32_t entityCount() const noexcept", "summary": "The live entity count right now (the G-R3 numerator; M1-ECS-06 turns the inUse/capacity ratio into the 25%/50%/100% warns).", "budget": null, "experimental": false}, - {"name": "laige::World::stats", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 295, "signature": "[[nodiscard]] EntityStats stats() const noexcept", "summary": "Entity accounting snapshot for the profiler (M1-PROF-01) and the G-R3 guardrail (M1-ECS-06). O(1), no allocation.", "budget": null, "experimental": false}, - {"name": "laige::World::registerComponent", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 316, "signature": "template [[nodiscard]] Result registerComponent() noexcept", "summary": "Register component type T with this world (setup phase, before the loop). Assigns the next ComponentTypeId — dense, in registration order, from 1 — and records sizeof(T)/alignof(T) for the M1-ECS-03 SoA layout. O(n) in the registered types; no allocation. The same path serves built-in and user-defined components (S-8 data-carrier case).", "budget": null, "experimental": false}, - {"name": "laige::World::componentCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 321, "signature": "[[nodiscard]] std::uint32_t componentCount() const noexcept", "summary": "The number of component types registered so far (0 .. kMaxComponentTypes). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::World::componentInfo", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 327, "signature": "[[nodiscard]] Result componentInfo(ComponentTypeId id) const noexcept", "summary": "The size/alignment recorded for the type assigned `id` (the M1-ECS-03 SoA layout reads these). O(1), no allocation. `id` invalid or not registered in this world -> ErrorCode::InvalidArgument.", "budget": null, "experimental": false}, - {"name": "laige::World::has", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 338, "signature": "template [[nodiscard]] bool has(Entity entity) const noexcept", "summary": "True when `entity` is live and has a component of type T. O(1), no allocation, no side effects (a pure query, like isValid: a stale handle is simply \"no\", no warn). T must be a Laige component (LAIGE_COMPONENT); an unregistered T reads as false.", "budget": null, "experimental": false}, - {"name": "laige::World::get", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 348, "signature": "template [[nodiscard]] T* get(Entity entity) noexcept", "summary": "The entity's component of type T, or nullptr: stale/out-of-range handle (after the rate-limited warn-once of check(), every build), T not registered in this world, or the entity lacks T (a normal negative query, no warn). O(1) in the entity count; no allocation. The pointer is valid until the next mutation of that entity's components (an add/remove that moves it shifts the column) or of the world — copy the value out if you must keep it (PERF-005).", "budget": null, "experimental": false}, - {"name": "laige::World::addComponent", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 365, "signature": "template [[nodiscard]] Status addComponent(Entity entity, const T& value) noexcept", "summary": "Give `entity` a component of type T: create-or-update. When the entity already has T, `value` overwrites it in place (the archetype does not change). Otherwise the entity moves to the archetype of its component set plus T — a pool-backed move over pre-reserved columns: O((tail rows) * row-stride) bytes moved, no heap allocation in steady state (growth events are bounded, accounted, and logged — archetype.h \"Reserve policy\").", "budget": null, "experimental": false}, - {"name": "laige::World::removeComponent", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 373, "signature": "template [[nodiscard]] Status removeComponent(Entity entity) noexcept", "summary": "Take the component of type T from `entity` (a no-op ok Status when the entity lacks T or has no components). Otherwise the entity moves to the archetype of its component set minus T — same cost and allocation contract as addComponent. Stale/invalid handle or unregistered T -> InvalidArgument (+ warn).", "budget": null, "experimental": false}, - {"name": "laige::World::archetypeCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 379, "signature": "[[nodiscard]] std::uint32_t archetypeCount() const noexcept", "summary": "The number of distinct component sets seen by this world so far (0 .. kMaxArchetypes; archetypes are never destroyed in M1). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::World::archetypeStats", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 384, "signature": "[[nodiscard]] ArchetypeStats archetypeStats() const noexcept", "summary": "Archetype storage accounting snapshot (ArchetypeStats): the profiler (M1-PROF-01) and the zero-overflow/zero-allocation checks read this. O(kMaxArchetypes), no allocation.", "budget": null, "experimental": false}, - {"name": "laige::World::each", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 419, "signature": "template [[nodiscard]] Status each(F&& fn, Acc...) noexcept", "summary": "Iterate every entity having ALL of T1..TN (superset match: extra components do not exclude an entity), invoking `fn(Entity, R1, ..., RN)` — one reference per listed component, in template order: a `const T&` where the access tag is Read, a `T&` where it is Write. The access tags follow `fn`, one Read/Write tag per listed component, in the same order (checked at compile time — they come after the callable because a pack of parameters must be the last parameters to be deducible); `each<>` (no components, no tags) visits every live entity in ascending slot-id order with no component references.", "budget": null, "experimental": false}, - {"name": "laige::World::clear", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 433, "signature": "[[nodiscard]] Status clear() noexcept", "summary": "Destroy every live entity (shutdown path, CONC-006). Every handle becomes stale; the capacity is unchanged and the world is immediately reusable. O(capacity + detached rows * row-stride), no allocation, idempotent. M1-ECS-03: each live entity is detached from its archetype first (the per-entity component data is released with its row); the archetypes themselves — and the component type registry — survive. M1-ECS-04: rejected with ErrorCode::InvalidArgument (+ one rate-limited warn) while an iteration is active and any matched archetype still holds live rows — the clear is skipped, never partial (assert in debug; query.h \"Iteration legality\"); an ok Status otherwise.", "budget": null, "experimental": false}, - {"name": "laige::World::World", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 437, "signature": "World(World&& other) noexcept", "summary": "Move is an O(1) pointer swap; the source becomes a valid empty world (capacity 0: every create() fails, every handle invalid).", "budget": null, "experimental": false}, - {"name": "laige::World::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 438, "signature": "World& operator=(World&& other) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::World::World", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 439, "signature": "World(const World&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::World::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 440, "signature": "World& operator=(const World&) = delete", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::World::~World", "kind": "destructor", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 445, "signature": "~World() noexcept", "summary": "Detaches every live entity's component rows (clear()) and releases the backing storage (per-slot tables, archetype table with its column blocks, type-key index). Idempotent with clear().", "budget": null, "experimental": false}, + {"name": "laige::Entity", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 172, "signature": "struct Entity", "summary": "The 32-bit entity handle (FR-1.2): a 16-bit slot id plus a 16-bit generation (CPP-007). See the header preamble for the full handle contract.", "budget": null, "experimental": false}, + {"name": "laige::Entity::id", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 173, "signature": "std::uint16_t id{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Entity::generation", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 174, "signature": "std::uint16_t generation{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Entity::kMaxEntityId", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 176, "signature": "static constexpr std::uint32_t kMaxEntityId = 0xFFFFu", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Entity::kMaxEntities", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 177, "signature": "static constexpr std::uint32_t kMaxEntities = 0x10000u", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::operator==", "kind": "function", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 185, "signature": "inline bool operator==(Entity a, Entity b) noexcept", "summary": "Handle comparison compares the (id, generation) pair.", "budget": null, "experimental": false}, + {"name": "laige::operator!=", "kind": "function", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 188, "signature": "inline bool operator!=(Entity a, Entity b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EntityStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 202, "signature": "struct EntityStats", "summary": "One world's entity accounting snapshot (FR-11.1/FR-11.4, G-R3 feed; mirrors the M0-CORE-05 PoolStats shape). A plain value the M1 profiler (M1-PROF-01) and the G-R3 guardrail (M1-ECS-06) pull:", "budget": null, "experimental": false}, + {"name": "laige::EntityStats::capacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 203, "signature": "std::uint32_t capacity{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EntityStats::inUse", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 204, "signature": "std::uint32_t inUse{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EntityStats::peakInUse", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 205, "signature": "std::uint32_t peakInUse{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EntityStats::totalCreated", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 206, "signature": "std::uint64_t totalCreated{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EntityStats::bytesCapacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 207, "signature": "std::size_t bytesCapacity{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::EntityStats::bytesInUse", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 208, "signature": "std::size_t bytesInUse{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kDefaultChurnPerFrameBudget", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 219, "signature": "inline constexpr std::uint32_t kDefaultChurnPerFrameBudget = 256", "summary": "The default G-R4 per-frame component-churn budget (CORE-005). At the M1 reference scene (10k entities, PRD §8.1) 256 lifecycle ops per frame is ~2.6% of the scene — steady-state gameplay stays far below it; a sustained breach indicates unbatched spawn/despawn churn on the hot path (the guardrail's advice). Overridable per world (World::Options::churnPerFrameBudget); scenes with a legitimately churning lifecycle raise it through typed configuration, and 0 disables the guardrail.", "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 236, "signature": "struct GuardrailStats", "summary": "M1-ECS-06 (G-R3, G-R4) guardrail snapshot. A plain value the M1 profiler (M1-PROF-01) pulls each frame (World::guardrailStats()); mirrors the EntityStats/ArchetypeStats snapshot shape:", "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats::capacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 237, "signature": "std::uint32_t capacity{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats::entityCount", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 238, "signature": "std::uint32_t entityCount{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats::entityBudgetLevel", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 239, "signature": "std::uint32_t entityBudgetLevel{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats::entityBudgetWarns", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 240, "signature": "std::uint32_t entityBudgetWarns[3]{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats::frameChurn", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 241, "signature": "std::uint64_t frameChurn{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats::churnPerFrameBudget", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 242, "signature": "std::uint32_t churnPerFrameBudget{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::GuardrailStats::churnWarns", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 243, "signature": "std::uint32_t churnWarns{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::World", "kind": "class", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 315, "signature": "class World", "summary": "The entity storage behind laige::Entity handles (M1-ECS-01).", "budget": null, "experimental": false}, + {"name": "laige::World::Options", "kind": "struct", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 319, "signature": "struct Options", "summary": "The declared scene budget (G-R3) and the G-R4 per-frame churn budget, fixed at construction (API-006).", "budget": null, "experimental": false}, + {"name": "laige::World::Options::capacity", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 324, "signature": "std::uint32_t capacity{}", "summary": "The declared scene budget (G-R3). 0 is legal: every create() fails. Values above Entity::kMaxEntities are rejected at construction — the 16-bit id space cannot address them (API-008: the invalid state stays unrepresentable).", "budget": null, "experimental": false}, + {"name": "laige::World::Options::churnPerFrameBudget", "kind": "variable", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 330, "signature": "std::uint32_t churnPerFrameBudget{kDefaultChurnPerFrameBudget}", "summary": "The G-R4 per-frame component-churn budget: the number of component add/remove ops per frame (beginFrame() to beginFrame()) above which the world warns (ecs/churn_per_frame). Strictly-greater semantics; 0 disables the guardrail. Default: kDefaultChurnPerFrameBudget.", "budget": null, "experimental": false}, + {"name": "laige::World::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 336, "signature": "[[nodiscard]] static Result create(Options options) noexcept", "summary": "Construction (setup path: the storage's only backing allocations). capacity > Entity::kMaxEntities -> ErrorCode::InvalidArgument (a handle-space configuration error; the world is not created).", "budget": null, "experimental": false}, + {"name": "laige::World::create", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 341, "signature": "[[nodiscard]] Result create() noexcept", "summary": "Create one entity. O(1), no allocation. Beyond the budget: ErrorCode::BudgetExhausted (the world never grows silently, S-2). Slot assignment is LIFO recycling — deterministic (see preamble).", "budget": null, "experimental": false}, + {"name": "laige::World::destroy", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 351, "signature": "[[nodiscard]] Status destroy(Entity entity) noexcept", "summary": "Destroy one live entity and return its slot to the free list. O(1) for a component-less entity; when the entity is in an archetype, its row is detached first — O(tail rows * row-stride) bytes moved, still no allocation (M1-ECS-03; archetype.h). The slot's generation is bumped, so every stale handle to it fails isValid() (CPP-007). Stale/invalid handle: debug -> assert (S-9); release -> ErrorCode::InvalidArgument + one rate-limited warn (FR-12.3: never silent).", "budget": null, "experimental": false}, + {"name": "laige::World::check", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 358, "signature": "[[nodiscard]] Status check(Entity entity) const noexcept", "summary": "Access validation — the check every entity access performs (M1-ECS-03's component access builds on this). O(1), no allocation. Stale/invalid handle: ErrorCode::InvalidArgument + one rate-limited warn in every build (queries degrade safely, never silent); live: an ok Status.", "budget": null, "experimental": false}, + {"name": "laige::World::isValid", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 361, "signature": "[[nodiscard]] bool isValid(Entity entity) const noexcept", "summary": "Generation-checked liveness (CPP-007). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::World::capacity", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 364, "signature": "[[nodiscard]] std::uint32_t capacity() const noexcept", "summary": "The declared scene budget (World::Options::capacity).", "budget": null, "experimental": false}, + {"name": "laige::World::entityCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 368, "signature": "[[nodiscard]] std::uint32_t entityCount() const noexcept", "summary": "The live entity count right now (the G-R3 numerator; M1-ECS-06 turns the inUse/capacity ratio into the 25%/50%/100% warns).", "budget": null, "experimental": false}, + {"name": "laige::World::stats", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 372, "signature": "[[nodiscard]] EntityStats stats() const noexcept", "summary": "Entity accounting snapshot for the profiler (M1-PROF-01) and the G-R3 guardrail (M1-ECS-06). O(1), no allocation.", "budget": null, "experimental": false}, + {"name": "laige::World::beginFrame", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 387, "signature": "void beginFrame() noexcept", "summary": "Mark the start of a frame (G-R3/G-R4): resets the per-frame component-churn counters and the once-per-frame entity-budget warn flags. O(1), no allocation, no log. The owning loop drives it once per frame (M1-LOOP-01); before the loop exists, the game or tests drive it manually. Never driven, the guardrails degrade to warn-once-per-lifetime (documented, never silent). Reading the per-frame counters: guardrailStats() before the next beginFrame() returns the just-completed frame's values.", "budget": null, "experimental": false}, + {"name": "laige::World::guardrailStats", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 393, "signature": "[[nodiscard]] GuardrailStats guardrailStats() const noexcept", "summary": "The guardrail accounting snapshot for the profiler (M1-PROF-01): the G-R3 level/warn counts, the G-R4 per-frame churn and its budget, and the warn counters (GuardrailStats). O(1), no allocation, no side effects.", "budget": null, "experimental": false}, + {"name": "laige::World::registerComponent", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 414, "signature": "template [[nodiscard]] Result registerComponent() noexcept", "summary": "Register component type T with this world (setup phase, before the loop). Assigns the next ComponentTypeId — dense, in registration order, from 1 — and records sizeof(T)/alignof(T) for the M1-ECS-03 SoA layout. O(n) in the registered types; no allocation. The same path serves built-in and user-defined components (S-8 data-carrier case).", "budget": null, "experimental": false}, + {"name": "laige::World::componentCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 419, "signature": "[[nodiscard]] std::uint32_t componentCount() const noexcept", "summary": "The number of component types registered so far (0 .. kMaxComponentTypes). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::World::componentInfo", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 425, "signature": "[[nodiscard]] Result componentInfo(ComponentTypeId id) const noexcept", "summary": "The size/alignment recorded for the type assigned `id` (the M1-ECS-03 SoA layout reads these). O(1), no allocation. `id` invalid or not registered in this world -> ErrorCode::InvalidArgument.", "budget": null, "experimental": false}, + {"name": "laige::World::has", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 436, "signature": "template [[nodiscard]] bool has(Entity entity) const noexcept", "summary": "True when `entity` is live and has a component of type T. O(1), no allocation, no side effects (a pure query, like isValid: a stale handle is simply \"no\", no warn). T must be a Laige component (LAIGE_COMPONENT); an unregistered T reads as false.", "budget": null, "experimental": false}, + {"name": "laige::World::get", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 446, "signature": "template [[nodiscard]] T* get(Entity entity) noexcept", "summary": "The entity's component of type T, or nullptr: stale/out-of-range handle (after the rate-limited warn-once of check(), every build), T not registered in this world, or the entity lacks T (a normal negative query, no warn). O(1) in the entity count; no allocation. The pointer is valid until the next mutation of that entity's components (an add/remove that moves it shifts the column) or of the world — copy the value out if you must keep it (PERF-005).", "budget": null, "experimental": false}, + {"name": "laige::World::addComponent", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 463, "signature": "template [[nodiscard]] Status addComponent(Entity entity, const T& value) noexcept", "summary": "Give `entity` a component of type T: create-or-update. When the entity already has T, `value` overwrites it in place (the archetype does not change). Otherwise the entity moves to the archetype of its component set plus T — a pool-backed move over pre-reserved columns: O((tail rows) * row-stride) bytes moved, no heap allocation in steady state (growth events are bounded, accounted, and logged — archetype.h \"Reserve policy\").", "budget": null, "experimental": false}, + {"name": "laige::World::removeComponent", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 471, "signature": "template [[nodiscard]] Status removeComponent(Entity entity) noexcept", "summary": "Take the component of type T from `entity` (a no-op ok Status when the entity lacks T or has no components). Otherwise the entity moves to the archetype of its component set minus T — same cost and allocation contract as addComponent. Stale/invalid handle or unregistered T -> InvalidArgument (+ warn).", "budget": null, "experimental": false}, + {"name": "laige::World::archetypeCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 477, "signature": "[[nodiscard]] std::uint32_t archetypeCount() const noexcept", "summary": "The number of distinct component sets seen by this world so far (0 .. kMaxArchetypes; archetypes are never destroyed in M1). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::World::archetypeStats", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 482, "signature": "[[nodiscard]] ArchetypeStats archetypeStats() const noexcept", "summary": "Archetype storage accounting snapshot (ArchetypeStats): the profiler (M1-PROF-01) and the zero-overflow/zero-allocation checks read this. O(kMaxArchetypes), no allocation.", "budget": null, "experimental": false}, + {"name": "laige::World::each", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 517, "signature": "template [[nodiscard]] Status each(F&& fn, Acc...) noexcept", "summary": "Iterate every entity having ALL of T1..TN (superset match: extra components do not exclude an entity), invoking `fn(Entity, R1, ..., RN)` — one reference per listed component, in template order: a `const T&` where the access tag is Read, a `T&` where it is Write. The access tags follow `fn`, one Read/Write tag per listed component, in the same order (checked at compile time — they come after the callable because a pack of parameters must be the last parameters to be deducible); `each<>` (no components, no tags) visits every live entity in ascending slot-id order with no component references.", "budget": null, "experimental": false}, + {"name": "laige::World::clear", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 531, "signature": "[[nodiscard]] Status clear() noexcept", "summary": "Destroy every live entity (shutdown path, CONC-006). Every handle becomes stale; the capacity is unchanged and the world is immediately reusable. O(capacity + detached rows * row-stride), no allocation, idempotent. M1-ECS-03: each live entity is detached from its archetype first (the per-entity component data is released with its row); the archetypes themselves — and the component type registry — survive. M1-ECS-04: rejected with ErrorCode::InvalidArgument (+ one rate-limited warn) while an iteration is active and any matched archetype still holds live rows — the clear is skipped, never partial (assert in debug; query.h \"Iteration legality\"); an ok Status otherwise.", "budget": null, "experimental": false}, + {"name": "laige::World::World", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 535, "signature": "World(World&& other) noexcept", "summary": "Move is an O(1) pointer swap; the source becomes a valid empty world (capacity 0: every create() fails, every handle invalid).", "budget": null, "experimental": false}, + {"name": "laige::World::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 536, "signature": "World& operator=(World&& other) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::World::World", "kind": "constructor", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 537, "signature": "World(const World&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::World::operator=", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 538, "signature": "World& operator=(const World&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::World::~World", "kind": "destructor", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 543, "signature": "~World() noexcept", "summary": "Detaches every live entity's component rows (clear()) and releases the backing storage (per-slot tables, archetype table with its column blocks, type-key index). Idempotent with clear().", "budget": null, "experimental": false}, {"name": "laige::Access", "kind": "enum", "header": "src/laige-sim/include/laige/sim/query.h", "line": 234, "signature": "enum class Access : std::uint8_t", "summary": "The declared per-component access of a query (FR-1.3). Read: the component is only read during the iteration; Write: the system mutates it (through the query's reference or an in-place addComponent overwrite — both legal, see the preamble \"Iteration legality\"). M1-SYS-01's system I/O declarations reuse this value type.", "budget": null, "experimental": false}, {"name": "laige::Access::Read", "kind": "enumerator", "header": "src/laige-sim/include/laige/sim/query.h", "line": 235, "signature": "Read = 0", "summary": null, "budget": null, "experimental": false}, {"name": "laige::Access::Write", "kind": "enumerator", "header": "src/laige-sim/include/laige/sim/query.h", "line": 236, "signature": "Write = 1", "summary": null, "budget": null, "experimental": false}, diff --git a/roadmap/M1-heartbeat.md b/roadmap/M1-heartbeat.md index d28f833..d6e5605 100644 --- a/roadmap/M1-heartbeat.md +++ b/roadmap/M1-heartbeat.md @@ -70,7 +70,7 @@ zero-allocation property (M1-ALLOC-01 enforces it once it exists; before that, A - **Verify:** `ctest -R iter_order` green (property test with fixed PRNG seed). - **Size:** ~100 lines + tests -- [ ] **M1-ECS-06 · ECS guardrails (G-R3, G-R4)** +- [x] **M1-ECS-06 · ECS guardrails (G-R3, G-R4)** - **Refs:** PRD §9.3 G-R3, G-R4; FR-12.3 - **Depends:** M1-ECS-01, M0-CORE-02 - **Scope:** diff --git a/src/laige-sim/CMakeLists.txt b/src/laige-sim/CMakeLists.txt index ab1e12d..8be8e15 100644 --- a/src/laige-sim/CMakeLists.txt +++ b/src/laige-sim/CMakeLists.txt @@ -19,8 +19,11 @@ # index); entity.cpp keeps the handle/slot storage and the World # lifecycle. M1-ECS-04 adds query.cpp: the iteration-legality guard # behind World::each (the query template itself is header-defined in -# entity.h, next to the component access templates). -set(LAIGE_SIM_SOURCES entity.cpp archetype.cpp query.cpp) +# entity.h, next to the component access templates). M1-ECS-06 adds +# guardrails.cpp: the G-R3 entity-count thresholds and the G-R4 +# per-frame churn budget (the check hooks live in entity.cpp's +# create() and entity.h's add/remove templates). +set(LAIGE_SIM_SOURCES entity.cpp archetype.cpp query.cpp guardrails.cpp) if(LAIGE_BUILD_SHARED) add_library(laige-sim SHARED ${LAIGE_SIM_SOURCES}) diff --git a/src/laige-sim/entity.cpp b/src/laige-sim/entity.cpp index 50d9dde..b67b113 100644 --- a/src/laige-sim/entity.cpp +++ b/src/laige-sim/entity.cpp @@ -57,6 +57,19 @@ World::World(World&& other) noexcept totalAdds_(other.totalAdds_), totalRemoves_(other.totalRemoves_), totalArchetypeGrowth_(other.totalArchetypeGrowth_), totalReservations_(other.totalReservations_), + churnPerFrameBudget_(other.churnPerFrameBudget_), + entityThreshold_{other.entityThreshold_[0], other.entityThreshold_[1], + other.entityThreshold_[2]}, + entityBudgetWarnedThisFrame_{other.entityBudgetWarnedThisFrame_[0], + other.entityBudgetWarnedThisFrame_[1], + other.entityBudgetWarnedThisFrame_[2]}, + churnWarnedThisFrame_(other.churnWarnedThisFrame_), + frameAdds_(other.frameAdds_), + frameRemoves_(other.frameRemoves_), + entityBudgetWarns_{other.entityBudgetWarns_[0], + other.entityBudgetWarns_[1], + other.entityBudgetWarns_[2]}, + churnWarns_(other.churnWarns_), iterationActive_(other.iterationActive_), iterationArchetypes_(other.iterationArchetypes_), iterationReadComponents_(other.iterationReadComponents_) { @@ -71,6 +84,20 @@ World::World(World&& other) noexcept other.totalRemoves_ = 0; other.totalArchetypeGrowth_ = 0; other.totalReservations_ = 0; + // M1-ECS-06: the guardrail state travels with the storage; the + // moved-from world must be a valid empty world in every field (a + // zero budget disables the G-R4 check on it — nothing can churn + // there anyway: every create() fails). + other.churnPerFrameBudget_ = 0; + for (std::uint32_t i = 0; i < 3; ++i) { + other.entityThreshold_[i] = 0; + other.entityBudgetWarnedThisFrame_[i] = false; + other.entityBudgetWarns_[i] = 0; + } + other.churnWarnedThisFrame_ = false; + other.frameAdds_ = 0; + other.frameRemoves_ = 0; + other.churnWarns_ = 0; // The iteration guard travels with the storage (a live world cannot // be moved while an iteration is active — the iteration is // synchronous on the owner thread — but the moved-from world must @@ -108,6 +135,18 @@ World& World::operator=(World&& other) noexcept { totalRemoves_ = other.totalRemoves_; totalArchetypeGrowth_ = other.totalArchetypeGrowth_; totalReservations_ = other.totalReservations_; + // M1-ECS-06: the guardrail state is re-taken from `other` (moved- + // from world emptied below). + churnPerFrameBudget_ = other.churnPerFrameBudget_; + for (std::uint32_t i = 0; i < 3; ++i) { + entityThreshold_[i] = other.entityThreshold_[i]; + entityBudgetWarnedThisFrame_[i] = other.entityBudgetWarnedThisFrame_[i]; + entityBudgetWarns_[i] = other.entityBudgetWarns_[i]; + } + churnWarnedThisFrame_ = other.churnWarnedThisFrame_; + frameAdds_ = other.frameAdds_; + frameRemoves_ = other.frameRemoves_; + churnWarns_ = other.churnWarns_; iterationActive_ = other.iterationActive_; iterationArchetypes_ = other.iterationArchetypes_; iterationReadComponents_ = other.iterationReadComponents_; @@ -122,6 +161,19 @@ World& World::operator=(World&& other) noexcept { other.totalRemoves_ = 0; other.totalArchetypeGrowth_ = 0; other.totalReservations_ = 0; + // M1-ECS-06: the guardrail state is re-taken from `other` above; + // the moved-from world is a valid empty world in every field (a + // zero budget disables the G-R4 check on it). + other.churnPerFrameBudget_ = 0; + for (std::uint32_t i = 0; i < 3; ++i) { + other.entityThreshold_[i] = 0; + other.entityBudgetWarnedThisFrame_[i] = false; + other.entityBudgetWarns_[i] = 0; + } + other.churnWarnedThisFrame_ = false; + other.frameAdds_ = 0; + other.frameRemoves_ = 0; + other.churnWarns_ = 0; other.iterationActive_ = false; other.iterationArchetypes_ = detail::IdSet256{}; other.iterationReadComponents_ = detail::IdSet256{}; @@ -136,6 +188,9 @@ Result World::create(Options options) noexcept { } World w; w.capacity_ = options.capacity; + // M1-ECS-06 (G-R3/G-R4): the guardrail configuration (setup path). + w.churnPerFrameBudget_ = options.churnPerFrameBudget; + w.initEntityBudgetThresholds(); // Component registry table (M1-ECS-02): the fixed engine-level // budget (kMaxComponentTypes), a setup-path allocation like the // entity tables below. @@ -181,6 +236,8 @@ Result World::create() noexcept { ++inUse_; if (inUse_ > peakInUse_) peakInUse_ = inUse_; ++totalCreated_; + // M1-ECS-06 (G-R3): the 25/50/100% crossing check (guardrails.cpp). + checkEntityBudget(); return Entity{slot, generations_[slot]}; } diff --git a/src/laige-sim/guardrails.cpp b/src/laige-sim/guardrails.cpp new file mode 100644 index 0000000..c239e6b --- /dev/null +++ b/src/laige-sim/guardrails.cpp @@ -0,0 +1,207 @@ +// laige-sim ECS guardrails (M1-ECS-06): the G-R3 entity-count +// thresholds and the G-R4 per-frame component-churn budget (PRD +// §9.3; FR-12.3, S-9). +// +// Implementation of the guardrail methods declared in +// include/laige/sim/entity.h — see that header (the preamble section +// "Guardrails") and docs/api/entity.md for the full contract: +// +// G-R3 create() emits one structured warn exactly when the live +// count reaches 25%/50%/100% of the declared scene budget +// (integer thresholds capacity * pct / 100), at most once per +// level per frame. +// G-R4 the per-frame component add/remove count emits one +// structured warn when it strictly exceeds +// Options::churnPerFrameBudget (0 disables the guardrail), +// at most once per frame. +// +// Message text follows the NFR-13.3 5-field error grammar +// ({code} | {what} | {why} | {fix} | {doc_anchor}) and is +// build-stable: machine-parseable output must not depend on the build +// type. Per PRD §9.3 ("warn (debug: with advice)"), the advice is +// carried as an extra structured FIELD in debug builds only — never +// as message text. +// +// Hot-path cost: three threshold comparisons per create(), one counter +// increment plus one comparison per counted add/remove — pure integer +// bookkeeping, no allocation (PERF-003). The warn paths are cold (a +// budget being crossed); their Field values construct only when the +// event is enabled (LOG-003). + +#include "laige/sim/entity.h" + +#include + +#include "laige/logging.h" + +namespace laige { + +namespace { + +// The stable subsystem name for ECS events (LOG-001; entity.cpp). +inline constexpr const char* kEcsSubsystem = "ecs"; + +// The G-R3 levels in ascending rank order (CORE-005: the single source +// for the thresholds, the event names, the level field, and the +// GuardrailStats index). +inline constexpr std::uint32_t kEntityBudgetLevels[3] = {25u, 50u, 100u}; + +// One stable event name per level (LOG-001: distinct per level so the +// facade's per-(subsystem, event, severity) rate limit cannot merge +// two levels' warns into one window). +inline constexpr const char* kEntityBudgetEvent[3] = { + "entity_budget_25", "entity_budget_50", "entity_budget_100"}; + +// NFR-13.3 5-field grammar, identical in every build (machine- +// parseable, stable): {code} | {what} | {why} | {fix} | {doc_anchor}. +inline constexpr const char* kEntityBudgetMessage[3] = { + "entity_budget_25 | live entities reached 25% of the declared scene " + "budget | the scene budget is filling up; 75% of the budget remains " + "| reduce simultaneous entity count or raise the scene budget through " + "typed configuration (World::Options::capacity) | " + "docs/api/entity.md#guardrails", + "entity_budget_50 | live entities reached 50% of the declared scene " + "budget | the scene budget is half full; create() starts failing " + "with BudgetExhausted at 100% | profile the entity composition " + "(M1-PROF-01) and reduce simultaneous entity count or raise the " + "scene budget through typed configuration | " + "docs/api/entity.md#guardrails", + "entity_budget_100 | live entities reached 100% of the declared " + "scene budget | the scene budget is full; the next create() fails " + "with BudgetExhausted | destroy entities before spawning more, or " + "raise the scene budget through typed configuration | " + "docs/api/entity.md#guardrails", +}; + +#ifndef NDEBUG +// The PRD §9.3 debug-only advice (G-R3: "warn (debug: with advice)"). +// A structured field, never message text: the 5-field grammar stays +// build-stable (NFR-13.3). +inline constexpr const char* kEntityBudgetAdvice[3] = { + "headroom remains: no action needed yet — monitor the entity " + "composition in the profiler (M1-PROF-01) before the 50% level", + "profile the entity composition (M1-PROF-01 counters) before " + "raising the budget — identify which systems hold the most live " + "entities", + "spawn paths now fail with BudgetExhausted — move spawning into a " + "budgeted spawn/despawn system that reclaims entities before it " + "spawns", +}; +#endif + +inline constexpr const char* kChurnEvent = "churn_per_frame"; +inline constexpr const char* kChurnMessage = + "churn_per_frame | component add/remove churn exceeded the per-frame " + "budget | per-frame entity lifecycle churn is unbatched and spikes " + "tick time | reduce per-frame add/remove churn (batch lifecycle " + "work) | docs/api/entity.md#guardrails"; + +#ifndef NDEBUG +// The PRD §9.3 G-R4 advice text, debug-only (see the entity-budget +// note: a field, never message text). +inline constexpr const char* kChurnAdvice = + "move the churn to a spawn/despawn system — batch entity lifecycle " + "(create/destroy/addComponent/removeComponent) in one bounded " + "per-frame system (PRD §9.3 G-R4)"; +#endif + +} // namespace + +void World::initEntityBudgetThresholds() noexcept { + // capacity <= Entity::kMaxEntities (65536) and the levels are <= + // 100, so capacity * level cannot overflow a uint32 + // (65536 * 100 = 6,553,600 < 2^32). + for (std::uint32_t i = 0; i < 3; ++i) { + entityThreshold_[i] = capacity_ * kEntityBudgetLevels[i] / 100u; + } +} + +void World::beginFrame() noexcept { + // The per-frame guardrail state: the G-R4 churn counters restart at + // 0 and the once-per-frame warn flags clear, so the next frame can + // re-warn a still-broken level (and only a NEW crossing within that + // frame warns — see checkEntityBudget). + frameAdds_ = 0; + frameRemoves_ = 0; + for (std::uint32_t i = 0; i < 3; ++i) { + entityBudgetWarnedThisFrame_[i] = false; + } + churnWarnedThisFrame_ = false; +} + +GuardrailStats World::guardrailStats() const noexcept { + // The highest budget percentage the PEAK live count reached: the + // thresholds are monotone in the level, so a linear scan is exact + // (a 0 threshold never matches: the peak is 0 only on an empty + // world, and 0 >= 0 would be a false "reached"). + std::uint32_t level = 0; + for (std::uint32_t i = 0; i < 3; ++i) { + if (entityThreshold_[i] != 0 && peakInUse_ >= entityThreshold_[i]) { + level = kEntityBudgetLevels[i]; + } + } + return GuardrailStats{ + capacity_, + inUse_, + level, + {entityBudgetWarns_[0], entityBudgetWarns_[1], entityBudgetWarns_[2]}, + frameAdds_ + frameRemoves_, + churnPerFrameBudget_, + churnWarns_}; +} + +void World::checkEntityBudget() noexcept { + // create() bumps inUse_ by exactly 1, so a level's threshold is + // crossed exactly at inUse_ == threshold (no other value can be a + // first reach). A 0 threshold never fires: the live count is 0 only + // before the first create. + for (std::uint32_t i = 0; i < 3; ++i) { + if (entityThreshold_[i] != 0 && inUse_ == entityThreshold_[i] && + !entityBudgetWarnedThisFrame_[i]) { + // Once per level per frame (the roadmap's "no duplicates"): the + // flag suppresses a same-frame down-cross + up-cross; beginFrame + // clears it for the next frame. + entityBudgetWarnedThisFrame_[i] = true; + ++entityBudgetWarns_[i]; +#ifndef NDEBUG + LAIGE_LOG_WARN(kEcsSubsystem, kEntityBudgetEvent[i], + kEntityBudgetMessage[i], + laige::log::field("entity_count", inUse_), + laige::log::field("capacity", capacity_), + laige::log::field("level", kEntityBudgetLevels[i]), + laige::log::field("advice", kEntityBudgetAdvice[i])); +#else + LAIGE_LOG_WARN(kEcsSubsystem, kEntityBudgetEvent[i], + kEntityBudgetMessage[i], + laige::log::field("entity_count", inUse_), + laige::log::field("capacity", capacity_), + laige::log::field("level", kEntityBudgetLevels[i])); +#endif + } + } +} + +void World::checkChurnBudget() noexcept { + // 0 disables the guardrail (World::Options); the flag makes the warn + // fire at most once per frame even while the churn stays above the + // budget. Strictly-greater: churn == budget is legal, churn > + // budget is the breach (PRD §9.3: "> threshold/tick"). + if (churnPerFrameBudget_ == 0 || churnWarnedThisFrame_) return; + if (frameAdds_ + frameRemoves_ <= churnPerFrameBudget_) return; + churnWarnedThisFrame_ = true; + ++churnWarns_; +#ifndef NDEBUG + LAIGE_LOG_WARN(kEcsSubsystem, kChurnEvent, kChurnMessage, + laige::log::field("frame_churn", + frameAdds_ + frameRemoves_), + laige::log::field("churn_budget", churnPerFrameBudget_), + laige::log::field("advice", kChurnAdvice)); +#else + LAIGE_LOG_WARN(kEcsSubsystem, kChurnEvent, kChurnMessage, + laige::log::field("frame_churn", + frameAdds_ + frameRemoves_), + laige::log::field("churn_budget", churnPerFrameBudget_)); +#endif +} + +} // namespace laige diff --git a/src/laige-sim/include/laige/sim/entity.h b/src/laige-sim/include/laige/sim/entity.h index 0aeb7c0..3b30b85 100644 --- a/src/laige-sim/include/laige/sim/entity.h +++ b/src/laige-sim/include/laige/sim/entity.h @@ -76,7 +76,9 @@ // configuration change, never a runtime behavior. 0 is legal (every // create() fails). create() beyond the budget returns // ErrorCode::BudgetExhausted — the world never grows silently (S-2, -// G-R1). The G-R3 warn thresholds (25%/50%/100%) land with M1-ECS-06. +// G-R1). The G-R3 warn thresholds (25%/50%/100%) and the G-R4 +// per-frame churn budget are enforced by M1-ECS-06 (see the +// "Guardrails" section below; guardrails.cpp). // // World::create(Options) performs the storage's only backing // allocations (a setup path, never a hot path). Every create()/ @@ -86,6 +88,39 @@ // stats() accounting is the check (M1 milestone rules). // // --------------------------------------------------------------------------- +// Guardrails (G-R3, G-R4; M1-ECS-06; PRD §9.3) +// --------------------------------------------------------------------------- +// +// G-R3 (entity count): create() emits one structured warn exactly +// when the live count REACHES 25%/50%/100% of the declared scene +// budget — integer thresholds capacity * pct / 100 (a level whose +// threshold computes to 0 never fires: the live count is 0 only +// before the first create). A level warns at most ONCE PER FRAME: +// dipping below and re-crossing within the same frame does not +// re-warn. Frame boundaries are driven by beginFrame(); without one +// the guardrail degrades to warn-once-per-lifetime (documented, +// never silent). Events: ecs/entity_budget_{25,50,100}. +// +// G-R4 (per-frame component churn): the successful addComponent +// calls (including in-place overwrites — the same counting as +// ArchetypeStats::totalAdds) plus the removeComponent calls that +// actually detach a row (no-op removes, destroy/clear detaches, and +// the BudgetExhausted/invalid rejects are not counted) are counted +// per frame. When the per-frame total STRICTLY EXCEEDS +// Options::churnPerFrameBudget, one ecs/churn_per_frame warn fires +// per frame. Budget 0 disables the guardrail. +// +// Both guardrails are O(1) integer bookkeeping on the hot path (no +// allocation — the warn paths are cold: fields construct only when +// the event is enabled, LOG-003). Their counters are pulled by the +// profiler (M1-PROF-01) through guardrailStats() (a plain value, no +// allocation, no side effects). Message text follows the NFR-13.3 +// 5-field error grammar ({code} | {what} | {why} | {fix} | +// {doc_anchor}) and is build-stable; per PRD §9.3 ("warn (debug: +// with advice)"), debug builds additionally carry an `advice` +// structured FIELD — never message text. +// +// --------------------------------------------------------------------------- // Ownership, threading, determinism // --------------------------------------------------------------------------- // @@ -173,6 +208,41 @@ struct EntityStats { std::size_t bytesInUse{}; }; +// The default G-R4 per-frame component-churn budget (CORE-005). At +// the M1 reference scene (10k entities, PRD §8.1) 256 lifecycle +// ops per frame is ~2.6% of the scene — steady-state gameplay stays +// far below it; a sustained breach indicates unbatched spawn/despawn +// churn on the hot path (the guardrail's advice). Overridable per +// world (World::Options::churnPerFrameBudget); scenes with a +// legitimately churning lifecycle raise it through typed +// configuration, and 0 disables the guardrail. +inline constexpr std::uint32_t kDefaultChurnPerFrameBudget = 256; + +// M1-ECS-06 (G-R3, G-R4) guardrail snapshot. A plain value the M1 +// profiler (M1-PROF-01) pulls each frame (World::guardrailStats()); +// mirrors the EntityStats/ArchetypeStats snapshot shape: +// +// capacity the declared scene budget (G-R3 denominator) +// entityCount live entities right now (G-R3 numerator) +// entityBudgetLevel 0, 25, 50, or 100 — the highest percentage of +// the budget the PEAK live count reached since +// construction (0 = never reached 25%) +// entityBudgetWarns per-level warn counts since construction +// (index 0 = 25%, 1 = 50%, 2 = 100%) +// frameChurn component adds + removes since the last +// beginFrame() (the G-R4 numerator) +// churnPerFrameBudget the configured G-R4 budget (0 = disabled) +// churnWarns churn warnings issued since construction +struct GuardrailStats { + std::uint32_t capacity{}; + std::uint32_t entityCount{}; + std::uint32_t entityBudgetLevel{}; + std::uint32_t entityBudgetWarns[3]{}; + std::uint64_t frameChurn{}; + std::uint32_t churnPerFrameBudget{}; + std::uint32_t churnWarns{}; +}; + // M1-ECS-04 query helpers (detail: engine implementation, excluded // from the public API scan). Declared before World: the member // templates of the World class reference them by qualified name at @@ -244,13 +314,20 @@ inline constexpr bool isAccessTags() { // allocation, ownership, threading, and determinism contracts. class World { public: - // The declared scene budget (G-R3), fixed at construction (API-006). - // 0 is legal: every create() fails. Values above - // Entity::kMaxEntities are rejected at construction — the 16-bit id - // space cannot address them (API-008: the invalid state stays - // unrepresentable). + // The declared scene budget (G-R3) and the G-R4 per-frame churn + // budget, fixed at construction (API-006). struct Options { + // The declared scene budget (G-R3). 0 is legal: every create() + // fails. Values above Entity::kMaxEntities are rejected at + // construction — the 16-bit id space cannot address them + // (API-008: the invalid state stays unrepresentable). std::uint32_t capacity{}; + // The G-R4 per-frame component-churn budget: the number of + // component add/remove ops per frame (beginFrame() to + // beginFrame()) above which the world warns + // (ecs/churn_per_frame). Strictly-greater semantics; 0 disables + // the guardrail. Default: kDefaultChurnPerFrameBudget. + std::uint32_t churnPerFrameBudget{kDefaultChurnPerFrameBudget}; }; // Construction (setup path: the storage's only backing allocations). @@ -294,6 +371,27 @@ class World { // G-R3 guardrail (M1-ECS-06). O(1), no allocation. [[nodiscard]] EntityStats stats() const noexcept; + // --------------------------------------------------------------- + // ECS guardrails (M1-ECS-06: G-R3, G-R4; full contract in the + // header preamble "Guardrails" and docs/api/entity.md) + // --------------------------------------------------------------- + + // Mark the start of a frame (G-R3/G-R4): resets the per-frame + // component-churn counters and the once-per-frame entity-budget + // warn flags. O(1), no allocation, no log. The owning loop drives + // it once per frame (M1-LOOP-01); before the loop exists, the game + // or tests drive it manually. Never driven, the guardrails + // degrade to warn-once-per-lifetime (documented, never silent). + // Reading the per-frame counters: guardrailStats() before the next + // beginFrame() returns the just-completed frame's values. + void beginFrame() noexcept; + + // The guardrail accounting snapshot for the profiler (M1-PROF-01): + // the G-R3 level/warn counts, the G-R4 per-frame churn and its + // budget, and the warn counters (GuardrailStats). O(1), no + // allocation, no side effects. + [[nodiscard]] GuardrailStats guardrailStats() const noexcept; + // ----------------------------------------------------------------- // Component registry (M1-ECS-02; full contract in component.h) // ----------------------------------------------------------------- @@ -552,6 +650,25 @@ class World { // iteration still holds live rows (ecs/iteration_clear). [[nodiscard]] Status guardClear() noexcept; + // ------------------------------------------------------------- + // M1-ECS-06 guardrail checks (G-R3/G-R4; defined in guardrails.cpp) + // ------------------------------------------------------------- + + // Compute the per-level entity-budget thresholds (25/50/100% of + // the declared capacity; a 0 threshold never fires) — setup path, + // called from World::create(Options). + void initEntityBudgetThresholds() noexcept; + + // G-R3: the per-level crossing check, run at the end of every + // successful create(). Emits the ecs/entity_budget_{25,50,100} + // warns (at most once per level per frame). + void checkEntityBudget() noexcept; + + // G-R4: the per-frame churn-budget check, run after every counted + // add/remove. Emits the ecs/churn_per_frame warn (at most once per + // frame). + void checkChurnBudget() noexcept; + // M1-ECS-04 query helpers: compile-time recursion over the listed // components (N ≤ 32 — the M1 bound). Recursion, not a fold: the // per-index component TYPE must reach a template argument, which a @@ -681,6 +798,18 @@ class World { // Rows moved by attachSlot/removeRow tail shifts (ArchetypeStats feed; // the churn test's deterministic work KAT — archetype.h). std::uint64_t totalRowShifts_{0}; + // M1-ECS-06 guardrails (G-R3/G-R4; guardrails.cpp). Per-frame state + // is reset by beginFrame(); the rest is since-construction. The + // per-level arrays are indexed by level rank (0 = 25%, 1 = 50%, + // 2 = 100% — see the header preamble "Guardrails"). + std::uint32_t churnPerFrameBudget_{0}; + std::uint32_t entityThreshold_[3]{}; // capacity * level / 100 (0: never fires) + bool entityBudgetWarnedThisFrame_[3]{}; // G-R3 once-per-frame flags + bool churnWarnedThisFrame_{false}; // G-R4 once-per-frame flag + std::uint64_t frameAdds_{0}; + std::uint64_t frameRemoves_{0}; + std::uint32_t entityBudgetWarns_[3]{}; // per-level warn counts + std::uint32_t churnWarns_{0}; // Iteration-legality guard (M1-ECS-04; query.h): live while a // World::each runs, on the world's single owner thread. The matched // set names the archetypes the active query visits (complete before @@ -844,6 +973,10 @@ Status World::addComponent(Entity entity, const T& value) noexcept { detail::copyRow(column.base + static_cast(curRow) * column.size, reinterpret_cast(&value), sizeof(T)); ++totalAdds_; + // M1-ECS-06 (G-R4): an in-place overwrite is a counted add + // (the ArchetypeStats::totalAdds semantics). + ++frameAdds_; + checkChurnBudget(); return Status{}; } } @@ -939,6 +1072,9 @@ Status World::addComponent(Entity entity, const T& value) noexcept { } if (curIdx != 0) removeRow(archetypes_[curIdx - 1], curRow); ++totalAdds_; + // M1-ECS-06 (G-R4): the structural add is counted (above). + ++frameAdds_; + checkChurnBudget(); return Status{}; } @@ -981,6 +1117,9 @@ Status World::removeComponent(Entity entity) noexcept { archetypeOf_[entity.id] = 0; rowOf_[entity.id] = 0; ++totalRemoves_; + // M1-ECS-06 (G-R4): the detached row is a counted remove. + ++frameRemoves_; + checkChurnBudget(); return Status{}; } // Build the target signature (the entity's set minus T, still @@ -1033,6 +1172,9 @@ Status World::removeComponent(Entity entity) noexcept { } removeRow(cur, curRow); ++totalRemoves_; + // M1-ECS-06 (G-R4): the detached row is a counted remove. + ++frameRemoves_; + checkChurnBudget(); return Status{}; } diff --git a/tests/laige-sim/CMakeLists.txt b/tests/laige-sim/CMakeLists.txt index 658198f..b88fbeb 100644 --- a/tests/laige-sim/CMakeLists.txt +++ b/tests/laige-sim/CMakeLists.txt @@ -1,19 +1,21 @@ -# laige-sim tests (M1-ECS-01/02/03/04/05): entity handle + World entity -# storage, component type registry, archetype SoA storage, query API + -# iteration legality, deterministic iteration order. +# laige-sim tests (M1-ECS-01/02/03/04/05/06): entity handle + World +# entity storage, component type registry, archetype SoA storage, +# query API + iteration legality, deterministic iteration order, and +# the ECS guardrails (G-R3/G-R4). # # One executable per module (tests/README.md; docs/testing.md is the # source of truth): laige-sim_tests links the module under test plus # gtest_main. The unfiltered entry runs the whole module; the `entity`, -# `component_registry`, `archetype`, `query`, and `iter_order` entries -# are the M1-ECS-01, M1-ECS-02, M1-ECS-03, M1-ECS-04, and M1-ECS-05 -# Verify commands (`ctest -R entity`, `ctest -R component_registry`, -# `ctest -R archetype`, `ctest -R query`, `ctest -R iter_order`), -# selecting exactly the suites below from the shared executable. +# `component_registry`, `archetype`, `query`, `iter_order`, and +# `ecs_guardrails` entries are the M1-ECS-01, M1-ECS-02, M1-ECS-03, +# M1-ECS-04, M1-ECS-05, and M1-ECS-06 Verify commands (`ctest -R entity`, +# `ctest -R component_registry`, `ctest -R archetype`, `ctest -R query`, +# `ctest -R iter_order`, `ctest -R ecs_guardrails`), selecting exactly +# the suites below from the shared executable. set(LAIGE_SIM_TEST_SOURCES entity_tests.cpp component_registry_tests.cpp archetype_tests.cpp query_tests.cpp - iter_order_tests.cpp) + iter_order_tests.cpp ecs_guardrails_tests.cpp) # M1-ECS-03: the test-only allocation counter overrides the global # operator new/new[]; the sanitizer runtimes define their own # new/delete (strong symbols in the Clang/GCC TSan runtime archives, @@ -91,9 +93,18 @@ add_test(NAME iter_order COMMAND laige-sim_tests --gtest_filter=IterOrder.*) +# M1-ECS-06: ECS guardrails (G-R3 entity-count thresholds, G-R4 +# per-frame component churn). The step's Verify command is +# `ctest -R ecs_guardrails`; this entry selects exactly the +# EcsGuardrails suites from the shared laige-sim_tests executable. +add_test(NAME ecs_guardrails + COMMAND laige-sim_tests + --gtest_filter=EcsGuardrails.*) + if(LAIGE_TSAN) # Make the first data race report fatal to the test process (NFR-8.2), # so ctest fails loudly on any TSan report. set_tests_properties(laige-sim_tests entity component_registry archetype - query iter_order PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") + query iter_order ecs_guardrails + PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") endif() diff --git a/tests/laige-sim/archetype_tests.cpp b/tests/laige-sim/archetype_tests.cpp index 5b372a2..19d897e 100644 --- a/tests/laige-sim/archetype_tests.cpp +++ b/tests/laige-sim/archetype_tests.cpp @@ -863,12 +863,36 @@ TEST(ArchetypeLogging, ArchetypeBudgetWarnsOnce) { } } - ASSERT_EQ(sinkPtr->entries.size(), 1u); - EXPECT_EQ(sinkPtr->entries[0].severity, laige::log::Severity::Warn); - EXPECT_EQ(sinkPtr->entries[0].subsystem, "ecs"); - EXPECT_EQ(sinkPtr->entries[0].event, "archetype_budget"); + // M1-ECS-06: this window also trips the guardrail warns — the 259 + // creates cross the 25% (inUse 75) and 50% (inUse 150) entity- + // budget levels, and the setup-phase adds (780 component ops, no + // beginFrame driven) exceed the default 256 per-frame churn + // budget. The expected events are counted by event name (LOG-001), + // not by position. + std::size_t budget25 = 0; + std::size_t budget50 = 0; + std::size_t churn = 0; + std::size_t archBudget = 0; + const MemorySink::Entry* archEntry = nullptr; + for (const auto& e : sinkPtr->entries) { + if (e.event == "entity_budget_25") ++budget25; + if (e.event == "entity_budget_50") ++budget50; + if (e.event == "churn_per_frame") ++churn; + if (e.event == "archetype_budget") { + ++archBudget; + archEntry = &e; + } + } + EXPECT_EQ(budget25, 1u); + EXPECT_EQ(budget50, 1u); + EXPECT_EQ(churn, 1u); + ASSERT_EQ(archBudget, 1u); + ASSERT_EQ(sinkPtr->entries.size(), 4u); + ASSERT_NE(archEntry, nullptr); + EXPECT_EQ(archEntry->severity, laige::log::Severity::Warn); + EXPECT_EQ(archEntry->subsystem, "ecs"); bool foundCount = false; - for (const auto& [key, value] : sinkPtr->entries[0].fields) { + for (const auto& [key, value] : archEntry->fields) { if (key == "archetype_count" && value == "256") foundCount = true; } EXPECT_TRUE(foundCount); @@ -876,10 +900,14 @@ TEST(ArchetypeLogging, ArchetypeBudgetWarnsOnce) { // Controlled shutdown drains the pending rate-limit summary // (CONC-006/LOG-007/LOG-004). laige::log::Logger::instance().shutdown(); - ASSERT_EQ(sinkPtr->entries.size(), 2u); - EXPECT_EQ(sinkPtr->entries[1].event, laige::log::kRateLimitedEvent); + ASSERT_EQ(sinkPtr->entries.size(), 5u); + const MemorySink::Entry* summary = nullptr; + for (const auto& e : sinkPtr->entries) { + if (e.event == laige::log::kRateLimitedEvent) summary = &e; + } + ASSERT_NE(summary, nullptr); bool foundSuppressed = false; - for (const auto& [key, value] : sinkPtr->entries[1].fields) { + for (const auto& [key, value] : summary->fields) { if (key == "suppressed" && value == "2") foundSuppressed = true; } EXPECT_TRUE(foundSuppressed); diff --git a/tests/laige-sim/ecs_guardrails_tests.cpp b/tests/laige-sim/ecs_guardrails_tests.cpp new file mode 100644 index 0000000..eba36c6 --- /dev/null +++ b/tests/laige-sim/ecs_guardrails_tests.cpp @@ -0,0 +1,652 @@ +// laige-sim ECS guardrails suite (M1-ECS-06): the G-R3 entity-count +// thresholds and the G-R4 per-frame component-churn budget (PRD +// §9.3; roadmap/M1-heartbeat.md step M1-ECS-06). +// +// Step Verify scope: +// - the entity-count guardrail warns exactly at 25%/50%/100% of the +// declared scene budget, at most once per level per frame (no +// duplicates), with a structured log + counter +// - the per-frame component-churn counter warns when a frame's +// adds+removes strictly exceed the configured budget, at most +// once per frame; the "move to a spawn/despawn system" advice is +// present in debug builds +// - the warn messages follow the NFR-13.3 5-field error grammar +// ({code} | {what} | {why} | {fix} | {doc_anchor}) +// - both guardrails are exposed to the profiler (M1-PROF-01) +// through guardrailStats() +// - the guardrail hot path allocates nothing below the thresholds +// (the M1 zero-allocation property; M1-ALLOC-01's standing check +// lands later) +// +// Runs as CTest `ecs_guardrails` (the step's Verify command: +// `ctest -R ecs_guardrails`): a filtered view of the shared +// laige-sim_tests executable, selecting exactly the EcsGuardrails +// suite below. + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "gtest/gtest.h" +#include "laige/errors.h" +#include "laige/logging.h" +#include "laige/sim/entity.h" + +#if defined(LAIGE_ALLOC_COUNTER) +#include "logging_alloc_counter.h" +#endif + +// --------------------------------------------------------------------------- +// NFR-8.10 policy self-checks (compile-time; a violation fails the build) +// --------------------------------------------------------------------------- + +#if defined(__cpp_exceptions) +static_assert(false, + "ecs_guardrails_tests must be built with exceptions " + "disabled (NFR-8.10); see laige_apply_engine_policy()."); +#elif defined(__EXCEPTIONS) && __EXCEPTIONS +static_assert(false, + "ecs_guardrails_tests must be built with exceptions " + "disabled (NFR-8.10); see laige_apply_engine_policy()."); +#endif + +#if defined(__cpp_rtti) && __cpp_rtti +static_assert(false, + "ecs_guardrails_tests must be built with RTTI disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#endif + +// MSVC never updates __cplusplus from /std (it stays 199711L, a legacy +// compatibility value); the active standard is reported by _MSVC_LANG. +// Every other supported compiler (NFR-8.10) sets __cplusplus from -std. +#if defined(_MSC_VER) +# define GUARDRAILS_TESTS_ACTIVE_CPLUSPLUS _MSVC_LANG +#else +# define GUARDRAILS_TESTS_ACTIVE_CPLUSPLUS __cplusplus +#endif + +#if GUARDRAILS_TESTS_ACTIVE_CPLUSPLUS < 202002L +static_assert(false, + "ecs_guardrails_tests must be built as C++20 (NFR-8.10); " + "see laige_apply_engine_policy()."); +#endif + +// The two component types the churn scenarios move (S-8 data +// carriers, trivially copyable). +struct GuardPos { + std::uint32_t x{}; + std::uint32_t y{}; +}; +LAIGE_COMPONENT(GuardPos) + +struct GuardVel { + std::uint32_t v{}; +}; +LAIGE_COMPONENT(GuardVel) + +namespace { + +// One world with a declared scene budget and a G-R4 churn budget, +// taken out of its Result (Result::value() is const; takeValue() && +// moves the storage out — the documented ownership-transfer path). +laige::World makeWorld(std::uint32_t capacity, std::uint32_t churnBudget) { + auto w = laige::World::create( + laige::World::Options{capacity, churnBudget}); + if (!w.ok()) { + ADD_FAILURE() << "World::create(" << capacity << ", " << churnBudget + << ") failed: " << laige::errorName(w.error()); + abort(); + } + return std::move(w).takeValue(); +} + +// A test-only Sink that records every Warn-or-above event (the +// guardrail asserts count warn events; the Debug-level +// ecs/archetype_created events of the component scenarios are +// irrelevant here). The logging facade is a process singleton; each +// test owns its window and restores the default console sink at the +// end. +class MemorySink : public laige::log::Sink { + public: + struct Entry { + laige::log::Severity severity{}; + std::string subsystem; + std::string event; + std::string message; + std::vector> fields; + }; + + void emit(const laige::log::LogRecord& record) override { + if (record.severity < laige::log::Severity::Warn) return; + Entry e; + e.severity = record.severity; + e.subsystem = record.subsystem; + e.event = record.event; + e.message = record.message; + for (const auto& f : record.fields) { + e.fields.emplace_back(std::string(f.name), f.value); + } + entries.push_back(std::move(e)); + } + void flush() override {} + + std::vector entries; +}; + +// Installs a fresh capture sink with rate limiting OFF: the tests +// assert the SOURCE-level once-per-frame dedup (the guardrail's own +// flag), not the facade's LOG-004 window. The caller restores the +// default sink after the scenario (restoreLogger). +MemorySink* installCaptureSink() { + auto sink = std::make_unique(); + MemorySink* ptr = sink.get(); + laige::log::LoggerOptions opts; + opts.sink = std::move(sink); + opts.rateLimiting = false; + if (!laige::log::Logger::instance().init(std::move(opts)).ok()) { + ADD_FAILURE() << "Logger::init (capture sink) failed"; + abort(); + } + return ptr; +} + +void restoreLogger() { + laige::log::LoggerOptions defaults; + if (!laige::log::Logger::instance().init(std::move(defaults)).ok()) { + ADD_FAILURE() << "Logger::init (restore default sink) failed"; + } +} + +std::size_t countEvents(const MemorySink& sink, std::string_view event) { + std::size_t n = 0; + for (const auto& e : sink.entries) { + if (e.subsystem == "ecs" && e.event == event) ++n; + } + return n; +} + +bool hasField(const MemorySink::Entry& e, std::string_view key, + std::string_view value) { + for (const auto& [k, v] : e.fields) { + if (k == key && v == value) return true; + } + return false; +} + +bool hasField(const MemorySink::Entry& e, std::string_view key) { + for (const auto& [k, v] : e.fields) { + if (k == key) return true; + } + return false; +} + +// Only used by the debug-only advice assertions below (NDEBUG builds +// never reference it — wrapping the definition keeps -Werror happy). +#ifndef NDEBUG +std::string fieldOf(const MemorySink::Entry& e, std::string_view key) { + for (const auto& [k, v] : e.fields) { + if (k == key) return v; + } + return std::string(); +} +#endif + +} // namespace + +// --------------------------------------------------------------------------- +// G-R3: the entity-count thresholds (25%/50%/100% of the scene budget) +// --------------------------------------------------------------------------- + +TEST(EcsGuardrails, EntityBudgetWarnsExactlyAtDocumentedPercentages) { + MemorySink* sink = installCaptureSink(); + // capacity 8: the levels are 2, 4, and 8 live entities. + laige::World world = makeWorld(8, laige::kDefaultChurnPerFrameBudget); + world.beginFrame(); + + // Below 25% (1 of 8 = 12.5%): no warn yet. + ASSERT_TRUE(world.create().ok()); + EXPECT_EQ(sink->entries.size(), 0u); + + // Exactly 25% (2 of 8): one warn, no duplicates. + ASSERT_TRUE(world.create().ok()); + EXPECT_EQ(sink->entries.size(), 1u); + const auto& e25 = sink->entries[0]; + EXPECT_EQ(e25.severity, laige::log::Severity::Warn); + EXPECT_EQ(e25.subsystem, "ecs"); + EXPECT_EQ(e25.event, "entity_budget_25"); + EXPECT_TRUE(hasField(e25, "entity_count", "2")); + EXPECT_TRUE(hasField(e25, "capacity", "8")); + EXPECT_TRUE(hasField(e25, "level", "25")); +#ifdef NDEBUG + EXPECT_FALSE(hasField(e25, "advice")); // the advice is debug-only +#else + EXPECT_TRUE(hasField(e25, "advice")); +#endif + + // Exactly 50% (4 of 8). + ASSERT_TRUE(world.create().ok()); + ASSERT_TRUE(world.create().ok()); + EXPECT_EQ(sink->entries.size(), 2u); + EXPECT_EQ(sink->entries[1].event, "entity_budget_50"); + EXPECT_TRUE(hasField(sink->entries[1], "entity_count", "4")); + + // Exactly 100% (8 of 8): the last successful create. + for (int i = 0; i < 4; ++i) { + ASSERT_TRUE(world.create().ok()); + } + EXPECT_EQ(sink->entries.size(), 3u); + EXPECT_EQ(sink->entries[2].event, "entity_budget_100"); + EXPECT_TRUE(hasField(sink->entries[2], "entity_count", "8")); + + // Over the budget: create fails; the 100% warn is not repeated. + auto over = world.create(); + EXPECT_FALSE(over.ok()); + EXPECT_EQ(over.error(), laige::ErrorCode::BudgetExhausted); + EXPECT_EQ(sink->entries.size(), 3u); + + // The guardrail counters (M1-PROF-01 feed). + const auto s = world.guardrailStats(); + EXPECT_EQ(s.capacity, 8u); + EXPECT_EQ(s.entityCount, 8u); + EXPECT_EQ(s.entityBudgetLevel, 100u); + EXPECT_EQ(s.entityBudgetWarns[0], 1u); + EXPECT_EQ(s.entityBudgetWarns[1], 1u); + EXPECT_EQ(s.entityBudgetWarns[2], 1u); + EXPECT_EQ(s.frameChurn, 0u); + EXPECT_EQ(s.churnPerFrameBudget, laige::kDefaultChurnPerFrameBudget); + EXPECT_EQ(s.churnWarns, 0u); + + restoreLogger(); +} + +TEST(EcsGuardrails, EntityBudgetWarnsAtMostOncePerLevelPerFrame) { + MemorySink* sink = installCaptureSink(); + // capacity 4: the 25% threshold is 1 live entity. + laige::World world = makeWorld(4, laige::kDefaultChurnPerFrameBudget); + world.beginFrame(); + + // Frame 1: cross 25% — the warn fires. + laige::Entity e{}; + { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + e = r.value(); + } + EXPECT_EQ(countEvents(*sink, "entity_budget_25"), 1u); + + // Same frame: dip below the threshold, cross it again — NO second + // warn (the roadmap's "no duplicates: warn once per level per + // frame"). + ASSERT_TRUE(world.destroy(e).ok()); + auto r2 = world.create(); + ASSERT_TRUE(r2.ok()); + e = r2.value(); + EXPECT_EQ(countEvents(*sink, "entity_budget_25"), 1u); + + // Frame 2: dip below, cross again — the new frame's warn fires. + world.beginFrame(); + ASSERT_TRUE(world.destroy(e).ok()); + auto r3 = world.create(); + ASSERT_TRUE(r3.ok()); + EXPECT_EQ(countEvents(*sink, "entity_budget_25"), 2u); + + EXPECT_EQ(world.guardrailStats().entityBudgetWarns[0], 2u); + restoreLogger(); +} + +TEST(EcsGuardrails, EntityBudgetDegenerateThresholds) { + // capacity 2: the 25% threshold is 0 (never fires); 50% is 1; 100% + // is 2. + MemorySink* sink = installCaptureSink(); + laige::World world = makeWorld(2, laige::kDefaultChurnPerFrameBudget); + world.beginFrame(); + ASSERT_TRUE(world.create().ok()); + ASSERT_TRUE(world.create().ok()); + auto over = world.create(); + EXPECT_FALSE(over.ok()); + EXPECT_EQ(countEvents(*sink, "entity_budget_25"), 0u); + EXPECT_EQ(countEvents(*sink, "entity_budget_50"), 1u); + EXPECT_EQ(countEvents(*sink, "entity_budget_100"), 1u); + restoreLogger(); + + // capacity 1: only the 100% level (threshold 1) can fire. + sink = installCaptureSink(); + world = makeWorld(1, laige::kDefaultChurnPerFrameBudget); + world.beginFrame(); + ASSERT_TRUE(world.create().ok()); + EXPECT_EQ(countEvents(*sink, "entity_budget_100"), 1u); + EXPECT_EQ(countEvents(*sink, "entity_budget_25"), 0u); + EXPECT_EQ(countEvents(*sink, "entity_budget_50"), 0u); + restoreLogger(); + + // capacity 0: every create fails; nothing can fire. + sink = installCaptureSink(); + world = makeWorld(0, laige::kDefaultChurnPerFrameBudget); + world.beginFrame(); + auto r0 = world.create(); + EXPECT_FALSE(r0.ok()); + EXPECT_EQ(sink->entries.size(), 0u); + restoreLogger(); +} + +TEST(EcsGuardrails, EntityBudgetFiresWithoutFrameBookkeeping) { + // No beginFrame() call: setup-phase creates still trigger the warns. + // The frame flags only DEDUPLICATE within a frame; they never gate + // the first crossing (a world driven without frame boundaries + // degrades to warn-once-per-lifetime — documented, never silent). + MemorySink* sink = installCaptureSink(); + laige::World world = makeWorld(8, laige::kDefaultChurnPerFrameBudget); + for (int i = 0; i < 8; ++i) { + ASSERT_TRUE(world.create().ok()); + } + EXPECT_EQ(countEvents(*sink, "entity_budget_25"), 1u); + EXPECT_EQ(countEvents(*sink, "entity_budget_50"), 1u); + EXPECT_EQ(countEvents(*sink, "entity_budget_100"), 1u); + restoreLogger(); +} + +// --------------------------------------------------------------------------- +// G-R4: the per-frame component-churn budget +// --------------------------------------------------------------------------- + +TEST(EcsGuardrails, ChurnWarnsExactlyWhenBudgetExceeded) { + MemorySink* sink = installCaptureSink(); + // Budget 4: churn of exactly 4 does NOT warn (strictly-greater); + // 5 does. + laige::World world = makeWorld(16, 4); + ASSERT_TRUE(world.registerComponent().ok()); + ASSERT_TRUE(world.registerComponent().ok()); + laige::Entity e{}; + { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + e = r.value(); + } + world.beginFrame(); + + auto addPos = [&] { + return world.addComponent(e, GuardPos{1, 2}); + }; + auto removePos = [&] { return world.removeComponent(e); }; + + // Four ops: churn 4 == budget — no warn yet. + ASSERT_TRUE(addPos().ok()); + ASSERT_TRUE(removePos().ok()); + ASSERT_TRUE(addPos().ok()); + ASSERT_TRUE(removePos().ok()); + EXPECT_EQ(countEvents(*sink, "churn_per_frame"), 0u); + EXPECT_EQ(world.guardrailStats().frameChurn, 4u); + + // The fifth op: churn 5 > 4 — the warn fires, once. + ASSERT_TRUE(addPos().ok()); + EXPECT_EQ(countEvents(*sink, "churn_per_frame"), 1u); + const auto& cw = sink->entries[0]; + EXPECT_EQ(cw.severity, laige::log::Severity::Warn); + EXPECT_EQ(cw.subsystem, "ecs"); + EXPECT_EQ(cw.event, "churn_per_frame"); + EXPECT_TRUE(hasField(cw, "frame_churn", "5")); + EXPECT_TRUE(hasField(cw, "churn_budget", "4")); +#ifdef NDEBUG + EXPECT_FALSE(hasField(cw, "advice")); // the advice is debug-only +#else + // The PRD §9.3 G-R4 advice text (debug builds). + EXPECT_TRUE(hasField(cw, "advice")); + EXPECT_NE(fieldOf(cw, "advice").find("spawn/despawn system"), + std::string::npos) + << "advice: " << fieldOf(cw, "advice"); +#endif + + // More churn in the same frame: no second warn. + ASSERT_TRUE(removePos().ok()); + ASSERT_TRUE(addPos().ok()); + ASSERT_TRUE(removePos().ok()); + EXPECT_EQ(countEvents(*sink, "churn_per_frame"), 1u); + + const auto s = world.guardrailStats(); + EXPECT_EQ(s.frameChurn, 8u); + EXPECT_EQ(s.churnWarns, 1u); + restoreLogger(); +} + +TEST(EcsGuardrails, ChurnCounterResetsAtFrameStart) { + MemorySink* sink = installCaptureSink(); + laige::World world = makeWorld(16, 4); + ASSERT_TRUE(world.registerComponent().ok()); + laige::Entity e{}; + { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + e = r.value(); + } + + // Frame 1: 10 ops (5 add/remove pairs) — one warn at op 5 + // (churn 5 > budget 4). + world.beginFrame(); + for (int i = 0; i < 5; ++i) { + ASSERT_TRUE( + world.addComponent( + e, GuardPos{static_cast(i), 0}) + .ok()); + ASSERT_TRUE(world.removeComponent(e).ok()); + } + EXPECT_EQ(countEvents(*sink, "churn_per_frame"), 1u); + EXPECT_EQ(world.guardrailStats().frameChurn, 10u); + + // Frame 2: the counter starts at 0 again; the warn can fire a + // second time in the new frame. + world.beginFrame(); + EXPECT_EQ(world.guardrailStats().frameChurn, 0u); + ASSERT_TRUE(world.addComponent(e, GuardPos{0, 9}).ok()); + EXPECT_EQ(countEvents(*sink, "churn_per_frame"), 1u); // churn 1: no warn + ASSERT_TRUE(world.removeComponent(e).ok()); + ASSERT_TRUE(world.addComponent(e, GuardPos{1, 9}).ok()); + ASSERT_TRUE(world.removeComponent(e).ok()); + ASSERT_TRUE(world.addComponent(e, GuardPos{2, 9}).ok()); + EXPECT_EQ(countEvents(*sink, "churn_per_frame"), 2u); // churn 5: warn + + EXPECT_EQ(world.guardrailStats().churnWarns, 2u); + restoreLogger(); +} + +TEST(EcsGuardrails, ChurnNoOpRemoveIsNotCounted) { + MemorySink* sink = installCaptureSink(); + laige::World world = makeWorld(16, 2); + ASSERT_TRUE(world.registerComponent().ok()); + laige::Entity e{}; + { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + e = r.value(); + } + world.beginFrame(); + + // A no-op remove (the entity lacks the component / has no + // components) is an ok Status and does NOT count toward the churn + // (the ArchetypeStats::totalRemoves semantics). + EXPECT_TRUE(world.removeComponent(e).ok()); + EXPECT_EQ(world.guardrailStats().frameChurn, 0u); + EXPECT_EQ(countEvents(*sink, "churn_per_frame"), 0u); + + // churn 1 (add), churn 2 (remove == budget: still no warn), + // churn 3 (add > budget: the warn). + ASSERT_TRUE(world.addComponent(e, GuardPos{1, 1}).ok()); + ASSERT_TRUE(world.removeComponent(e).ok()); + EXPECT_EQ(countEvents(*sink, "churn_per_frame"), 0u); + ASSERT_TRUE(world.addComponent(e, GuardPos{1, 1}).ok()); + EXPECT_EQ(countEvents(*sink, "churn_per_frame"), 1u); + restoreLogger(); +} + +TEST(EcsGuardrails, ChurnBudgetZeroDisablesTheGuardrail) { + MemorySink* sink = installCaptureSink(); + laige::World world = makeWorld(16, 0); + ASSERT_TRUE(world.registerComponent().ok()); + laige::Entity e{}; + { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + e = r.value(); + } + world.beginFrame(); + for (int i = 0; i < 30; ++i) { + ASSERT_TRUE( + world.addComponent( + e, GuardPos{static_cast(i), 0}) + .ok()); + ASSERT_TRUE(world.removeComponent(e).ok()); + } + EXPECT_EQ(sink->entries.size(), 0u); + EXPECT_EQ(world.guardrailStats().frameChurn, 60u); + EXPECT_EQ(world.guardrailStats().churnWarns, 0u); + restoreLogger(); +} + +// --------------------------------------------------------------------------- +// The NFR-13.3 message grammar + the profiler feed +// --------------------------------------------------------------------------- + +TEST(EcsGuardrails, WarnMessagesFollowTheErrorGrammar) { + // NFR-13.3: every engine error follows + // {code} | {what} | {why} | {fix} | {doc_anchor} — the guardrail + // warns carry the same 5-field line in their message text + // (identical in every build; debug adds the advice FIELD, not + // message text). + MemorySink* sink = installCaptureSink(); + // capacity 8: creating 4 entities crosses 25% (2) and 50% (4); + // budget 2: 3 component ops breach the churn. + laige::World world = makeWorld(8, 2); + ASSERT_TRUE(world.registerComponent().ok()); + laige::Entity e{}; + { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + e = r.value(); + } + world.beginFrame(); + for (int i = 0; i < 4; ++i) { + ASSERT_TRUE(world.create().ok()); + } + ASSERT_TRUE(world.addComponent(e, GuardPos{1, 1}).ok()); + ASSERT_TRUE(world.removeComponent(e).ok()); + ASSERT_TRUE(world.addComponent(e, GuardPos{1, 1}).ok()); + + EXPECT_EQ(countEvents(*sink, "entity_budget_25"), 1u); + EXPECT_EQ(countEvents(*sink, "entity_budget_50"), 1u); + EXPECT_EQ(countEvents(*sink, "churn_per_frame"), 1u); + ASSERT_EQ(sink->entries.size(), 3u); + + for (const auto& entry : sink->entries) { + // Split the message on " | ": exactly 5 fields, none empty. + std::vector fields; + std::size_t start = 0; + for (;;) { + const std::size_t pos = entry.message.find(" | ", start); + if (pos == std::string::npos) { + fields.push_back(entry.message.substr(start)); + break; + } + fields.push_back(entry.message.substr(start, pos - start)); + start = pos + 3; + } + ASSERT_EQ(fields.size(), 5u) << "message: " << entry.message; + for (const auto& f : fields) { + EXPECT_FALSE(f.empty()) << "message: " << entry.message; + } + // {code} names the event; {doc_anchor} points at the entity docs. + EXPECT_EQ(fields[0], entry.event); + EXPECT_EQ(fields[4], "docs/api/entity.md#guardrails"); + } + restoreLogger(); +} + +TEST(EcsGuardrails, GuardrailStatsTrackTheFrameWindow) { + // The profiler feed (M1-PROF-01): the per-frame values are read + // before the next beginFrame(); the warn counters are + // since-construction. + MemorySink* sink = installCaptureSink(); + laige::World world = makeWorld(8, 2); + ASSERT_TRUE(world.registerComponent().ok()); + laige::Entity e{}; + { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + e = r.value(); + } + world.beginFrame(); + ASSERT_TRUE(world.create().ok()); // inUse 2: crosses 25% (threshold 2) + ASSERT_TRUE(world.addComponent(e, GuardPos{1, 1}).ok()); + ASSERT_TRUE(world.removeComponent(e).ok()); + ASSERT_TRUE(world.addComponent(e, GuardPos{1, 1}).ok()); + const auto s = world.guardrailStats(); + EXPECT_EQ(s.capacity, 8u); + EXPECT_EQ(s.entityCount, 2u); + EXPECT_EQ(s.entityBudgetLevel, 25u); + EXPECT_EQ(s.entityBudgetWarns[0], 1u); + EXPECT_EQ(s.frameChurn, 3u); + EXPECT_EQ(s.churnPerFrameBudget, 2u); + EXPECT_EQ(s.churnWarns, 1u); + + // The next frame: the churn window restarts, the level is kept + // (peak-based), the warn counts persist. + world.beginFrame(); + const auto s2 = world.guardrailStats(); + EXPECT_EQ(s2.frameChurn, 0u); + EXPECT_EQ(s2.entityBudgetLevel, 25u); + EXPECT_EQ(s2.entityBudgetWarns[0], 1u); + EXPECT_EQ(s2.churnWarns, 1u); + EXPECT_EQ(sink->entries.size(), 2u); // one entity-budget, one churn + restoreLogger(); +} + +TEST(EcsGuardrails, GuardrailChecksAllocateNothingBelowTheThresholds) { +#if defined(LAIGE_ALLOC_COUNTER) + MemorySink* sink = installCaptureSink(); + laige::World world = makeWorld(16, laige::kDefaultChurnPerFrameBudget); + ASSERT_TRUE(world.registerComponent().ok()); + laige::Entity e{}; + { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + e = r.value(); + } + // Setup (before the window): create the GuardPos archetype so the + // window's adds are steady-state moves (no growth reservations). + ASSERT_TRUE(world.addComponent(e, GuardPos{0, 0}).ok()); + ASSERT_TRUE(world.removeComponent(e).ok()); + // The world/sink/registry setup above allocated; the reset lands + // between setup and the guarded window. + laige::test::resetAllocCounter(); + world.beginFrame(); + // Stay below every threshold: 2 more creates (inUse 3 < the 25% + // threshold of 4) and 8 component ops (churn 8 < budget 256) — + // no warn fires, so no Field construction, so no heap. + for (int i = 0; i < 2; ++i) { + auto r = world.create(); + ASSERT_TRUE(r.ok()); + e = r.value(); + } + for (int i = 0; i < 8; ++i) { + ASSERT_TRUE( + world.addComponent( + e, GuardPos{static_cast(i), 0}) + .ok()); + ASSERT_TRUE(world.removeComponent(e).ok()); + } + EXPECT_EQ(sink->entries.size(), 0u); // no warn fired + // The guardrail hot path (the threshold comparisons per create, the + // churn counters per add/remove) touches no heap: the M1 + // zero-allocation property (M1-ALLOC-01's standing assertion lands + // later; ASan + this counter is the check until then). + EXPECT_EQ(laige::test::allocCounter(), 0u); + restoreLogger(); +#else + GTEST_SKIP() << "the allocation counter is excluded from the sanitizer " + "trees (their own new/delete interposes); the property " + "is covered there by the leak-free run."; +#endif +} diff --git a/tests/laige-sim/entity_tests.cpp b/tests/laige-sim/entity_tests.cpp index ff044ae..d2fb265 100644 --- a/tests/laige-sim/entity_tests.cpp +++ b/tests/laige-sim/entity_tests.cpp @@ -591,19 +591,39 @@ TEST(WorldLogging, StaleAccessWarnsOnce) { } } - ASSERT_EQ(sinkPtr->entries.size(), 1u); - EXPECT_EQ(sinkPtr->entries[0].severity, laige::log::Severity::Warn); - EXPECT_EQ(sinkPtr->entries[0].subsystem, "ecs"); - EXPECT_EQ(sinkPtr->entries[0].event, "stale_entity_access"); + // M1-ECS-06: the capacity-1 create() above crossed the 100% + // entity-budget level, so the ecs/entity_budget_100 guardrail warn + // precedes the stale warn in this window — the expected events are + // counted by event name (LOG-001), not by position. + std::size_t staleCount = 0; + std::size_t budget100 = 0; + for (const auto& e : sinkPtr->entries) { + if (e.event == "stale_entity_access") ++staleCount; + if (e.event == "entity_budget_100") ++budget100; + } + ASSERT_EQ(staleCount, 1u); + ASSERT_EQ(budget100, 1u); + ASSERT_EQ(sinkPtr->entries.size(), 2u); + const MemorySink::Entry* staleEntry = nullptr; + for (const auto& e : sinkPtr->entries) { + if (e.event == "stale_entity_access") staleEntry = &e; + } + ASSERT_NE(staleEntry, nullptr); + EXPECT_EQ(staleEntry->severity, laige::log::Severity::Warn); + EXPECT_EQ(staleEntry->subsystem, "ecs"); // Controlled shutdown drains the pending rate-limit summary // (CONC-006/LOG-007/LOG-004). laige::log::Logger::instance().shutdown(); - ASSERT_EQ(sinkPtr->entries.size(), 2u); - EXPECT_EQ(sinkPtr->entries[1].event, laige::log::kRateLimitedEvent); + ASSERT_EQ(sinkPtr->entries.size(), 3u); + const MemorySink::Entry* summary = nullptr; + for (const auto& e : sinkPtr->entries) { + if (e.event == laige::log::kRateLimitedEvent) summary = &e; + } + ASSERT_NE(summary, nullptr); bool foundEventField = false; bool foundCountField = false; - for (const auto& [key, value] : sinkPtr->entries[1].fields) { + for (const auto& [key, value] : summary->fields) { if (key == "event" && value == "stale_entity_access") foundEventField = true; if (key == "suppressed" && value == "2") foundCountField = true; }