diff --git a/docs/README.md b/docs/README.md index f97f467..15bcf7a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -65,6 +65,11 @@ still to land. pre-run validation (unknown dependency, cycle, double writer, read-before-write warn), and the per-tick system phase (M1-SYS-02; `laige-sim`). +- [Per-system timing and budget enforcement](api/system_timing.md) — + the per-tick rolling time windows, the G-R5 budget enforcement + (`system/budget_overrun` warn, `system/budget_critical` error), and + the `World::systemTimingStats`/`systemTimingWindow` profiler feed + (M1-SYS-03; `laige-sim`). - [Result / Status / error codes](api/errors.md) — `laige::Result`, `laige::Status`, the stable `ErrorCode` registry (M0-CORE-01). - [Structured logging](api/logging.md) — the `laige::log` facade, sinks, @@ -155,7 +160,8 @@ still to land. [query.md](api/query.md), [iteration_order.md](api/iteration_order.md), [system_registry.md](api/system_registry.md), - [scheduler.md](api/scheduler.md).) + [scheduler.md](api/scheduler.md), + [system_timing.md](api/system_timing.md).) ## Related diff --git a/docs/api/scheduler.md b/docs/api/scheduler.md index cfff311..d53e441 100644 --- a/docs/api/scheduler.md +++ b/docs/api/scheduler.md @@ -161,7 +161,9 @@ CTest entry pins the property. id check, and one `SystemContext` construction + one call per system) plus the systems' own work. **No allocation, no logging** on the success path — the per-tick cost is the systems' declared - budgets (M1-SYS-03 measures them). + budgets (M1-SYS-03 measures them; the per-system timing + budget + enforcement is documented in + [system_timing.md](system_timing.md)). - **Misuse:** scheduling a world near the `kMaxSystems` bound costs O(n³) worst case (~1M bounded integer ops for n = 256) — a setup cost, never a hot path; a game that outgrows 256 systems raises the diff --git a/docs/api/system_registry.md b/docs/api/system_registry.md index 208a3b8..55c7692 100644 --- a/docs/api/system_registry.md +++ b/docs/api/system_registry.md @@ -53,7 +53,8 @@ inheritance, no state object. The function plus its `SystemDef` store it across ticks. - Systems are deterministic when the engine runs in deterministic mode (M1-DET-01) and must stay within their declared budget - (M1-SYS-03 measures per-system time). + (M1-SYS-03 measures per-system time and enforces the budget — + [system_timing.md](system_timing.md)). `LAIGE_SYSTEM(Name, budget_ms, Dep..., ...)` (namespace scope, directly above the function) expands to the function declaration plus @@ -67,7 +68,9 @@ so `Name` is both the C++ function name and the system's registration name (stringified), and the def variable is `Name##Def`. `budget_ms` is a numeric literal in milliseconds (1, 0.5, …); the conversion to the exact `fpx16_16` happens once, at program start -(setup path, never a hot path). The macro and the function +(setup path, never a hot path). The budget is enforced per tick by +the per-system timing (M1-SYS-03 — +[system_timing.md](system_timing.md)). The macro and the function definition live in the same translation unit. The optional trailing `Dep...` names are the **depends_on** spec (M1-SYS-02): the registration names of the systems `Name` must run after, stringified diff --git a/docs/api/system_timing.md b/docs/api/system_timing.md new file mode 100644 index 0000000..b1283e2 --- /dev/null +++ b/docs/api/system_timing.md @@ -0,0 +1,187 @@ +# Per-system timing and budget enforcement (`World::runSystems`, G-R5) + +The M1 system framework's per-system time budget (M1-SYS-03; PRD +§9.3 G-R5, FR-11.1/11.2, FR-12.3; AGENTS CORE-001, PERF-002/003, +ARCH-009): measures every system's run time per tick into a fixed +rolling histogram, enforces the declared per-system budget with +structured warn/error events, and feeds the profiler and the frame +graph report (M1-PROF-01/02). Public header: +`src/laige-sim/include/laige/sim/system.h` (`SystemTimingStats`, +`kSystemTimingWindowSamples`, `kBudgetCriticalMultiplier`, the full +contract) plus the `World::systemTimingStats`/`systemTimingWindow` +members in `src/laige-sim/include/laige/sim/entity.h`; implementation: +`src/laige-sim/system_timing.cpp` (the check and the queries) and the +per-system measurement inside `World::runSystems` +(`src/laige-sim/systems.cpp`). Unit suite: `ctest -R system_timing` +(`tests/laige-sim/system_timing_tests.cpp`). + +The loop (M1-LOOP-01) runs the schedule every tick, and the timing +is automatic — nothing per system is wired by the game: + +```cpp +for (tick) { + world.beginFrame(); + world.runSystems(schedule); // measures every system (M1-SYS-03) + // ... read the feed, e.g. for the frame graph: + // auto st = world.systemTimingStats(id); +} +``` + +## What is measured + +`World::runSystems` times each system's **own run**: the `TimeIt` +scope (the M0-CORE-08 `steady_clock` timer — monotonic, ms as a +double) starts before the run function is called and is read back +immediately after it returns. The per-tick, per-system bookkeeping +(the schedule dispatch, the `SystemContext` construction) is outside +the window — the measurement is the system's work, not the +scheduler's. The sample is handed to the system's rolling window and +the budget check in schedule order. + +## The rolling window + +One `Histogram` per system (M0-CORE-08): fixed capacity +`kSystemTimingWindowSamples` (64 samples — ~1.1 s at the default +60 Hz tick rate), rolling across **ticks** (`beginFrame()` does not +touch it — it is not a per-frame window). `record()` is O(1) and +allocates nothing; recording beyond the capacity drops the **oldest** +sample, and `totalRecorded()` keeps counting every sample ever +recorded, so the truncation is observable (the M0-CORE-08 contract). +The capacity is fixed at world construction (a setup-path +allocation); raising it is an ADR, not a knob. + +`stats()` over the window (nearest-rank percentiles — the M0-CORE-08 +definition) is a cold path: O(n log n) with no allocation. The +engine calls it only while a system is over budget (the warn/error +path). + +## Budget enforcement (PRD §9.3 G-R5) + +After every measured run, the sample is compared against the system's +declared `SystemDef` budget (fpx16_16 ms, converted to double +**exactly** — raw/2^16 is a power-of-two scale, so the comparison +operands are exact): + +| Condition | Event | Severity | +|---|---|---| +| `measured > 1 × budget` | `system/budget_overrun` | Warn | +| `measured >= 3 × budget` (`kBudgetCriticalMultiplier`) | `system/budget_critical` | Error | + +The warn fires strictly above the budget (`measured == budget` is +legal — the G-R4 strictly-greater precedent); the error at 3× or more +("over 3× → error event"). A run that is 3× over fires **both** — +the warn first, then the error, in the same tick (they are separate +rate-limit keys). + +Both events: + +- follow the NFR-13.3 5-field message grammar + (`{code} | {what} | {why} | {fix} | {doc_anchor}`) — build-stable + message text; the dynamic values are structured **fields**, never + message text (machine-parseable output stays build-stable); +- are rate-limited per `(subsystem, event, severity)` with the + facade's default 1 s window (LOG-004) — a sustained overrun logs + once per window plus a `rate_limited` summary, never a log storm; +- count separately in `SystemTimingStats` (`warns`, `errors`) even + when the event is suppressed — the counters are since-construction. + +The fields on both events: + +| Field | Meaning | +|---|---| +| `system` | the registered system name | +| `id` | the `SystemId` value | +| `measured_ms` | this tick's measured run time (ms) | +| `budget_ms` | the declared budget (ms, exact) | +| `p99_ms` | the rolling window's p99 after this sample | +| `window_samples` | the samples stored in the window | + +An over-budget system is **still run**: the timing is observation and +reporting, never an execution gate (the engine does not skip, defer, +or cancel a system — the breach is surfaced, FR-12.3: never hidden). + +## The profiler feed (M1-PROF-01/02) + +- `World::systemTimingStats(id)` — `Result`: the run count, the last measured ms, and the + warn/error counts. O(1), no allocation, no side effects; the + per-frame pull for the profiler. +- `World::systemTimingWindow(id)` — `const Histogram*` (nullptr for an + invalid id or a moved-from world): the rolling window itself, for + the frame graph report's `budgetCheck` (the M0-CORE-08 check; cold + path). + +Both are pure queries: an invalid `SystemId` (0, above +`systemCount()`) or a moved-from world is +`ErrorCode::InvalidArgument` / nullptr — no log, no warn (the +`World::system` precedent). + +## Determinism scope (ARCH-009/ARCH-010) + +The measured times are **diagnostics only**: wall-clock readings are +platform-sensitive, so they never enter authoritative simulation +state, state hashes, or replays. The only world state the timing +adds is the window contents and the counters — observability, not +sim state (the PRD's separation of authoritative from +presentation/diagnostic state). The warn/error **events** carry the +measurements as log output; the log stream is not replay state. + +## Performance + +Per tick, per system (the hot path): + +- two `steady_clock::now()` reads (the `TimeIt` scope); +- one O(1) ring write (`Histogram::record`); +- two comparisons (warn and error thresholds); +- **no allocation, no logging** on the success path (PERF-003, + LOG-003). At the 10k-entity reference tick with ≤ 256 systems this + is a few hundred nanoseconds — far below the 3 ms `sim_tick_avg` + budget (PRD §8.1). + +The warn/error path is cold (a budget being breached): it performs one +window `stats()` pass — O(W log W) over the fixed W = 64 samples, no +allocation (the pre-allocated scratch buffer) — and constructs the +field values. Note the facade's level gate, not the rate state, +controls field construction: a system that stays over budget pays +this bounded cold cost on every tick while the breach persists (the +event itself is rate-limited to one per window). That is deliberate — +a persistently broken system is worth a few microseconds of +diagnostic overhead, and the cost disappears when the breach is +fixed. + +World construction pays the setup cost once: the fixed +kMaxSystems record table plus one 64-sample Histogram per record +(256 records × 2 small allocations ≈ 256 KB, one-time, never a hot +path; the `engine_base_rss` 100 MB budget, PRD §8.1). + +## Threading and failure + +The timing state is owned by the world and written strictly on the +world's single owner thread (CONC-001; PRD §10.2: simulation is +single-threaded): `checkSystemBudget` runs inside `runSystems`; the +queries are pure reads of the same single-owner state (a `const +Histogram&` read is safe on a fully built window, the M0-CORE-08 +publish contract). The table travels with the world on move and +survives `clear()` (like the system registry); a moved-from world has +no timing state (the queries fail as pure queries). + +Failure behavior: nothing in the timing path can fail at runtime — +the checks are comparisons, the window cannot overflow (bounded), and +the events are logged, not thrown (FR-12.1, NFR-8.10: no +exceptions). + +## Misuse warnings + +- **The budget is declared at registration** — a system that cannot + finish within its declared budget will warn (and eventually error) + every tick; either cut the work or declare a budget the system can + actually meet (CORE-005: the budget is a named, honest number, not + a hope). +- **The window is not per-frame** — do not expect `beginFrame()` to + reset it; the p99 always describes the last 64 ticks, wherever they + fall in the frame cycle. +- **Do not treat the events as control flow** — they never stop the + system; the fix is in the system's work or its budget (the event's + `{fix}` field says so). +- **Do not keep the `systemTimingWindow` reference past the world** + (it is a non-owning view into the world's single-owner state). diff --git a/laige-api.json b/laige-api.json index cbda2a9..a3440f6 100644 --- a/laige-api.json +++ b/laige-api.json @@ -423,64 +423,66 @@ {"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": 181, "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": 182, "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": 183, "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": 185, "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": 186, "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": 194, "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": 197, "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": 211, "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": 212, "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": 213, "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": 214, "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": 215, "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": 216, "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": 217, "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": 228, "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": 245, "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": 246, "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": 247, "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": 248, "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": 249, "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": 250, "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": 251, "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": 252, "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": 324, "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": 328, "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": 333, "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": 339, "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": 345, "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": 350, "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": 360, "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": 367, "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": 370, "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": 373, "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": 377, "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": 381, "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": 396, "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": 402, "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": 423, "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": 428, "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": 434, "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": 445, "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": 455, "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": 472, "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": 480, "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": 486, "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": 491, "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": 526, "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::registerSystem", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 567, "signature": "template [[nodiscard]] Result registerSystem(const SystemDef& def, Ios...) noexcept", "summary": "Register the system described by `def` in this world, declaring its component I/O as the Io<...> pack (zero entries = a system that touches no components). Setup phase (world construction, before the loop), like registerComponent: O(n) in the number of registered systems, no allocation (the def is copied into the fixed kMaxSystems record table; the I/O sets are written in place).", "budget": null, "experimental": false}, - {"name": "laige::World::systemCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 572, "signature": "[[nodiscard]] std::uint32_t systemCount() const noexcept", "summary": "The number of systems registered so far (0 .. kMaxSystems). O(1), no side effects.", "budget": null, "experimental": false}, - {"name": "laige::World::system", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 579, "signature": "[[nodiscard]] Result system(SystemId id) const noexcept", "summary": "The registered system's record under `id` (SystemInfo: the def value copy plus the declared I/O membership queries). O(1), no allocation. `id` invalid (0 or above systemCount()) or a moved-from world -> ErrorCode::InvalidArgument (a pure query, like componentInfo).", "budget": null, "experimental": false}, - {"name": "laige::World::scheduleSystems", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 601, "signature": "[[nodiscard]] Status scheduleSystems(SystemSchedule& out) const noexcept", "summary": "Compute and validate this world's execution order into `out` (SystemSchedule). Setup phase (after all registrations, before the loop); a pure read of the registry (const). The order is the stable topological sort of the registration order plus the declared depends_on edges (system.h). Validation order (first failure wins): unknown dependency name (system/dep_missing), dependency cycle (system/dependency_cycle), two systems writing the same component (system/double_writer) — each InvalidArgument + one rate-limited warn; a declared read ordered before a declared write of the same component WARNs without failing (system/read_before_write). Success: `out` fully populated, nothing logged (LOG-003). Setup path: O(n·d·n + c·n²) in the system count n (≤ kMaxSystems), direct dependencies d (≤ kMaxSystemDependencies), and component count c (≤ kMaxComponentTypes); no allocation.", "budget": null, "experimental": false}, - {"name": "laige::World::runSystems", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 620, "signature": "[[nodiscard]] Status runSystems(const SystemSchedule& schedule) noexcept", "summary": "Run the systems of `schedule` once — one sim tick's system phase (the M1-LOOP-01 accumulator calls this once per tick). The systems run strictly one at a time, in schedule order, on the world's single owner thread (PRD §10.2); each gets a fresh non-owning SystemContext. O(n) dispatch plus the systems' own work; no allocation (PERF-003), no logging on the success path (LOG-003).", "budget": null, "experimental": false}, - {"name": "laige::World::clear", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 633, "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": 637, "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": 638, "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": 639, "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": 640, "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": 645, "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": 187, "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": 188, "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": 189, "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": 191, "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": 192, "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": 200, "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": 203, "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": 217, "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": 218, "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": 219, "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": 220, "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": 221, "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": 222, "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": 223, "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": 234, "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": 251, "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": 252, "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": 253, "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": 254, "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": 255, "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": 256, "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": 257, "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": 258, "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": 330, "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": 334, "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": 339, "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": 345, "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": 351, "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": 356, "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": 366, "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": 373, "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": 376, "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": 379, "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": 383, "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": 387, "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": 402, "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": 408, "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": 429, "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": 434, "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": 440, "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": 451, "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": 461, "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": 478, "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": 486, "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": 492, "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": 497, "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": 532, "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::registerSystem", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 573, "signature": "template [[nodiscard]] Result registerSystem(const SystemDef& def, Ios...) noexcept", "summary": "Register the system described by `def` in this world, declaring its component I/O as the Io<...> pack (zero entries = a system that touches no components). Setup phase (world construction, before the loop), like registerComponent: O(n) in the number of registered systems, no allocation (the def is copied into the fixed kMaxSystems record table; the I/O sets are written in place).", "budget": null, "experimental": false}, + {"name": "laige::World::systemCount", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 578, "signature": "[[nodiscard]] std::uint32_t systemCount() const noexcept", "summary": "The number of systems registered so far (0 .. kMaxSystems). O(1), no side effects.", "budget": null, "experimental": false}, + {"name": "laige::World::system", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 585, "signature": "[[nodiscard]] Result system(SystemId id) const noexcept", "summary": "The registered system's record under `id` (SystemInfo: the def value copy plus the declared I/O membership queries). O(1), no allocation. `id` invalid (0 or above systemCount()) or a moved-from world -> ErrorCode::InvalidArgument (a pure query, like componentInfo).", "budget": null, "experimental": false}, + {"name": "laige::World::scheduleSystems", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 607, "signature": "[[nodiscard]] Status scheduleSystems(SystemSchedule& out) const noexcept", "summary": "Compute and validate this world's execution order into `out` (SystemSchedule). Setup phase (after all registrations, before the loop); a pure read of the registry (const). The order is the stable topological sort of the registration order plus the declared depends_on edges (system.h). Validation order (first failure wins): unknown dependency name (system/dep_missing), dependency cycle (system/dependency_cycle), two systems writing the same component (system/double_writer) — each InvalidArgument + one rate-limited warn; a declared read ordered before a declared write of the same component WARNs without failing (system/read_before_write). Success: `out` fully populated, nothing logged (LOG-003). Setup path: O(n·d·n + c·n²) in the system count n (≤ kMaxSystems), direct dependencies d (≤ kMaxSystemDependencies), and component count c (≤ kMaxComponentTypes); no allocation.", "budget": null, "experimental": false}, + {"name": "laige::World::runSystems", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 626, "signature": "[[nodiscard]] Status runSystems(const SystemSchedule& schedule) noexcept", "summary": "Run the systems of `schedule` once — one sim tick's system phase (the M1-LOOP-01 accumulator calls this once per tick). The systems run strictly one at a time, in schedule order, on the world's single owner thread (PRD §10.2); each gets a fresh non-owning SystemContext. O(n) dispatch plus the systems' own work; no allocation (PERF-003), no logging on the success path (LOG-003).", "budget": null, "experimental": false}, + {"name": "laige::World::systemTimingStats", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 639, "signature": "[[nodiscard]] Result systemTimingStats(SystemId id) const noexcept", "summary": "The per-system timing snapshot (SystemTimingStats: the run count, the last measured ms, and the warn/error counts). O(1), no allocation, no side effects (a pure query, like system()). `id` invalid (0 or above systemCount()) or a moved-from world -> ErrorCode::InvalidArgument.", "budget": null, "experimental": false}, + {"name": "laige::World::systemTimingWindow", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 648, "signature": "[[nodiscard]] const Histogram* systemTimingWindow(SystemId id) const noexcept", "summary": "The per-system rolling window (the M0-CORE-08 Histogram of the last kSystemTimingWindowSamples measured run times, ms). Cold path: the M1-PROF-02 frame graph's budgetCheck consumes it (its stats() is O(n log n)). nullptr for an invalid id or a moved-from world. The window is owned by the world (one owner thread — CONC-001): never keep the reference past the world.", "budget": null, "experimental": false}, + {"name": "laige::World::clear", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 661, "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": 665, "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": 666, "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": 667, "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": 668, "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": 673, "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}, @@ -488,32 +490,39 @@ {"name": "laige::Read::value", "kind": "variable", "header": "src/laige-sim/include/laige/sim/query.h", "line": 247, "signature": "static constexpr Access value = Access::Read", "summary": null, "budget": null, "experimental": false}, {"name": "laige::Write", "kind": "struct", "header": "src/laige-sim/include/laige/sim/query.h", "line": 249, "signature": "struct Write", "summary": null, "budget": null, "experimental": false}, {"name": "laige::Write::value", "kind": "variable", "header": "src/laige-sim/include/laige/sim/query.h", "line": 250, "signature": "static constexpr Access value = Access::Write", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::SystemId", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 325, "signature": "struct SystemId", "summary": "The stable per-world system id (FR-1.3): assigned in registration order, densely from 1. See the header preamble for the id and determinism contract.", "budget": null, "experimental": false}, - {"name": "laige::SystemId::value", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 326, "signature": "std::uint32_t value{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::kInvalidSystemId", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 331, "signature": "inline constexpr SystemId kInvalidSystemId{0}", "summary": "The never-assigned id (API-008: the invalid state is representable and checkable; call sites never spell raw 0s).", "budget": null, "experimental": false}, - {"name": "laige::operator==", "kind": "function", "header": "src/laige-sim/include/laige/sim/system.h", "line": 333, "signature": "inline bool operator==(SystemId a, SystemId b) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::operator!=", "kind": "function", "header": "src/laige-sim/include/laige/sim/system.h", "line": 336, "signature": "inline bool operator!=(SystemId a, SystemId b) noexcept", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::kMaxSystems", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 344, "signature": "inline constexpr std::uint32_t kMaxSystems = 256", "summary": "The engine-level cap on systems per world (CORE-005: a named engine constant, the kMaxComponentTypes precedent — a game's system count is orders of magnitude smaller than its entity count; raising it is an ADR, not a knob).", "budget": null, "experimental": false}, - {"name": "laige::kMaxSystemDependencies", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 351, "signature": "inline constexpr std::uint32_t kMaxSystemDependencies = 16", "summary": "The bound on one system's direct depends_on list (CORE-005). A direct dependency list is a small hand-written declaration; beyond 16 the ordering should be carried by registration position (a barrier is registration order, not a dependency list). Raising it is an ADR.", "budget": null, "experimental": false}, - {"name": "laige::SystemFn", "kind": "alias", "header": "src/laige-sim/include/laige/sim/system.h", "line": 357, "signature": "using SystemFn = void (*)(World&, SystemContext&)", "summary": "The system function signature (FR-1.3): a plain free function — no class, no inheritance. `world` is the world the system runs on; `ctx` is that tick's SystemContext (one world, one owner thread, PRD §10.2).", "budget": null, "experimental": false}, - {"name": "laige::SystemDef", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 372, "signature": "struct SystemDef", "summary": "The static declaration of a system (FR-1.3): one per system, built by the LAIGE_SYSTEM macro (see the header preamble for the shape). `name` is the stable registration name (unique per world); `run` is the plain system function; `budgetMs` is the declared per-tick time budget in MILLISECONDS (fpx16_16 — exact, no floating point; ADR 0002). `dependsOn` is the raw depends_on spec (M1-SYS-02): a comma-separated list of registration names — nullptr or \"\" means no dependencies (see the preamble \"Scheduler\" for the format and the validation). The declared component I/O is NOT part of the def (per-world runtime ids, see the preamble): it is declared at registration (the Io<...> pack of World::registerSystem) and stored in the world's record. The def is a small trivially-copyable value — registerSystem copies it, so a def on the stack is safe.", "budget": null, "experimental": false}, - {"name": "laige::SystemDef::name", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 373, "signature": "const char* name", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::SystemDef::run", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 374, "signature": "SystemFn run", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::SystemDef::budgetMs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 375, "signature": "fpx16_16 budgetMs", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::SystemDef::dependsOn", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 376, "signature": "const char* dependsOn", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::SystemSchedule", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 387, "signature": "struct SystemSchedule", "summary": "The computed execution order of one world's systems (M1-SYS-02). A plain value: built by World::scheduleSystems (setup phase), consumed by World::runSystems once per tick, owned by the caller (the game's engine object — M1-HEAD-01). `systemCount` is the world's system count AT SCHEDULING TIME (runSystems' staleness check); `order[i]` is the SystemId of the system that runs i-th (order[0] first, order[systemCount - 1] last; no repeats, dense 1..systemCount).", "budget": null, "experimental": false}, - {"name": "laige::SystemSchedule::systemCount", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 388, "signature": "std::uint32_t systemCount{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::SystemSchedule::order", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 389, "signature": "std::uint32_t order[kMaxSystems]{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::SystemContext", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 399, "signature": "struct SystemContext", "summary": "The per-tick context handed to a system's run() (FR-1.3; the PRD Appendix B sketch's `ctx`). It names the world the system runs on and delegates iteration to World::each (query.h) — the sketch's `ctx.each<...>()`. The context is built per system per tick by the scheduler (M1-SYS-02); until then games and tests build it directly. It is a non-owning view (the world owns the storage): never store it across ticks.", "budget": null, "experimental": false}, - {"name": "laige::SystemContext::world", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 401, "signature": "World& world", "summary": "The world the system runs on (one world, one owner thread).", "budget": null, "experimental": false}, - {"name": "laige::SystemContext::each", "kind": "method", "header": "src/laige-sim/include/laige/sim/system.h", "line": 410, "signature": "template [[nodiscard]] Status each(F&& fn, Acc... acc) noexcept", "summary": "Delegate to World::each(fn, Read/Write tags...) on the same world: identical semantics, visit order, iteration-legality behavior, and Status results (query.h). No allocation. The definition is out-of-line in entity.h (the World home): World is incomplete here, and the delegated call is checked at instantiation — which needs the complete World.", "budget": "O(kMaxArchetypes * N) scan + one visit per matching entity; no allocation.", "experimental": false}, - {"name": "laige::Io", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 427, "signature": "template struct Io", "summary": "One declared component I/O entry of a system (FR-1.3): component type T and its declared access. Pass one value of this tag type per component in the Io<...> pack of World::registerSystem:", "budget": null, "experimental": false}, - {"name": "laige::Io::access", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 430, "signature": "static constexpr Access access = kAccess", "summary": "The declared access of the entry (Read or Write).", "budget": null, "experimental": false}, - {"name": "laige::SystemInfo", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 440, "signature": "struct SystemInfo", "summary": "A registered system's snapshot (a plain value; the M1-SYS-02 scheduler and the M1-PROF-01 profiler pull it). `def` is the value copy of the registered def; `id` is the world's SystemId. The declared component I/O list (FR-1.3) is stored as the disjoint read/write id sets and read back through the membership queries: the documented list order is ascending ComponentTypeId (component.h id contract — a pure function of the sets).", "budget": null, "experimental": false}, - {"name": "laige::SystemInfo::def", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 441, "signature": "SystemDef def{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::SystemInfo::id", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 442, "signature": "SystemId id{}", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::SystemInfo::declaresRead", "kind": "method", "header": "src/laige-sim/include/laige/sim/system.h", "line": 446, "signature": "[[nodiscard]] bool declaresRead(ComponentTypeId componentId) const noexcept", "summary": "True when the system declares `componentId` for reading.", "budget": "O(1); no allocation.", "experimental": false}, - {"name": "laige::SystemInfo::declaresWrite", "kind": "method", "header": "src/laige-sim/include/laige/sim/system.h", "line": 450, "signature": "[[nodiscard]] bool declaresWrite(ComponentTypeId componentId) const noexcept", "summary": "True when the system declares `componentId` for writing.", "budget": "O(1); no allocation.", "experimental": false}, - {"name": "LAIGE_SYSTEM", "kind": "macro", "header": "src/laige-sim/include/laige/sim/system.h", "line": 485, "signature": "#define LAIGE_SYSTEM(Name, budget_ms, ...)", "summary": "Declare a system (FR-1.3): at namespace scope, directly above the plain system function's definition. `Name` is both the C++ function name and the system's registration name (stringified); `budget_ms` is the declared per-tick time budget in milliseconds (a numeric literal, e.g. 1 or 0.5 — converted to the exact fpx16_16 once, at program start); the optional trailing `Dep...` names are the depends_on spec (M1-SYS-02): the registration names of the systems `Name` must run after, stringified verbatim into the def's `dependsOn` field (comma-separated, as written). Expands to the function declaration plus", "budget": null, "experimental": false} + {"name": "laige::SystemId", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 376, "signature": "struct SystemId", "summary": "The stable per-world system id (FR-1.3): assigned in registration order, densely from 1. See the header preamble for the id and determinism contract.", "budget": null, "experimental": false}, + {"name": "laige::SystemId::value", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 377, "signature": "std::uint32_t value{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kInvalidSystemId", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 382, "signature": "inline constexpr SystemId kInvalidSystemId{0}", "summary": "The never-assigned id (API-008: the invalid state is representable and checkable; call sites never spell raw 0s).", "budget": null, "experimental": false}, + {"name": "laige::operator==", "kind": "function", "header": "src/laige-sim/include/laige/sim/system.h", "line": 384, "signature": "inline bool operator==(SystemId a, SystemId b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::operator!=", "kind": "function", "header": "src/laige-sim/include/laige/sim/system.h", "line": 387, "signature": "inline bool operator!=(SystemId a, SystemId b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::kMaxSystems", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 395, "signature": "inline constexpr std::uint32_t kMaxSystems = 256", "summary": "The engine-level cap on systems per world (CORE-005: a named engine constant, the kMaxComponentTypes precedent — a game's system count is orders of magnitude smaller than its entity count; raising it is an ADR, not a knob).", "budget": null, "experimental": false}, + {"name": "laige::kMaxSystemDependencies", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 402, "signature": "inline constexpr std::uint32_t kMaxSystemDependencies = 16", "summary": "The bound on one system's direct depends_on list (CORE-005). A direct dependency list is a small hand-written declaration; beyond 16 the ordering should be carried by registration position (a barrier is registration order, not a dependency list). Raising it is an ADR.", "budget": null, "experimental": false}, + {"name": "laige::kSystemTimingWindowSamples", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 409, "signature": "inline constexpr std::uint32_t kSystemTimingWindowSamples = 64", "summary": "The per-system rolling window capacity (M1-SYS-03; CORE-005): the number of measured run times (ms) kept per system in the rolling histogram — ~1.1 s of samples at the default 60 Hz tick rate. The window is fixed at world construction (a setup-path allocation); raising the capacity is an ADR, not a knob.", "budget": null, "experimental": false}, + {"name": "laige::kBudgetCriticalMultiplier", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 415, "signature": "inline constexpr std::uint32_t kBudgetCriticalMultiplier = 3", "summary": "The over-budget multiplier that escalates the budget_overrun warn into a budget_critical error event (M1-SYS-03; PRD §9.3 G-R5: \"over 3× → error event\"; CORE-005). The warn fires strictly above 1× the declared budget; the error at 3× or more.", "budget": null, "experimental": false}, + {"name": "laige::SystemFn", "kind": "alias", "header": "src/laige-sim/include/laige/sim/system.h", "line": 421, "signature": "using SystemFn = void (*)(World&, SystemContext&)", "summary": "The system function signature (FR-1.3): a plain free function — no class, no inheritance. `world` is the world the system runs on; `ctx` is that tick's SystemContext (one world, one owner thread, PRD §10.2).", "budget": null, "experimental": false}, + {"name": "laige::SystemDef", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 436, "signature": "struct SystemDef", "summary": "The static declaration of a system (FR-1.3): one per system, built by the LAIGE_SYSTEM macro (see the header preamble for the shape). `name` is the stable registration name (unique per world); `run` is the plain system function; `budgetMs` is the declared per-tick time budget in MILLISECONDS (fpx16_16 — exact, no floating point; ADR 0002). `dependsOn` is the raw depends_on spec (M1-SYS-02): a comma-separated list of registration names — nullptr or \"\" means no dependencies (see the preamble \"Scheduler\" for the format and the validation). The declared component I/O is NOT part of the def (per-world runtime ids, see the preamble): it is declared at registration (the Io<...> pack of World::registerSystem) and stored in the world's record. The def is a small trivially-copyable value — registerSystem copies it, so a def on the stack is safe.", "budget": null, "experimental": false}, + {"name": "laige::SystemDef::name", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 437, "signature": "const char* name", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::SystemDef::run", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 438, "signature": "SystemFn run", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::SystemDef::budgetMs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 439, "signature": "fpx16_16 budgetMs", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::SystemDef::dependsOn", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 440, "signature": "const char* dependsOn", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::SystemSchedule", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 451, "signature": "struct SystemSchedule", "summary": "The computed execution order of one world's systems (M1-SYS-02). A plain value: built by World::scheduleSystems (setup phase), consumed by World::runSystems once per tick, owned by the caller (the game's engine object — M1-HEAD-01). `systemCount` is the world's system count AT SCHEDULING TIME (runSystems' staleness check); `order[i]` is the SystemId of the system that runs i-th (order[0] first, order[systemCount - 1] last; no repeats, dense 1..systemCount).", "budget": null, "experimental": false}, + {"name": "laige::SystemSchedule::systemCount", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 452, "signature": "std::uint32_t systemCount{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::SystemSchedule::order", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 453, "signature": "std::uint32_t order[kMaxSystems]{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::SystemContext", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 463, "signature": "struct SystemContext", "summary": "The per-tick context handed to a system's run() (FR-1.3; the PRD Appendix B sketch's `ctx`). It names the world the system runs on and delegates iteration to World::each (query.h) — the sketch's `ctx.each<...>()`. The context is built per system per tick by the scheduler (M1-SYS-02); until then games and tests build it directly. It is a non-owning view (the world owns the storage): never store it across ticks.", "budget": null, "experimental": false}, + {"name": "laige::SystemContext::world", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 465, "signature": "World& world", "summary": "The world the system runs on (one world, one owner thread).", "budget": null, "experimental": false}, + {"name": "laige::SystemContext::each", "kind": "method", "header": "src/laige-sim/include/laige/sim/system.h", "line": 474, "signature": "template [[nodiscard]] Status each(F&& fn, Acc... acc) noexcept", "summary": "Delegate to World::each(fn, Read/Write tags...) on the same world: identical semantics, visit order, iteration-legality behavior, and Status results (query.h). No allocation. The definition is out-of-line in entity.h (the World home): World is incomplete here, and the delegated call is checked at instantiation — which needs the complete World.", "budget": "O(kMaxArchetypes * N) scan + one visit per matching entity; no allocation.", "experimental": false}, + {"name": "laige::Io", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 491, "signature": "template struct Io", "summary": "One declared component I/O entry of a system (FR-1.3): component type T and its declared access. Pass one value of this tag type per component in the Io<...> pack of World::registerSystem:", "budget": null, "experimental": false}, + {"name": "laige::Io::access", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 494, "signature": "static constexpr Access access = kAccess", "summary": "The declared access of the entry (Read or Write).", "budget": null, "experimental": false}, + {"name": "laige::SystemInfo", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 504, "signature": "struct SystemInfo", "summary": "A registered system's snapshot (a plain value; the M1-SYS-02 scheduler and the M1-PROF-01 profiler pull it). `def` is the value copy of the registered def; `id` is the world's SystemId. The declared component I/O list (FR-1.3) is stored as the disjoint read/write id sets and read back through the membership queries: the documented list order is ascending ComponentTypeId (component.h id contract — a pure function of the sets).", "budget": null, "experimental": false}, + {"name": "laige::SystemInfo::def", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 505, "signature": "SystemDef def{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::SystemInfo::id", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 506, "signature": "SystemId id{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::SystemInfo::declaresRead", "kind": "method", "header": "src/laige-sim/include/laige/sim/system.h", "line": 510, "signature": "[[nodiscard]] bool declaresRead(ComponentTypeId componentId) const noexcept", "summary": "True when the system declares `componentId` for reading.", "budget": "O(1); no allocation.", "experimental": false}, + {"name": "laige::SystemInfo::declaresWrite", "kind": "method", "header": "src/laige-sim/include/laige/sim/system.h", "line": 514, "signature": "[[nodiscard]] bool declaresWrite(ComponentTypeId componentId) const noexcept", "summary": "True when the system declares `componentId` for writing.", "budget": "O(1); no allocation.", "experimental": false}, + {"name": "laige::SystemTimingStats", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 536, "signature": "struct SystemTimingStats", "summary": "One system's measured-run scalars (M1-SYS-03; PRD §9.3 G-R5): the cheap per-frame snapshot the profiler (M1-PROF-01) and the frame graph (M1-PROF-02) pull through World::systemTimingStats(id) — O(1), no allocation, no side effects. The window SAMPLES are not here (the rolling histogram is read cold through World::systemTimingWindow(id) — its stats() is O(n log n)). All counters are since-construction; the window rolls across ticks (kSystemTimingWindowSamples, not per-frame).", "budget": null, "experimental": false}, + {"name": "laige::SystemTimingStats::runs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 538, "signature": "std::uint64_t runs{}", "summary": "Measured runs of the system since world construction.", "budget": null, "experimental": false}, + {"name": "laige::SystemTimingStats::lastMs", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 541, "signature": "double lastMs{}", "summary": "The measured time (ms) of the most recent run (0 before the first run).", "budget": null, "experimental": false}, + {"name": "laige::SystemTimingStats::warns", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 543, "signature": "std::uint32_t warns{}", "summary": "The system/budget_overrun warns issued since construction.", "budget": null, "experimental": false}, + {"name": "laige::SystemTimingStats::errors", "kind": "variable", "header": "src/laige-sim/include/laige/sim/system.h", "line": 546, "signature": "std::uint32_t errors{}", "summary": "The system/budget_critical error events issued since construction.", "budget": null, "experimental": false}, + {"name": "LAIGE_SYSTEM", "kind": "macro", "header": "src/laige-sim/include/laige/sim/system.h", "line": 570, "signature": "#define LAIGE_SYSTEM(Name, budget_ms, ...)", "summary": "Declare a system (FR-1.3): at namespace scope, directly above the plain system function's definition. `Name` is both the C++ function name and the system's registration name (stringified); `budget_ms` is the declared per-tick time budget in milliseconds (a numeric literal, e.g. 1 or 0.5 — converted to the exact fpx16_16 once, at program start); the optional trailing `Dep...` names are the depends_on spec (M1-SYS-02): the registration names of the systems `Name` must run after, stringified verbatim into the def's `dependsOn` field (comma-separated, as written). Expands to the function declaration plus", "budget": null, "experimental": false} ] } diff --git a/roadmap/M1-heartbeat.md b/roadmap/M1-heartbeat.md index f66c775..7df2eb2 100644 --- a/roadmap/M1-heartbeat.md +++ b/roadmap/M1-heartbeat.md @@ -113,7 +113,7 @@ zero-allocation property (M1-ALLOC-01 enforces it once it exists; before that, A - **Verify:** `ctest -R scheduler` green. - **Size:** ~200 lines + tests -- [ ] **M1-SYS-03 · Per-system timing + budget enforcement (G-R5)** +- [x] **M1-SYS-03 · Per-system timing + budget enforcement (G-R5)** - **Refs:** PRD §9.3 G-R5; FR-11.1/11.2; FR-12.3 - **Depends:** M1-SYS-02, M0-CORE-08 - **Scope:** diff --git a/roadmap/README.md b/roadmap/README.md index e343c92..23a4762 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -156,7 +156,7 @@ Updated in the same PR that closes steps. "Done" = box checked + Verify green. | Milestone | Steps | Done | Status | |---|---|---|---| | M0 | 22 | 22 | ✅ complete (2026-09-13, M0-EXIT-01) | -| M1 | 25 | 9 | 🚧 in progress (M1-SYS-02) | +| M1 | 25 | 10 | 🚧 in progress (M1-SYS-03) | | M2 | 32 | 0 | ⬜ not started | | M3 | 36 | 0 | ⬜ not started | | M4 | 12 | 0 | ⬜ not started | @@ -165,7 +165,7 @@ Updated in the same PR that closes steps. "Done" = box checked + Verify green. | M7 | 15 | 0 | ⬜ not started | | M8 | 8 | 0 | ⬜ not started | | M9 | 6 | 0 | ⬜ proposals only | -| **Total** | **193** | **29** | | +| **Total** | **193** | **30** | | --- @@ -204,6 +204,7 @@ One line per completed (or split/renumbered) step. | 2026-09-14 | M1-ECS-07 | `2995ec7` | ECS stress + memory accounting test (M1-ECS-07 scope, nothing else): new EcsStress suite — the step's Verify command `ctest -R ecs_stress` (added to the TSan property list) over the shared `laige-sim_tests` executable: 10k entities at the 100% scene budget (capacity 10000), 6 registered component types (Pos/Vel/Flag/Quad/Pair/Tag — 4/8/8/16/8/4 B, distinct strides), 10k frames of add/remove churn — each frame `beginFrame()` (drives G-R3/G-R4) + 64 seeded Tag adds + 64 Tag removes (cyclic Fisher-Yates permutation, `TestPrng` substream 1008, default-seed deterministic; 128 ops/frame, the default 256 G-R4 budget never exceeded) + one `each` iteration (Read, Read) with visit count + 64-bit FNV-1a checksum over (slot, generation, tag value); 700-frame warm-up (one full 625-frame cohort period — 8 cycles × 10000/128 picks/cycle — plus margin) brings every archetype's columns to their high water before the window, so the window's zero `totalReservations`/`totalArchetypeGrowth` delta IS the "pool high-water stable" claim (measured high water: 9 archetypes — 4 base + 4 Tagged + transient {Pos} — 24592 reserved rows, 549024 bytes); iteration within the documented cost (query.h: bounded archetype scan + one visit per matching entity) via a window-wide ns-per-visit throughput floor (600 ns, ≥8x the slowest measured: 58.7 ns g++ 16.2.1 / 72.3 ns clang++ 22.1.8, -O0 Debug); zero-allocation window (test-only operator-new counter, non-sanitizer trees; sanitizer trees: leak-free run + reservation delta); no NEW guardrail warns in the window (churnWarns delta 0; the entity-budget 25/50/100% warns fire exactly once at setup and never re-cross — the entity count never changes); memory accounting (PRD §8.1 base memory, accounted bytes): 110000 entity bookkeeping bytes (11 B/slot × 10k) + 549024 reserved row bytes at the 100%-full scene; machine-greppable `ecs-stress window/iteration/memory` lines on every ctest run — the window/memory lines are BYTE-IDENTICAL across g++/clang++ (pure integer workload, ARCH-010) and across repeated runs; no-leak Verify: `ctest -R ecs_stress` green on `build-asan` (37.7 s, no ASan/UBSan report); second baseline `docs/benchmarks/baselines/m1-ecs-stress.md` (AGENTS §12 fields, verbatim runs, cross-tree results; not a `budgets.json` workload — no `measured` field updated); baselines index + benchmarks README updated; no public API added — `laige-api.json` unchanged (452 symbols, `api-real-tree` green in the full-suite runs), no include-graph change (comments only); local Verify: `ctest -R ecs_stress` green on `build` (Debug g++, 14.7 s), `build-asan` (required, leak-free), `build-release`, `build-clang`, `build-tsan`, `build-shared`; full suite 40/40 on `build`/`build-asan`/`build-clang`, `laige-sim_tests` + `ecs_stress` green on the other trees; zero new warnings under NFR-8.10 | | 2026-09-14 | M1-SYS-01 | `115d28c` | System registry (FR-1.3: plain registered functions with declared time budgets and declared component I/O; M1-SYS-01 scope, nothing else): new public header `src/laige-sim/include/laige/sim/system.h` — `SystemId` (32-bit dense id from 1, registration order, per-world, deterministic — component.h id contract), `SystemDef` (name + `SystemFn` = `void(*)(World&, SystemContext&)` + `budgetMs` in ms as `fpx16_16` — exact, ADR 0002, keeps the future sim source scan float-free), the `LAIGE_SYSTEM(Name, budget_ms)` macro (namespace scope: the plain function declaration + the `Name##Def` def variable — no class, no inheritance), `SystemContext` (per-tick world view; `each(fn, Read/Write tags...)` delegates to `World::each` — definition out-of-line in entity.h where World is complete), `Io` (the per-component declared I/O tag; a component appears at most once per system, any access combination — the I/O is a set, not a multiset, stored as disjoint read/write id sets; ascending-id enumeration order documented), `SystemInfo` (the def value copy + `declaresRead`/`declaresWrite`, the M1-SYS-02/M1-PROF-01 feed), `kMaxSystems = 256` (engine-level bound, CORE-005), `detail::SystemRecord` + the `IsIoTag`/`IsIoComponent`/`IoComponent` traits (class form — the api scanner parses class partial specializations; variable templates are an unsupported scanner construct); `World::registerSystem(def, Io<...>...)` (header-defined template, entity.h), `World::systemCount()`, `World::system(id)` (`systems.cpp`); the fixed record table is allocated in `create()` like the component registry, travels with the world on move, and survives `clear()`; validation (first failure wins; every failure one rate-limited structured warn + `Status` — FR-12.3/LOG-004): moved-from world → `InvalidArgument` (no warn, the registerComponent precedent), null/empty name → `system/name_invalid`, null run → `system/run_invalid`, budget ≤ 0 → `system/budget_invalid` (the budget must be explicit and positive), duplicate name → `system/duplicate` (the roadmap's named property), Io T not a component → compile error (static_assert), Io T unregistered in this world → `system/io_unregistered`, same component twice (any access) → `system/io_duplicate`, > kMaxSystems → `BudgetExhausted` + `system/budget_exhausted`; the def is value-copied into the record table: no allocation at registration (setup path — PERF-003); new `SystemRegistry` suite (25 tests, CTest entry `system_registry`, added to the TSan property list): registration, ids dense from 1, the def value copy, the I/O sets + zero-I/O pack, context delegation (write + read paths through the plain functions), every validation error, the kMaxSystems budget (257 distinct names), `system()` id validation, id stability across two worlds + registration-order-determines-ids (ARCH-010), move/clear/moved-from-world lifetime, the zero-alloc registration window (test-only operator-new counter, non-sanitizer trees; sanitizer trees: leak-free), warn-once + `rate_limited` sink checks (`system/duplicate` name/existing_system_id fields, `system/budget_invalid` budget_raw field); docs: `docs/api/system_registry.md` (full contract + Performance section) linked from `docs/README.md`, `src/laige-sim/README.md` status updated (incl. the M1-ECS-06/07 lines), entity.h preamble + member docs carry the M1-SYS-01 note; `laige-api.json` regenerated (489 symbols; `api-real-tree` green); local Verify: `ctest -R system_registry` green on `build` (25/25 incl. the zero-alloc window), full suite 41/41 on `build`/`build-asan` (leak-free)/`build-tsan`/`build-clang`/`build-release`/`build-shared`, zero new warnings under NFR-8.10, `tools/laige-include-lint` OK (27 source files, 1/10 vendored deps) | | 2026-09-14 | M1-SYS-02 | `84c5c06` | System scheduler (M1-SYS-02 scope, nothing else): the scheduler turns the M1-SYS-01 registry (registration order + declared depends_on + declared component I/O) into the per-tick execution order and runs the systems in it — `SystemSchedule` (the systemCount plus the dense SystemId order array), `World::scheduleSystems(SystemSchedule&) const` (setup phase; pure registry read; the STABLE topological sort of the registration order plus the depends_on edges — Kahn's algorithm with a min-id tie-break: repeatedly place the smallest unrun id whose dependencies are all placed, so a system only moves LATER, behind its dependencies, and no dependencies = exactly the registration order), and `World::runSystems(const SystemSchedule&)` (one sim tick's system phase: the systems run STRICTLY one at a time in schedule order on the world's single owner thread, a fresh non-owning SystemContext per system — PRD §10.2/API-004); `SystemDef` gains `dependsOn` (the raw comma-separated registration-name spec; nullptr/"" = none) and `LAIGE_SYSTEM(Name, budget_ms, Dep..., ...)` becomes variadic (the optional trailing names stringized verbatim into the spec — `LAIGE_SYSTEM(Health, 1, Spawner)` = spec "Spawner"); `kMaxSystemDependencies = 16` (the direct-dep bound, CORE-005 — a barrier is registration position, not a dependency list); `detail::DepSpecParse`/`DepSpecError`/`parseDepSpec`/`depSpecErrorName` (system.h; defined in systems.cpp — tokens point into the spec literal, no copy, no allocation); validation (first failure wins; every failure one rate-limited structured warn, subsystem `system`, + Status — FR-12.3): at REGISTRATION (the def-level form, before the duplicate-name check — def fields first): malformed spec (empty token/trailing comma, duplicate name, > 16 deps) → `system/dep_spec_invalid` (fields name/error); at SCHEDULING (normative order): unknown dependency name (first in ascending (system id, spec position)) → `system/dep_missing` (fields system/missing_dep/position; the token logged bounded to 64 chars — LOG-005), dependency cycle → `system/dependency_cycle` (ONE concrete cycle reported: the deterministic walk from the smallest remaining id following each system's first spec-listed dependency that is still remaining — a remaining system always has one, the Kahn invariant; the `cycle` field is the walk order, comma-joined, bounded to 256 chars — independents already scheduled are excluded), two systems both declaring Write of the same component in one tick (order-independent: the last write would silently win; first conflict in ascending component-id then writer-id) → `system/double_writer` (fields component_id/first_writer/second_writer); WARN ONLY (scheduling succeeds): a declared read that the computed order places BEFORE a declared write of the same component (the reader sees the previous tick's value; each (reader, writer, component) triple once, ascending component/reader/writer; fix advice in the message: declare depends_on or register the writer earlier) → `system/read_before_write` (fields reader/writer/component_id); at RUNNING: schedule.systemCount ≠ the current systemCount (registry changed since scheduling, or another world's schedule) → `system/schedule_stale` (fields scheduled_systems/current_systems), an order entry that is 0 / above the count / a duplicate id (hand-built schedule) → `system/schedule_invalid` (fields slot/id), empty schedule → ok and runs nothing; the success paths log nothing (LOG-003); determinism (ARCH-010): pure integer/string bookkeeping — no floating point, no randomness, no addresses in the order or the warning set (two worlds, two runs, two builds → bit-identical schedules + warning sequences); no allocation at scheduling or per tick (PERF-003 — all state fixed-size stack/world arrays); new `SystemScheduler` suite (26 tests, CTest entry `scheduler`, added to the TSan property list): registration order = execution order, forward/backward deps, the chain and the diamond (reversed spec list — the dependency set is orderless, the tie-break is the min id), the macro spec stringization (1-dep and 2-dep macro forms + the no-dep "" spec), running in scheduled order with state flow (writer before reader → the reader sees the fresh 0x1234; reader before writer → the stale value 0x9999 is observed), reader-before-writer warns (sink: reader/writer/component_id fields; schedule still succeeds), writer-before-reader is clean (the sink stays EMPTY — the success path logs nothing), double-writer rejected (sink: component_id/first_writer/second_writer + LOG-004 rate_limited summary suppressed=2 on shutdown), missing dependency (sink: system/missing_dep fields), the 2-cycle + the self-dependency (cycle [self]) + the cycle among independent systems (the reported cycle excludes the independents that scheduled first), spec validation (empty token, trailing comma, duplicate name, the 17-dep bound, whitespace trimming is legal — " TrimA , TrimB " resolves), the empty + moved-from world schedules and runs empty, the stale schedule is rejected (recompute → usable; nothing ran), the hand-built malformed schedules (duplicate id, id above the count) are rejected (nothing ran), the order bit-identical across two worlds with the same registrations (ARCH-010 memcmp over the order arrays), the known-answer pin (the fixed 5-system scenario WITH a forward edge: order B,A,C,D,E — machine-greppable `scheduler-order systems=5 fnv1a=0xaef3282f393ab332`, pinned in the test), and the zero-alloc window (100 ticks × 3 systems over 4 entities: schedule + every runSystems allocate nothing — the test-only operator-new counter, non-sanitizer trees; machine-greppable `scheduler-zeroalloc ticks=100 allocs=0`; the sanitizer trees prove it leak-free); docs: `docs/api/scheduler.md` (full contract + Performance section) linked from `docs/README.md` (the API list + the per-module laige-sim list, which gains the previously missing system_registry.md entry), `docs/api/system_registry.md` updated (the macro is variadic now, the validation table gains the dep_spec_invalid row, cross-refs to scheduler.md), `src/laige-sim/README.md` status updated, system.h/entity.h preambles + member docs carry the M1-SYS-02 note; no new source file (the scheduler lands in systems.cpp — the sim CMake comment updated); `laige-api.json` regenerated (496 symbols, +7: kMaxSystemDependencies, SystemDef::dependsOn, SystemSchedule + systemCount + order, World::scheduleSystems + World::runSystems; `api-real-tree` green); local Verify: `ctest -R scheduler` green on `build` (26/26 incl. the zero-alloc window), full suite 42/42 on `build`/`build-asan` (leak-free)/`build-tsan`/`build-clang`/`build-release`/`build-shared`, zero new warnings under NFR-8.10, `tools/laige-include-lint` OK (27 source files, 1/10 vendored deps) | +| 2026-09-14 | M1-SYS-03 | `a63d6b9` | Per-system timing + budget enforcement (PRD §9.3 G-R5; FR-11.1/11.2, FR-12.3; M1-SYS-03 scope, nothing else): `World::runSystems` now times each system's own run (the M0-CORE-08 `TimeIt` steady_clock scope around the run function — two steady_clock reads per system, the context built outside the window) and hands the sample to `World::checkSystemBudget` (new `src/laige-sim/system_timing.cpp`): it records into the system's rolling window — a fixed-capacity `Histogram` (`kSystemTimingWindowSamples` = 64 samples ≈ 1.1 s at 60 Hz; O(1) record, no allocation, drops the OLDEST on overflow, `totalRecorded()` keeps counting; the window rolls across TICKS — `beginFrame()` does not touch it) — and enforces the declared budget: `measured > 1× budget` → `system/budget_overrun` (Warn), `measured >= 3× budget` (`kBudgetCriticalMultiplier`, PRD "over 3× → error event") → `system/budget_critical` (Error); a 3× run fires BOTH in the same tick. Both events: NFR-13.3 5-field grammar (build-stable message text; dynamic values as structured fields `system`/`id`/`measured_ms`/`budget_ms`/`p99_ms`/`window_samples` — never message text), rate-limited per (subsystem, event, severity) with the 1 s window (LOG-004; the `rate_limited` summary carries the suppressed count), and count in `SystemTimingStats` (`warns`/`errors`) even when suppressed; the p99 comes from one cold O(W log W) `stats()` pass (no allocation) only while the breach persists. An over-budget system is STILL RUN — observation and reporting, never an execution gate (FR-12.3). New public API (additive): `SystemTimingStats` (runs/lastMs/warns/errors), `kSystemTimingWindowSamples`, `kBudgetCriticalMultiplier`, `World::systemTimingStats(SystemId)` (Result; O(1) pure query; invalid id or moved-from → `InvalidArgument`, no warn — the `World::system` precedent) and `World::systemTimingWindow(SystemId)` (const Histogram* — the M1-PROF-02 frame graph's budgetCheck feed; nullptr for invalid); `detail::SystemTimingRecord` (unique_ptr Histogram — the Histogram has no default ctor — + the cheap scalars) in a fixed kMaxSystems table parallel to the registry (allocated in `create()` even for zero-capacity worlds, travels with the world on move, survives `clear()`); `World::checkSystemBudget` (private hook, called per system per tick). Determinism: measured times are DIAGNOSTIC only (ARCH-009) — they never enter authoritative state, hashes, or replays. Hot path: two clock reads + one ring write + two comparisons per system per tick — no allocation, no logging on success (the `SchedulingAndTicksAllocateNothing` window still proves 0 allocs over 100 ticks). New `SystemTiming` suite (8 tests) + CTest entry `system_timing` (the step's Verify command; TSan property list): healthy ticks log nothing + track stats (runs/lastMs/window count/totalRecorded); over-budget synthetic system warns at the documented multiplier (1 warn, measured ≥ 6.5 ms against a 5 ms budget; second tick rate-limited; shutdown `rate_limited` summary `suppressed` = 1); critical synthetic system (1 ms budget, 7 ms burn) fires the warn THEN the error in one tick; rolling window drops oldest (5W-sample fast/slow/fast phases with min/max/p99 bounds + machine-greppable `system-timing window` line); NFR-13.3 grammar check (5-field split on `" | "`, fields[0] = event, doc anchor `docs/api/system_timing.md`; the second over-budget system's warn is rate-suppressed and summarized at shutdown); query validation (id 0 / above count / moved-from → `InvalidArgument`/nullptr; no-systems world → 0-count stats ok); state travels with move (5 ticks pre-move, moved world keeps stats + window, moved-from queries fail); zero-allocation window (test-only operator-new counter, non-sanitizer trees only — 100 ticks × 2 systems → `allocs=0`, machine-greppable `system-timing-zeroalloc` line). Verified: `ctest -R system_timing` green + full suite 43/43 on all six local trees (`build` Debug GCC 16.2.1, `build-asan` ASan+UBSan leak-free, `build-tsan`, `build-clang`, `build-release`, `build-shared`), zero new warnings under NFR-8.10, `tools/laige-include-lint` OK (28 source files, 1/10 vendored deps), `laige-api.json` regenerated (496 → 505 symbols; +9: `SystemTimingStats` + 4 members, `kSystemTimingWindowSamples`, `kBudgetCriticalMultiplier`, `World::systemTimingStats`, `World::systemTimingWindow`) with `api-real-tree` green. Docs in the same change (DOC-007): new `docs/api/system_timing.md` (measurement scope, the rolling window, the thresholds + event fields, the profiler feed, the ARCH-009 determinism scope, the Performance section, misuse warnings) + cross-refs in `docs/api/scheduler.md`, `docs/api/system_registry.md`, `docs/README.md`, `src/laige-sim/README.md`. Compat: additive only — no existing symbol or behavior changed. --- diff --git a/src/laige-sim/CMakeLists.txt b/src/laige-sim/CMakeLists.txt index 3d4290c..443ebc2 100644 --- a/src/laige-sim/CMakeLists.txt +++ b/src/laige-sim/CMakeLists.txt @@ -30,8 +30,13 @@ # system scheduler to the same systems.cpp (scheduleSystems, # runSystems + the depends_on spec parse — no new source file; the # public types and contract live in include/laige/sim/system.h). +# M1-SYS-03 adds system_timing.cpp: the per-system timing + budget +# enforcement (the World::systemTimingStats/systemTimingWindow +# queries and the World::checkSystemBudget hook that runSystems +# calls per system per tick; the public types and contract live in +# include/laige/sim/system.h). set(LAIGE_SIM_SOURCES entity.cpp archetype.cpp query.cpp guardrails.cpp - systems.cpp) + systems.cpp system_timing.cpp) if(LAIGE_BUILD_SHARED) add_library(laige-sim SHARED ${LAIGE_SIM_SOURCES}) diff --git a/src/laige-sim/README.md b/src/laige-sim/README.md index 8143af2..0822098 100644 --- a/src/laige-sim/README.md +++ b/src/laige-sim/README.md @@ -56,6 +56,15 @@ read-before-write warn), `SystemSchedule`, and `systems.cpp`; API contract in [docs/api/scheduler.md](../docs/api/scheduler.md), tests under [tests/laige-sim](../tests/laige-sim), CTest entry `scheduler`). -The system timing/budget measurement (M1-SYS-03 onward) and the game -loop (M1-LOOP) land in the remaining M1 steps; physics, input, and -animation in M3. +M1-SYS-03 landed the per-system timing + budget enforcement — the +per-tick rolling time windows (`kSystemTimingWindowSamples`), the +G-R5 budget enforcement (the `system/budget_overrun` warn and +`system/budget_critical` error events, +`kBudgetCriticalMultiplier`), and the +`World::systemTimingStats`/`systemTimingWindow` profiler feed +(`include/laige/sim/system.h`, `system_timing.cpp` + the per-system +measurement in `runSystems` (`systems.cpp`); API contract in +[docs/api/system_timing.md](../docs/api/system_timing.md), tests +under [tests/laige-sim](../tests/laige-sim), CTest entry +`system_timing`). The game loop (M1-LOOP) and the profiler land in +the remaining M1 steps; physics, input, and animation in M3. diff --git a/src/laige-sim/entity.cpp b/src/laige-sim/entity.cpp index 96d9b86..93b7a89 100644 --- a/src/laige-sim/entity.cpp +++ b/src/laige-sim/entity.cpp @@ -74,7 +74,8 @@ World::World(World&& other) noexcept iterationArchetypes_(other.iterationArchetypes_), iterationReadComponents_(other.iterationReadComponents_), systems_(std::move(other.systems_)), - systemCount_(other.systemCount_) { + systemCount_(other.systemCount_), + systemTiming_(std::move(other.systemTiming_)) { other.capacity_ = 0; other.freeCount_ = 0; other.inUse_ = 0; @@ -111,6 +112,9 @@ World::World(World&& other) noexcept // moved-from world is a valid empty world in every field (no // registry: registerSystem returns InvalidArgument on it). other.systemCount_ = 0; + // M1-SYS-03: the per-system timing table travels with the registry + // (the move leaves the moved-from world's table null — no timing + // state survives the move, like the registry itself). } World& World::operator=(World&& other) noexcept { @@ -159,6 +163,9 @@ World& World::operator=(World&& other) noexcept { // M1-SYS-01: the system registry travels with the storage. systems_ = std::move(other.systems_); systemCount_ = other.systemCount_; + // M1-SYS-03: the per-system timing table travels with the registry + // (this world's old table is released with the old state). + systemTiming_ = std::move(other.systemTiming_); other.capacity_ = 0; other.freeCount_ = 0; other.inUse_ = 0; @@ -212,6 +219,18 @@ Result World::create(Options options) noexcept { // allocation like the component registry above — allocated even // for a zero-capacity world so it stays a valid empty world. w.systems_ = std::make_unique(kMaxSystems); + // System timing table (M1-SYS-03): the fixed engine-level budget + // (kMaxSystems per-system timing records), parallel to the + // registry table above — allocated even for a zero-capacity world + // so it stays a valid empty world. Each record's rolling window is + // built with the fixed kSystemTimingWindowSamples capacity (the + // M0-CORE-08 Histogram's two backing allocations — a setup path, + // never a hot path). + w.systemTiming_ = std::make_unique(kMaxSystems); + for (std::uint32_t i = 0; i < kMaxSystems; ++i) { + w.systemTiming_[i].window = std::make_unique( + Histogram::Options{kSystemTimingWindowSamples}); + } // Archetype storage (M1-ECS-03): the fixed archetype table // (kMaxArchetypes records, value-initialized) and the type-key // index (kComponentKeyIndexSize slots) — setup-path allocations, diff --git a/src/laige-sim/include/laige/sim/entity.h b/src/laige-sim/include/laige/sim/entity.h index 9eb2f28..11ab601 100644 --- a/src/laige-sim/include/laige/sim/entity.h +++ b/src/laige-sim/include/laige/sim/entity.h @@ -27,7 +27,13 @@ // adds the system scheduler (system.h: SystemSchedule, // the depends_on spec, World::scheduleSystems/ // runSystems — execution order, depends_on, and the -// pre-run I/O validation). +// pre-run I/O validation); M1-SYS-03 adds the per-system +// timing + budget enforcement (system.h: +// SystemTimingStats, kSystemTimingWindowSamples, +// kBudgetCriticalMultiplier, +// World::systemTimingStats/systemTimingWindow — the +// per-tick rolling windows plus the G-R5 warn/error +// events, driven from runSystems). // // --------------------------------------------------------------------------- // The handle contract (FR-1.2, CPP-007) @@ -619,6 +625,28 @@ class World { // schedule.systemCount == 0 -> ok, runs nothing [[nodiscard]] Status runSystems(const SystemSchedule& schedule) noexcept; + // ------------------------------------------------------------- + // Per-system timing + budget enforcement (M1-SYS-03; full + // contract in system.h "Timing and budget enforcement" and + // docs/api/system_timing.md) + // ------------------------------------------------------------- + + // The per-system timing snapshot (SystemTimingStats: the run count, + // the last measured ms, and the warn/error counts). O(1), no + // allocation, no side effects (a pure query, like system()). `id` + // invalid (0 or above systemCount()) or a moved-from world -> + // ErrorCode::InvalidArgument. + [[nodiscard]] Result + systemTimingStats(SystemId id) const noexcept; + + // The per-system rolling window (the M0-CORE-08 Histogram of the + // last kSystemTimingWindowSamples measured run times, ms). Cold + // path: the M1-PROF-02 frame graph's budgetCheck consumes it (its + // stats() is O(n log n)). nullptr for an invalid id or a + // moved-from world. The window is owned by the world (one owner + // thread — CONC-001): never keep the reference past the world. + [[nodiscard]] const Histogram* systemTimingWindow(SystemId id) const noexcept; + // 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), @@ -786,6 +814,14 @@ class World { // frame). void checkChurnBudget() noexcept; + // M1-SYS-03: record one measured system run (ms) in the system's + // rolling window and enforce the declared budget (PRD §9.3 G-R5): + // the system/budget_overrun warn (measured strictly above the + // budget) and the system/budget_critical error event (measured at + // kBudgetCriticalMultiplier × the budget or more). Called from + // runSystems per system per tick (defined in system_timing.cpp). + void checkSystemBudget(std::uint32_t id, double measuredMs) 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 @@ -943,6 +979,14 @@ class World { // not per-entity data). std::unique_ptr systems_; std::uint32_t systemCount_{0}; + // Per-system timing (M1-SYS-03; system_timing.cpp): the fixed + // engine budget (kMaxSystems records), indexed by (system id - 1) + // — parallel to systems_ (the M1-SYS-01 table precedent). Allocated + // in create() alongside the registry, travels with the world on + // move, and survives clear(). Each record's rolling window carries + // the fixed kSystemTimingWindowSamples capacity (setup-path + // allocation only — PERF-003). + std::unique_ptr systemTiming_; }; // Component registration (M1-ECS-02). Header-defined: it is a template, diff --git a/src/laige-sim/include/laige/sim/system.h b/src/laige-sim/include/laige/sim/system.h index 3d0d5e4..b6b5377 100644 --- a/src/laige-sim/include/laige/sim/system.h +++ b/src/laige-sim/include/laige/sim/system.h @@ -24,6 +24,10 @@ // SystemSchedule The computed execution order of a world's systems // (M1-SYS-02): the systemCount plus the SystemId // values in execution order. +// SystemTimingStats +// One system's measured-run scalars (runs, last ms, +// warn/error counts) for the profiler (M1-SYS-03, +// M1-PROF-01/02). // LAIGE_SYSTEM The one-line declaration of a system: the plain // function declaration plus the SystemDef, at // namespace scope directly above the function; @@ -250,6 +254,50 @@ // warning sequences. // // --------------------------------------------------------------------------- +// Timing and budget enforcement (M1-SYS-03; PRD §9.3 G-R5) +// --------------------------------------------------------------------------- +// +// World::runSystems measures each system's own run time per tick +// (TimeIt — the M0-CORE-08 steady_clock scope timer, ms as a double) +// and hands the measurement to the system's rolling window and the +// budget check: +// +// - Rolling window — one fixed-capacity Histogram per system +// (kSystemTimingWindowSamples = 64 samples, ~1.1 s at the default +// 60 Hz). record() is O(1) and allocates nothing (PERF-003); +// recording beyond the capacity drops the OLDEST sample, and +// totalRecorded() keeps counting every sample ever recorded +// (silent truncation is not allowed — the M0-CORE-08 contract). +// The window rolls across TICKS (it is not a per-frame window): +// beginFrame() does not touch it. +// - Budget enforcement — measured vs the declared SystemDef +// budget (fpx16_16 ms, converted to double exactly — raw/2^16 is +// a power-of-two scale): +// measured > 1 × budget -> one system/budget_overrun WARN +// (the rolling window p99 is carried +// as a field) +// measured >= 3 × budget -> one system/budget_critical ERROR +// (kBudgetCriticalMultiplier; PRD +// §9.3 G-R5: "over 3× → error event") +// Both events follow the NFR-13.3 5-field message grammar +// (build-stable text; the dynamic values are structured fields) +// and are rate-limited per (subsystem, event, severity) (LOG-004); +// the warn and the error count separately in SystemTimingStats. +// An over-budget system is STILL RUN — the timing is observation +// and reporting, never an execution gate (the engine does not skip +// or cancel a system; FR-12.3: the breach is surfaced, not hidden). +// - Profiler feed — World::systemTimingStats(id) (the cheap scalars, +// O(1), per frame) and World::systemTimingWindow(id) (the rolling +// window itself, cold path: the M1-PROF-02 frame graph's +// budgetCheck consumes it). +// +// Determinism (ARCH-009/ARCH-010): the measured times are DIAGNOSTIC +// only — they never enter authoritative simulation state, state +// hashes, or replays (wall-clock readings are platform-sensitive). +// The only state the timing adds to a world is the window contents +// and the counters: diagnostics, not sim state. +// +// --------------------------------------------------------------------------- // Threading and failure // --------------------------------------------------------------------------- // @@ -257,10 +305,11 @@ // sim-thread operations on the world's single owner thread (CONC-001; // API-004: mutations in explicit phases). scheduleSystems is a pure // read of the registry (const); runSystems is the per-tick mutation -// phase; the run functions are sim-thread code (PRD §10.2: -// simulation is single-threaded). All failures are Result/Status -// values with one rate-limited structured warn each (LOG-004); no -// exceptions (FR-12.1, NFR-8.10). +// phase (it also owns the per-system timing state — written strictly +// on the owner thread); the run functions are sim-thread code (PRD +// §10.2: simulation is single-threaded). All failures are Result/ +// Status values with one rate-limited structured warn each (LOG-004); +// no exceptions (FR-12.1, NFR-8.10). // // --------------------------------------------------------------------------- // Misuse warnings @@ -308,7 +357,9 @@ #pragma once #include +#include +#include "laige/budget_harness.h" // M1-SYS-03: the rolling window (Histogram) #include "laige/fpx16_16.h" #include "laige/result.h" #include "laige/sim/component.h" @@ -350,6 +401,19 @@ inline constexpr std::uint32_t kMaxSystems = 256; // is an ADR. inline constexpr std::uint32_t kMaxSystemDependencies = 16; +// The per-system rolling window capacity (M1-SYS-03; CORE-005): the +// number of measured run times (ms) kept per system in the rolling +// histogram — ~1.1 s of samples at the default 60 Hz tick rate. The +// window is fixed at world construction (a setup-path allocation); +// raising the capacity is an ADR, not a knob. +inline constexpr std::uint32_t kSystemTimingWindowSamples = 64; + +// The over-budget multiplier that escalates the budget_overrun warn +// into a budget_critical error event (M1-SYS-03; PRD §9.3 G-R5: +// "over 3× → error event"; CORE-005). The warn fires strictly above +// 1× the declared budget; the error at 3× or more. +inline constexpr std::uint32_t kBudgetCriticalMultiplier = 3; + // The system function signature (FR-1.3): a plain free function — no // class, no inheritance. `world` is the world the system runs on; // `ctx` is that tick's SystemContext (one world, one owner thread, @@ -461,6 +525,27 @@ struct SystemInfo { detail::IdSet256 writeComponents_{}; }; +// One system's measured-run scalars (M1-SYS-03; PRD §9.3 G-R5): the +// cheap per-frame snapshot the profiler (M1-PROF-01) and the frame +// graph (M1-PROF-02) pull through World::systemTimingStats(id) — +// O(1), no allocation, no side effects. The window SAMPLES are not +// here (the rolling histogram is read cold through +// World::systemTimingWindow(id) — its stats() is O(n log n)). All +// counters are since-construction; the window rolls across ticks +// (kSystemTimingWindowSamples, not per-frame). +struct SystemTimingStats { + // Measured runs of the system since world construction. + std::uint64_t runs{}; + // The measured time (ms) of the most recent run (0 before the first + // run). + double lastMs{}; + // The system/budget_overrun warns issued since construction. + std::uint32_t warns{}; + // The system/budget_critical error events issued since + // construction. + std::uint32_t errors{}; +}; + // Declare a system (FR-1.3): at namespace scope, directly above the // plain system function's definition. `Name` is both the C++ function // name and the system's registration name (stringified); `budget_ms` @@ -498,6 +583,26 @@ struct SystemRecord { IdSet256 writeComponents; // declared Write component ids (1..256) }; +// One per-system timing record (M1-SYS-03): the rolling window of +// measured run times (ms — the M0-CORE-08 Histogram, fixed capacity +// kSystemTimingWindowSamples) plus the cheap scalars exposed by +// SystemTimingStats. World stores a dense array of these indexed by +// (system id - 1) — parallel to the SystemRecord table (the M1-SYS-01 +// precedent: fixed engine budget, setup-path allocation, moves with +// the world, survives clear()). +struct SystemTimingRecord { + // The rolling window of measured run times (ms): the M0-CORE-08 + // Histogram, fixed capacity kSystemTimingWindowSamples. Built in + // World::create (setup path; the Histogram has no default + // constructor, so the record holds it as a unique_ptr — the one + // level of indirection is bounded by kMaxSystems). + std::unique_ptr window; + double lastMs{}; // most recent measured run (0 before first) + std::uint64_t runs{}; // measured runs since construction + std::uint32_t warns{}; // budget_overrun warns issued + std::uint32_t errors{}; // budget_critical errors issued +}; + // The outcome of one Io entry's resolution in World::registerSystem // (the fold short-circuits on the first failure). enum class IoResolution : std::uint8_t { diff --git a/src/laige-sim/system_timing.cpp b/src/laige-sim/system_timing.cpp new file mode 100644 index 0000000..b9d7692 --- /dev/null +++ b/src/laige-sim/system_timing.cpp @@ -0,0 +1,153 @@ +// laige-sim per-system timing + budget enforcement (M1-SYS-03; PRD +// §9.3 G-R5). +// +// Implementation of the timing methods declared in +// include/laige/sim/entity.h — see that header (the member docs), +// the system.h "Timing and budget enforcement" section, and +// docs/api/system_timing.md for the full contract: +// +// - World::runSystems (systems.cpp) times each system's own run +// (TimeIt — the M0-CORE-08 steady_clock scope timer, ms as a +// double) and hands the measurement to World::checkSystemBudget. +// - checkSystemBudget records the sample in the system's rolling +// window (the M0-CORE-08 Histogram, fixed capacity +// kSystemTimingWindowSamples — O(1), no allocation) and +// enforces the declared budget: +// measured > 1 × budget -> system/budget_overrun (Warn) +// measured >= 3 × budget -> system/budget_critical (Error) +// (kBudgetCriticalMultiplier; PRD §9.3 G-R5 "over 3× → error +// event"). Both events follow the NFR-13.3 5-field message +// grammar (build-stable text; the dynamic values are structured +// fields) and are rate-limited per (subsystem, event, severity) +// (LOG-004); the warn carries the window's rolling p99, and both +// events count in SystemTimingStats. An over-budget system is +// still run (observation, never an execution gate). +// - systemTimingStats / systemTimingWindow are the profiler feed +// (M1-PROF-01/02): cheap per-frame scalars plus the rolling +// window for the frame graph's budgetCheck. +// +// Hot-path cost (per system per tick): two steady_clock reads, one +// O(1) ring write, two comparisons — no allocation and no logging on +// the success path (PERF-003, LOG-003). The measured times are +// diagnostics (ARCH-009): they never enter authoritative simulation +// state, hashes, or replays. + +#include "laige/sim/entity.h" + +#include +#include + +#include "laige/logging.h" + +namespace laige { + +namespace { + +// The stable subsystem name for system events (LOG-001; systems.cpp). +inline constexpr const char* kSystemSubsystem = "system"; + +// NFR-13.3 5-field grammar, identical in every build ({code} | +// {what} | {why} | {fix} | {doc_anchor}). The dynamic values (measured +// time, budget, rolling p99, window fill) are structured fields, never +// message text: the machine-parseable message stays build-stable. +inline constexpr const char* kBudgetOverrunMessage = + "budget_overrun | a system exceeded its declared per-tick time " + "budget this tick | sustained overruns consume tick time and mask " + "performance regressions | profile the system's per-tick work " + "(query scope, batching, bounded iteration) and reduce it, or " + "raise the declared budget at registration | " + "docs/api/system_timing.md"; + +inline constexpr const char* kBudgetCriticalMessage = + "budget_critical | a system ran 3x or more over its declared " + "per-tick time budget this tick | the system is consuming a large " + "fraction of the tick and endangers the tick-time targets | cut " + "the system's per-tick work (query scope, batching, spatial " + "partitioning) and re-measure, or document the higher budget " + "through typed configuration | docs/api/system_timing.md"; + +} // namespace + +Result +World::systemTimingStats(SystemId id) const noexcept { + // Ids are dense from 1, so a valid registered id is exactly the + // range [1, systemCount_] (system.h contract; the World::system + // precedent: an invalid id is a pure-query failure, no warn). + if (id.value == 0 || id.value > systemCount_ || + systemTiming_ == nullptr) { + return ErrorCode::InvalidArgument; + } + const detail::SystemTimingRecord& rec = systemTiming_[id.value - 1]; + return SystemTimingStats{rec.runs, rec.lastMs, rec.warns, rec.errors}; +} + +const Histogram* World::systemTimingWindow(SystemId id) const noexcept { + if (id.value == 0 || id.value > systemCount_ || + systemTiming_ == nullptr) { + return nullptr; + } + return systemTiming_[id.value - 1].window.get(); +} + +void World::checkSystemBudget(std::uint32_t id, double measuredMs) noexcept { + // The timing table exists whenever a system is registered + // (allocated in create() alongside the registry, moved with it), + // and runSystems reaches here only with a non-empty registry — + // the invariant is structural (assert, CPP-012). + assert(systemTiming_ != nullptr); + detail::SystemTimingRecord& rec = systemTiming_[id - 1]; + const detail::SystemRecord& recDef = systems_[id - 1]; + + // The rolling window sample: O(1), no allocation (PERF-003). The + // oldest sample drops when the window is full (the M0-CORE-08 + // contract — totalRecorded() keeps counting). + rec.window->record(measuredMs); + ++rec.runs; + rec.lastMs = measuredMs; + + // The declared budget in ms, converted EXACTLY: fpx16_16 raw / 2^16 + // is a power-of-two scale, and the raw range (±2^31) fits the + // double mantissa, so the comparison operands are exact. + const double budgetMs = + static_cast(recDef.def.budgetMs.raw) / 65536.0; + const double criticalMs = + static_cast(kBudgetCriticalMultiplier) * budgetMs; + + if (measuredMs > budgetMs || measuredMs >= criticalMs) { + // Cold path — only while the system is over budget. One window + // stats pass (O(W log W) over the fixed W = + // kSystemTimingWindowSamples, no allocation: the Histogram's + // pre-allocated scratch buffer) feeds both events' rolling-p99 + // field. The field values construct only because the event's + // level gate passed (LOG-003); rate-limited repeats still pay + // this bounded cold cost while the breach persists. + const HistogramStats stats = rec.window->stats(); + if (measuredMs > budgetMs) { + // Strictly above the declared budget (measured == budget is + // legal — the G-R4 strictly-greater precedent). + ++rec.warns; + LAIGE_LOG_WARN(kSystemSubsystem, "budget_overrun", + kBudgetOverrunMessage, + laige::log::field("system", recDef.def.name), + laige::log::field("id", id), + laige::log::field("measured_ms", measuredMs), + laige::log::field("budget_ms", budgetMs), + laige::log::field("p99_ms", stats.p99), + laige::log::field("window_samples", stats.n)); + } + if (measuredMs >= criticalMs) { + // 3× or more over the declared budget (PRD §9.3 G-R5). + ++rec.errors; + LAIGE_LOG_ERROR(kSystemSubsystem, "budget_critical", + kBudgetCriticalMessage, + laige::log::field("system", recDef.def.name), + laige::log::field("id", id), + laige::log::field("measured_ms", measuredMs), + laige::log::field("budget_ms", budgetMs), + laige::log::field("p99_ms", stats.p99), + laige::log::field("window_samples", stats.n)); + } + } +} + +} // namespace laige diff --git a/src/laige-sim/systems.cpp b/src/laige-sim/systems.cpp index ba02646..05d0257 100644 --- a/src/laige-sim/systems.cpp +++ b/src/laige-sim/systems.cpp @@ -18,6 +18,7 @@ #include #include +#include "laige/budget_harness.h" // M1-SYS-03: TimeIt (the scope timer) #include "laige/logging.h" namespace laige { @@ -380,7 +381,9 @@ Status World::scheduleSystems(SystemSchedule& out) const noexcept { // runSystems: one sim tick's system phase (M1-LOOP-01 calls it once // per tick). Strictly one system at a time, in schedule order, on the // world's single owner thread; a fresh non-owning SystemContext per -// system. O(n) dispatch; no allocation, no logging on success. +// system. O(n) dispatch plus the M1-SYS-03 per-system measurement (two +// steady_clock reads, one O(1) ring write, two comparisons per +// system); no allocation, no logging on success. Status World::runSystems(const SystemSchedule& schedule) noexcept { // The schedule must describe the CURRENT registry: a systemCount // mismatch means systems were registered after the schedule was @@ -414,9 +417,15 @@ Status World::runSystems(const SystemSchedule& schedule) noexcept { seen.set(id); } for (std::uint32_t k = 0; k < count; ++k) { - const detail::SystemRecord& rec = systems_[schedule.order[k] - 1]; + const std::uint32_t id = schedule.order[k]; + const detail::SystemRecord& rec = systems_[id - 1]; SystemContext ctx{*this}; // per-tick, per-system, non-owning + // M1-SYS-03: the system's own run time (the context is built + // outside the window — the measurement is the run function + // itself, not the dispatch bookkeeping). + TimeIt timer; rec.def.run(*this, ctx); + checkSystemBudget(id, timer.elapsedMs()); } return Status{}; } diff --git a/tests/laige-sim/CMakeLists.txt b/tests/laige-sim/CMakeLists.txt index dfa5425..730d8b9 100644 --- a/tests/laige-sim/CMakeLists.txt +++ b/tests/laige-sim/CMakeLists.txt @@ -1,22 +1,25 @@ -# laige-sim tests (M1-ECS-01/02/03/04/05/06/07 + M1-SYS-01/02): entity -# handle + World entity storage, component type registry, archetype -# SoA storage, query API + iteration legality, deterministic iteration -# order, the ECS guardrails (G-R3/G-R4), the ECS stress + memory -# accounting suite, the system registry (plain registered functions, -# declared budgets + component I/O), and the system scheduler -# (execution order, depends_on, the declared-I/O pre-run validation). +# laige-sim tests (M1-ECS-01/02/03/04/05/06/07 + M1-SYS-01/02/03): +# entity handle + World entity storage, component type registry, +# archetype SoA storage, query API + iteration legality, +# deterministic iteration order, the ECS guardrails (G-R3/G-R4), the +# ECS stress + memory accounting suite, the system registry (plain +# registered functions, declared budgets + component I/O), the system +# scheduler (execution order, depends_on, the declared-I/O pre-run +# validation), and per-system timing + budget enforcement (the +# rolling window, the G-R5 warn/error events, the profiler feed). # # 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`, `iter_order`, -# `ecs_guardrails`, `ecs_stress`, `system_registry`, and `scheduler` -# entries are the M1-ECS-01, M1-ECS-02, M1-ECS-03, M1-ECS-04, -# M1-ECS-05, M1-ECS-06, M1-ECS-07, M1-SYS-01, and M1-SYS-02 Verify -# commands (`ctest -R entity`, `ctest -R component_registry`, -# `ctest -R archetype`, `ctest -R query`, `ctest -R iter_order`, -# `ctest -R ecs_guardrails`, `ctest -R ecs_stress`, -# `ctest -R system_registry`, `ctest -R scheduler`), selecting exactly +# `ecs_guardrails`, `ecs_stress`, `system_registry`, `scheduler`, and +# `system_timing` entries are the M1-ECS-01, M1-ECS-02, M1-ECS-03, +# M1-ECS-04, M1-ECS-05, M1-ECS-06, M1-ECS-07, M1-SYS-01, M1-SYS-02, +# and M1-SYS-03 Verify commands (`ctest -R entity`, +# `ctest -R component_registry`, `ctest -R archetype`, `ctest -R +# query`, `ctest -R iter_order`, `ctest -R ecs_guardrails`, +# `ctest -R ecs_stress`, `ctest -R system_registry`, +# `ctest -R scheduler`, `ctest -R system_timing`), selecting exactly # the suites below from the shared executable. set(LAIGE_SIM_TEST_SOURCES entity_tests.cpp component_registry_tests.cpp @@ -24,7 +27,8 @@ set(LAIGE_SIM_TEST_SOURCES entity_tests.cpp component_registry_tests.cpp iter_order_tests.cpp ecs_guardrails_tests.cpp ecs_stress_tests.cpp system_registry_tests.cpp - scheduler_tests.cpp) + scheduler_tests.cpp + system_timing_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, @@ -136,10 +140,20 @@ add_test(NAME scheduler COMMAND laige-sim_tests --gtest_filter=SystemScheduler.*) +# M1-SYS-03: per-system timing + budget enforcement (PRD §9.3 G-R5). +# The step's Verify command is `ctest -R system_timing`; this entry +# selects exactly the SystemTiming suites from the shared +# laige-sim_tests executable (the machine-greppable +# system-timing-window / system-timing-zeroalloc lines land in the +# ctest output). +add_test(NAME system_timing + COMMAND laige-sim_tests + --gtest_filter=SystemTiming.*) + 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 ecs_guardrails ecs_stress system_registry scheduler - PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") + system_timing PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") endif() diff --git a/tests/laige-sim/system_timing_tests.cpp b/tests/laige-sim/system_timing_tests.cpp new file mode 100644 index 0000000..17e6b5e --- /dev/null +++ b/tests/laige-sim/system_timing_tests.cpp @@ -0,0 +1,659 @@ +// laige-sim per-system timing + budget enforcement suite (M1-SYS-03). +// +// Step Verify scope (roadmap/M1-heartbeat.md): +// - a synthetic slow system fires the budget_overrun WARN at the +// documented 1× multiplier, and the budget_critical ERROR at the +// documented 3× multiplier (the warn first, the error after, in +// the same tick) +// - the rolling histogram window resets correctly: samples beyond +// kSystemTimingWindowSamples drop the OLDEST sample (count caps +// at the capacity, totalRecorded keeps counting, and stats() +// reflects exactly the stored window — old burn samples are +// gone after the fast ticks roll in) +// - healthy systems log nothing (the success path is silent, +// LOG-003) and the cheap SystemTimingStats track the runs +// - the events follow the NFR-13.3 5-field message grammar +// ({code} | {what} | {why} | {fix} | {doc_anchor}) +// - the warn's rolling p99 field carries the window's p99 +// - the queries validate ids (invalid id / no-systems world / +// moved-from world) +// - the timing state travels with the world on move +// - no heap allocation on the healthy per-tick timing path +// (test-only operator-new counter, non-sanitizer trees; the +// sanitizer trees prove it leak-free) +// +// Runs as CTest `system_timing` (the step's Verify command: +// `ctest -R system_timing`): a filtered view of the shared +// laige-sim_tests executable, selecting exactly the suites 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" +#include "laige/sim/system.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, + "system_timing_tests must be built with exceptions " + "disabled (NFR-8.10); see laige_apply_engine_policy()."); +#elif defined(__EXCEPTIONS) && __EXCEPTIONS +static_assert(false, + "system_timing_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, + "system_timing_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 SYSTEM_TIMING_TESTS_ACTIVE_CPLUSPLUS _MSVC_LANG +#else +# define SYSTEM_TIMING_TESTS_ACTIVE_CPLUSPLUS __cplusplus +#endif + +static_assert(SYSTEM_TIMING_TESTS_ACTIVE_CPLUSPLUS >= 202002L, + "system_timing_tests must be built with C++20 " + "(NFR-8.10); see laige_apply_engine_policy()."); + +// LAIGE_COMPONENT specializes laige::detail::ComponentTraits, which +// must be specialized at global scope (the component_registry_tests +// pattern). +struct STTag { + std::int32_t v{}; +}; +LAIGE_COMPONENT(STTag) + +namespace { + +using laige::Access; +using laige::ErrorCode; +using laige::Entity; +using laige::Io; +using laige::SystemDef; +using laige::SystemId; +using laige::SystemSchedule; +using laige::SystemTimingStats; +using laige::World; + +// --------------------------------------------------------------------------- +// The synthetic slow system (the step's "synthetic slow system") +// --------------------------------------------------------------------------- + +// The burn target (ms) of STBurn — test plumbing: the tests set it +// before running a tick (the sim is single-threaded, PRD §10.2). 0 +// means a healthy no-burn run. +double stBurnMs = 0.0; + +// The burn loop's accumulator (namespace scope: written, never read — +// the loop's work must not be eliminated, and a namespace-scope +// variable carries no unused-variable diagnostic). +volatile std::uint64_t gBurnSink = 0; + +// Burn roughly `ms` milliseconds of wall time: a clock-driven loop, +// machine-independent in intent (the measured duration is ~ms plus a +// small clock-check granularity). The volatile sink defeats +// elimination. +void burnMs(double ms) { + if (ms <= 0.0) return; + const auto start = std::chrono::steady_clock::now(); + while (std::chrono::duration_cast>( + std::chrono::steady_clock::now() - start).count() < ms) { + gBurnSink += 1; + } +} + +LAIGE_SYSTEM(STBurn, 1) +void STBurn(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + static_cast(ctx); + burnMs(stBurnMs); +} + +LAIGE_SYSTEM(STNoop, 1) +void STNoop(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + static_cast(ctx); +} + +#if defined(LAIGE_ALLOC_COUNTER) +// Two plain functions for the two-system zero-alloc window (a +// manual def needs a distinct function per registration name). +// Only the non-sanitizer trees (the alloc-counter trees) define the +// test that uses them. +void fnNoopA(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + static_cast(ctx); +} + +void fnNoopB(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + static_cast(ctx); +} +#endif + +// --------------------------------------------------------------------------- +// World + def builders (the scheduler/system_registry pattern) +// --------------------------------------------------------------------------- + +World makeWorld() { + auto w = World::create(World::Options{16}); + if (!w.ok()) { + ADD_FAILURE() << "World::create(16) failed: " + << laige::errorName(w.error()); + abort(); + } + World world = std::move(w).takeValue(); + // STTag is registered in every test world: the test systems declare + // it in their I/O (the M1-SYS-01 io_unregistered check). The success + // path logs nothing, so this setup does not reach the test sinks. + if (!world.registerComponent().ok()) { + ADD_FAILURE() << "registerComponent failed"; + abort(); + } + return world; +} + +SystemDef makeDef(const char* name, laige::SystemFn fn, + laige::fpx16_16 budgetMs) { + return SystemDef{name, fn, budgetMs, nullptr}; +} + +// --------------------------------------------------------------------------- +// Log capture (the logging_tests / scheduler_tests pattern) +// --------------------------------------------------------------------------- + +// A test-only Sink that records every emitted event (the logging +// facade is a process singleton; the tests that use it restore the +// default console sink at the end — the scheduler_tests pattern). +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 { + 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; +}; + +MemorySink* sink = nullptr; + +// Install the capture sink (a 60 s rate window: the tests' repeated +// events stay within one window, so the rate-limited repeats are +// suppressed and summarized at shutdown — the scheduler_tests +// pattern). +MemorySink* installCaptureSink() { + auto mem = std::make_unique(); + MemorySink* memPtr = mem.get(); + laige::log::LoggerOptions opts; + opts.sink = std::move(mem); + opts.rateWindow = std::chrono::seconds(60); + if (!laige::log::Logger::instance().init(std::move(opts)).ok()) { + ADD_FAILURE() << "Logger::init failed"; + std::abort(); + } + sink = memPtr; + return memPtr; +} + +void restoreConsoleSink() { + laige::log::LoggerOptions defaults; + if (!laige::log::Logger::instance().init(std::move(defaults)).ok()) { + ADD_FAILURE() << "Logger re-init with the default console sink failed"; + std::abort(); + } + sink = nullptr; +} + +std::size_t countEvents(const MemorySink& s, const char* event) { + std::size_t n = 0; + for (const auto& e : s.entries) { + if (e.event == event) ++n; + } + return n; +} + +const char* fieldValue(const MemorySink::Entry& entry, const char* key) { + for (const auto& [k, v] : entry.fields) { + if (k == key) return v.c_str(); + } + return ""; +} + +// Parse a field value as a double (the field() double rendering is the +// shortest round-trip decimal — std::from_chars is exact). +double fieldMs(const MemorySink::Entry& e, const char* key) { + const char* v = fieldValue(e, key); + EXPECT_TRUE(v[0] != '\0') << "field " << key << " missing"; + char* end = nullptr; + const double d = std::strtod(v, &end); + EXPECT_TRUE(end != nullptr && *end == '\0') + << "field " << key << " unparsable: " << v; + return d; +} + +// Run one tick (beginFrame + runSystems), asserting the run status. +void runTick(World& w, const SystemSchedule& sched) { + w.beginFrame(); + ASSERT_TRUE(w.runSystems(sched).ok()); +} + +} // namespace + +// --------------------------------------------------------------------------- +// Healthy path: nothing is logged, the scalars track the runs +// --------------------------------------------------------------------------- + +TEST(SystemTiming, HealthyTicksLogNothingAndTrackStats) { + MemorySink* mem = installCaptureSink(); + World w = makeWorld(); + ASSERT_TRUE(w + .registerSystem(makeDef("STNoop", &STNoop, + laige::fpx16_16::fromInt32(1)), + Io{}) + .ok()); + SystemSchedule sched; + ASSERT_TRUE(w.scheduleSystems(sched).ok()); + for (int tick = 0; tick < 8; ++tick) { + runTick(w, sched); + } + + // The success path is silent (LOG-003): no event at all. + EXPECT_EQ(mem->entries.size(), 0u); + + auto st = w.systemTimingStats(SystemId{1}); + ASSERT_TRUE(st.ok()); + EXPECT_EQ(st.value().runs, 8u); + EXPECT_EQ(st.value().warns, 0u); + EXPECT_EQ(st.value().errors, 0u); + // A noop system on an empty world runs in microseconds — well + // under its 1 ms budget, with margin for a slow machine. + EXPECT_GT(st.value().lastMs, 0.0); + EXPECT_LT(st.value().lastMs, 1.0); + + const laige::Histogram* win = w.systemTimingWindow(SystemId{1}); + ASSERT_TRUE(win != nullptr); + // 8 ticks < the window capacity: every sample is stored. + EXPECT_EQ(win->count(), 8u); + EXPECT_EQ(win->totalRecorded(), 8u); + + restoreConsoleSink(); +} + +// --------------------------------------------------------------------------- +// The documented multipliers: warn at 1×, error at 3× +// --------------------------------------------------------------------------- + +TEST(SystemTiming, OverBudgetSystemWarnsAtTheDocumentedMultiplier) { + MemorySink* mem = installCaptureSink(); + World w = makeWorld(); + // Budget 5 ms, burn ~6.5 ms: over 1× (warn) but under 3× (15 ms — + // the margin holds even under preemption). + ASSERT_TRUE(w + .registerSystem(makeDef("STBurn", &STBurn, + laige::fpx16_16::fromInt32(5)), + Io{}) + .ok()); + SystemSchedule sched; + ASSERT_TRUE(w.scheduleSystems(sched).ok()); + stBurnMs = 6.5; + runTick(w, sched); + + // Exactly one event: the warn (the error threshold was not reached). + ASSERT_EQ(mem->entries.size(), 1u); + const auto& e = mem->entries[0]; + EXPECT_EQ(e.event, "budget_overrun"); + EXPECT_EQ(e.subsystem, "system"); + EXPECT_EQ(e.severity, laige::log::Severity::Warn); + EXPECT_STREQ(fieldValue(e, "system"), "STBurn"); + EXPECT_STREQ(fieldValue(e, "id"), "1"); + EXPECT_DOUBLE_EQ(fieldMs(e, "budget_ms"), 5.0); + const double measured = fieldMs(e, "measured_ms"); + EXPECT_GE(measured, 6.0); + EXPECT_LT(measured, 15.0); + // The rolling p99: the window holds exactly this one sample. + EXPECT_NEAR(fieldMs(e, "p99_ms"), measured, 1.0); + EXPECT_STREQ(fieldValue(e, "window_samples"), "1"); + + auto st = w.systemTimingStats(SystemId{1}); + ASSERT_TRUE(st.ok()); + EXPECT_EQ(st.value().runs, 1u); + EXPECT_EQ(st.value().warns, 1u); + EXPECT_EQ(st.value().errors, 0u); + + // The next over-budget tick: the counter keeps counting, but the + // event is rate-limited (LOG-004) — no new entry. + stBurnMs = 6.5; + runTick(w, sched); + EXPECT_EQ(mem->entries.size(), 1u); + auto st2 = w.systemTimingStats(SystemId{1}); + ASSERT_TRUE(st2.ok()); + EXPECT_EQ(st2.value().warns, 2u); + + // Shutdown: the suppressed repeat is summarized (rate_limited). + laige::log::Logger::instance().shutdown(); + ASSERT_EQ(mem->entries.size(), 2u); + EXPECT_EQ(mem->entries[1].event, "rate_limited"); + EXPECT_STREQ(fieldValue(mem->entries[1], "suppressed"), "1"); + sink = nullptr; +} + +TEST(SystemTiming, CriticallyOverBudgetSystemErrorsAfterTheWarn) { + MemorySink* mem = installCaptureSink(); + World w = makeWorld(); + // Budget 1 ms, burn ~7 ms: over 3× (3 ms) — the error event fires, + // and the warn (a lower threshold, the same breach) fires first in + // the same tick. + ASSERT_TRUE(w + .registerSystem(makeDef("STBurn", &STBurn, + laige::fpx16_16::fromInt32(1)), + Io{}) + .ok()); + SystemSchedule sched; + ASSERT_TRUE(w.scheduleSystems(sched).ok()); + stBurnMs = 7.0; + runTick(w, sched); + + // Two events, in the documented order: the warn, then the error. + ASSERT_EQ(mem->entries.size(), 2u); + EXPECT_EQ(mem->entries[0].event, "budget_overrun"); + EXPECT_EQ(mem->entries[0].severity, laige::log::Severity::Warn); + EXPECT_EQ(mem->entries[1].event, "budget_critical"); + EXPECT_EQ(mem->entries[1].severity, laige::log::Severity::Error); + EXPECT_EQ(mem->entries[1].subsystem, "system"); + for (const auto& e : mem->entries) { + EXPECT_STREQ(fieldValue(e, "system"), "STBurn"); + EXPECT_STREQ(fieldValue(e, "id"), "1"); + EXPECT_DOUBLE_EQ(fieldMs(e, "budget_ms"), 1.0); + EXPECT_GE(fieldMs(e, "measured_ms"), 7.0); + EXPECT_GE(fieldMs(e, "p99_ms"), 7.0); + EXPECT_STREQ(fieldValue(e, "window_samples"), "1"); + } + + auto st = w.systemTimingStats(SystemId{1}); + ASSERT_TRUE(st.ok()); + EXPECT_EQ(st.value().runs, 1u); + EXPECT_EQ(st.value().warns, 1u); + EXPECT_EQ(st.value().errors, 1u); + + restoreConsoleSink(); +} + +// --------------------------------------------------------------------------- +// The rolling window: oldest-sample drop and stats over the window +// --------------------------------------------------------------------------- + +TEST(SystemTiming, RollingWindowDropsOldestSamples) { + MemorySink* mem = installCaptureSink(); + World w = makeWorld(); + // Budget 50 ms: the 7 ms burn never crosses it — no events, pure + // window mechanics. + ASSERT_TRUE(w + .registerSystem(makeDef("STBurn", &STBurn, + laige::fpx16_16::fromInt32(50)), + Io{}) + .ok()); + SystemSchedule sched; + ASSERT_TRUE(w.scheduleSystems(sched).ok()); + const std::uint32_t cap = laige::kSystemTimingWindowSamples; + const auto* win = w.systemTimingWindow(SystemId{1}); + ASSERT_TRUE(win != nullptr); + + // Phase 1: 3*cap healthy ticks — the window is full of fast samples. + stBurnMs = 0.0; + for (std::uint32_t i = 0; i < 3 * cap; ++i) { + runTick(w, sched); + } + EXPECT_EQ(win->count(), cap); + EXPECT_EQ(win->totalRecorded(), 3 * cap); + EXPECT_LT(win->stats().max, 5.0); // fast ticks only + + // Phase 2: cap burn ticks — the window is now full of burn samples. + stBurnMs = 7.0; + for (std::uint32_t i = 0; i < cap; ++i) { + runTick(w, sched); + } + EXPECT_EQ(win->count(), cap); + EXPECT_EQ(win->totalRecorded(), 4 * cap); + EXPECT_GT(win->stats().min, 6.5); // the fast samples rolled out + + // Phase 3: cap healthy ticks — the burn samples rolled out (the + // window reset: exactly the stored window is visible). + stBurnMs = 0.0; + for (std::uint32_t i = 0; i < cap; ++i) { + runTick(w, sched); + } + EXPECT_EQ(win->count(), cap); + EXPECT_EQ(win->totalRecorded(), 5 * cap); + EXPECT_LT(win->stats().max, 5.0); // the old 7 ms samples are gone + EXPECT_LT(win->stats().p99, 5.0); + + auto st = w.systemTimingStats(SystemId{1}); + ASSERT_TRUE(st.ok()); + EXPECT_EQ(st.value().runs, 5 * cap); + EXPECT_EQ(st.value().warns, 0u); + EXPECT_EQ(st.value().errors, 0u); + // The success path stayed silent through every phase. + EXPECT_EQ(mem->entries.size(), 0u); + + std::printf("system-timing window ticks=%u capacity=%u total=%llu\n", + 5u * cap, cap, + static_cast(win->totalRecorded())); + restoreConsoleSink(); +} + +// --------------------------------------------------------------------------- +// The NFR-13.3 message grammar +// --------------------------------------------------------------------------- + +TEST(SystemTiming, EventsFollowTheErrorGrammar) { + // NFR-13.3: every engine error follows + // {code} | {what} | {why} | {fix} | {doc_anchor} — the timing events + // carry the same 5-field line in their message text (identical in + // every build; the dynamic values are structured fields, never + // message text). + MemorySink* mem = installCaptureSink(); + World w = makeWorld(); + // Two systems, one burn amount (6.5 ms): the first sits over its + // 5 ms budget (warn only), the second over its 2 ms budget by + // 3× or more (6.5 >= 6 — the error event too, plus its own warn). + ASSERT_TRUE(w + .registerSystem(makeDef("STBurnWarn", &STBurn, + laige::fpx16_16::fromInt32(5)), + Io{}) + .ok()); + ASSERT_TRUE(w + .registerSystem(makeDef("STBurnCrit", &STBurn, + laige::fpx16_16::fromInt32(2)), + Io{}) + .ok()); + SystemSchedule sched; + ASSERT_TRUE(w.scheduleSystems(sched).ok()); + stBurnMs = 6.5; + runTick(w, sched); + + // The tick emits two events: system 1's warn, then system 2's + // error. System 2's own warn is the SECOND budget_overrun in this + // 60 s rate window — rate-limited per (subsystem, event, severity) + // (LOG-004) and summarized at shutdown. + EXPECT_EQ(countEvents(*mem, "budget_overrun"), 1u); + EXPECT_EQ(countEvents(*mem, "budget_critical"), 1u); + ASSERT_EQ(mem->entries.size(), 2u); + EXPECT_EQ(mem->entries[0].event, "budget_overrun"); + EXPECT_STREQ(fieldValue(mem->entries[0], "system"), "STBurnWarn"); + EXPECT_EQ(mem->entries[1].event, "budget_critical"); + EXPECT_STREQ(fieldValue(mem->entries[1], "system"), "STBurnCrit"); + + for (const auto& e : mem->entries) { + // Split the message on " | ": exactly 5 fields, none empty. + std::vector fields; + std::size_t start = 0; + for (;;) { + const std::size_t pos = e.message.find(" | ", start); + if (pos == std::string::npos) { + fields.push_back(e.message.substr(start)); + break; + } + fields.push_back(e.message.substr(start, pos - start)); + start = pos + 3; + } + ASSERT_EQ(fields.size(), 5u) << "message: " << e.message; + for (const auto& f : fields) { + EXPECT_FALSE(f.empty()) << "message: " << e.message; + } + // {code} names the event; {doc_anchor} points at the timing docs. + EXPECT_EQ(fields[0], e.event); + EXPECT_EQ(fields[4], "docs/api/system_timing.md"); + } + + // The suppressed second warn is summarized at shutdown. + laige::log::Logger::instance().shutdown(); + ASSERT_EQ(mem->entries.size(), 3u); + EXPECT_EQ(mem->entries[2].event, "rate_limited"); + EXPECT_STREQ(fieldValue(mem->entries[2], "suppressed"), "1"); + EXPECT_STREQ(fieldValue(mem->entries[2], "event"), "budget_overrun"); + sink = nullptr; +} + +// --------------------------------------------------------------------------- +// The profiler feed: query validation + the window reference +// --------------------------------------------------------------------------- + +TEST(SystemTiming, QueriesValidateIdsAndNoSystemsWorld) { + World w = makeWorld(); + ASSERT_TRUE(w + .registerSystem(makeDef("STNoop", &STNoop, + laige::fpx16_16::fromInt32(1)), + Io{}) + .ok()); + + // Pure queries: invalid ids fail without logging. + auto r0 = w.systemTimingStats(SystemId{0}); + ASSERT_FALSE(r0.ok()); + EXPECT_EQ(r0.error(), ErrorCode::InvalidArgument); + auto r2 = w.systemTimingStats(SystemId{2}); + ASSERT_FALSE(r2.ok()); + EXPECT_EQ(r2.error(), ErrorCode::InvalidArgument); + EXPECT_EQ(w.systemTimingWindow(SystemId{0}), nullptr); + EXPECT_EQ(w.systemTimingWindow(SystemId{2}), nullptr); + ASSERT_TRUE(w.systemTimingStats(SystemId{1}).ok()); + ASSERT_TRUE(w.systemTimingWindow(SystemId{1}) != nullptr); + + // A world with no registered systems: id 1 is above the (empty) + // registry — invalid in both queries. + World empty = makeWorld(); + auto re = empty.systemTimingStats(SystemId{1}); + ASSERT_FALSE(re.ok()); + EXPECT_EQ(re.error(), ErrorCode::InvalidArgument); + EXPECT_EQ(empty.systemTimingWindow(SystemId{1}), nullptr); +} + +TEST(SystemTiming, TimingStateTravelsWithMove) { + World w = makeWorld(); + ASSERT_TRUE(w + .registerSystem(makeDef("STNoop", &STNoop, + laige::fpx16_16::fromInt32(1)), + Io{}) + .ok()); + SystemSchedule sched; + ASSERT_TRUE(w.scheduleSystems(sched).ok()); + for (int tick = 0; tick < 5; ++tick) { + runTick(w, sched); + } + World w2 = std::move(w); + + // The runs, the counters, and the window travel with the world. + auto st = w2.systemTimingStats(SystemId{1}); + ASSERT_TRUE(st.ok()); + EXPECT_EQ(st.value().runs, 5u); + EXPECT_EQ(st.value().warns, 0u); + EXPECT_EQ(st.value().errors, 0u); + const laige::Histogram* win = w2.systemTimingWindow(SystemId{1}); + ASSERT_TRUE(win != nullptr); + EXPECT_EQ(win->totalRecorded(), 5u); + + // The moved-from world is a valid empty world: the timing queries + // fail as pure queries (no registry, no timing table). + auto rm = w.systemTimingStats(SystemId{1}); + ASSERT_FALSE(rm.ok()); + EXPECT_EQ(rm.error(), ErrorCode::InvalidArgument); + EXPECT_EQ(w.systemTimingWindow(SystemId{1}), nullptr); + EXPECT_EQ(w.systemCount(), 0u); +} + +// --------------------------------------------------------------------------- +// The zero-allocation healthy path (M1 zero-alloc property, PERF-003) +// --------------------------------------------------------------------------- + +#if defined(LAIGE_ALLOC_COUNTER) +TEST(SystemTiming, HealthyTicksAllocateNothing) { + // The M1-ECS-03/07 pattern: the test-only operator-new counter + // (non-sanitizer trees; the sanitizer trees prove it leak-free). + MemorySink* mem = installCaptureSink(); + World w = makeWorld(); + ASSERT_TRUE(w + .registerSystem(makeDef("NoopA", &fnNoopA, + laige::fpx16_16::fromInt32(1)), + Io{}) + .ok()); + ASSERT_TRUE(w + .registerSystem(makeDef("NoopB", &fnNoopB, + laige::fpx16_16::fromInt32(1)), + Io{}) + .ok()); + SystemSchedule sched; + ASSERT_TRUE(w.scheduleSystems(sched).ok()); + laige::test::resetAllocCounter(); + for (int tick = 0; tick < 100; ++tick) { + runTick(w, sched); + } + const std::uint64_t allocs = laige::test::allocCounter(); + // Per tick per system: two clock reads, one O(1) ring write, two + // comparisons — nothing touches the heap while the systems stay + // under budget. + std::printf("system-timing-zeroalloc ticks=100 allocs=%llu\n", + static_cast(allocs)); + EXPECT_EQ(allocs, 0u); + EXPECT_EQ(mem->entries.size(), 0u); + restoreConsoleSink(); +} +#endif