diff --git a/docs/README.md b/docs/README.md index 70299cc..f97f467 100644 --- a/docs/README.md +++ b/docs/README.md @@ -59,6 +59,12 @@ still to land. (fpx16_16 ms) and declared component I/O (`Io`), `World::registerSystem`/`system`/`systemCount`, and `SystemContext`'s delegated `each` (M1-SYS-01; `laige-sim`). +- [System scheduler](api/scheduler.md) — + `World::scheduleSystems`/`runSystems`: the execution order (the + registration order plus the declared `depends_on` edges), the + pre-run validation (unknown dependency, cycle, double writer, + read-before-write warn), and the per-tick system phase + (M1-SYS-02; `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, @@ -147,7 +153,9 @@ still to land. [component_registry.md](api/component_registry.md), [archetype.md](api/archetype.md), [query.md](api/query.md), - [iteration_order.md](api/iteration_order.md).) + [iteration_order.md](api/iteration_order.md), + [system_registry.md](api/system_registry.md), + [scheduler.md](api/scheduler.md).) ## Related diff --git a/docs/api/scheduler.md b/docs/api/scheduler.md new file mode 100644 index 0000000..cfff311 --- /dev/null +++ b/docs/api/scheduler.md @@ -0,0 +1,199 @@ +# System scheduler (`World::scheduleSystems`, `World::runSystems`) + +The M1 system framework's scheduler (M1-SYS-02; PRD §9.1 S-8, FR-1.3, +PRD §10.2, AGENTS API-004, PERF-003): turns the system registry (the +registration order, the declared `depends_on` edges, and the declared +component I/O) into the per-tick execution order, validates it +once, and runs the systems in that order. Public header: +`src/laige-sim/include/laige/sim/system.h` (`SystemSchedule`, the +`depends_on` spec, the full contract) plus the `World:: +scheduleSystems`/`runSystems` members in +`src/laige-sim/include/laige/sim/entity.h`; implementation: +`src/laige-sim/systems.cpp`. Unit suite: `ctest -R scheduler` +(`tests/laige-sim/scheduler_tests.cpp`). + +The game's setup path registers systems once, schedules once, and then +the loop runs the schedule every tick (M1-LOOP-01 owns the loop): + +```cpp +// World setup (before the loop): +for (the systems) { world.registerSystem(def, Io<...>...); } +SystemSchedule schedule; +Status s = world.scheduleSystems(schedule); // check it (no exceptions) + +for (tick) { + world.beginFrame(); + world.runSystems(schedule); +} +``` + +## Execution order + +- **Base order** — the registration order (ascending `SystemId`). + Without `depends_on`, the computed order is exactly the registration + order. +- **`depends_on` edges** — a system lists the registration names of + the systems it must run after. Forward edges are legal (the + dependency may be registered later: the spec is resolved against the + world at scheduling time, not at registration). +- **The stable topological sort** — the order is computed with Kahn's + algorithm with a min-id tie-break: repeatedly place the smallest + unrun `SystemId` whose dependencies are all placed. A system only + moves LATER, behind its dependencies — it never moves earlier than + registration order would place it. The order is a pure function of + the registration order and the declared edges (ARCH-010), so two + runs (two builds, two processes) produce bit-identical schedules. +- **A barrier is registration position, not a dependency list** — to + run one system after all others, register it last. The direct + `depends_on` list is a small hand-written declaration, bounded at + `kMaxSystemDependencies` (16). + +## The depends_on spec + +The spec is the def's `dependsOn` string (`SystemDef::dependsOn`, +`const char*`). The `LAIGE_SYSTEM(Name, budget_ms, Dep..., ...)` macro +builds it by stringizing the trailing names verbatim: + +```cpp +LAIGE_SYSTEM(Health, 1, Spawner) // spec: "Spawner" +LAIGE_SYSTEM(Damage, 1, Spawner, Movement) // spec: "Spawner, Movement" +LAIGE_SYSTEM(CleanUp, 1) // spec: "" (no dependencies) +``` + +Manual `SystemDef` construction takes the raw string (or nullptr): + +```cpp +laige::SystemDef def{"Damage", &Damage, laige::fpx16_16::fromInt32(1), + "Spawner, Movement"}; +``` + +Parse rules (checked at registration, resolved at scheduling): + +- comma-separated registration names; each token is trimmed of ASCII + whitespace (space, tab, CR, LF); +- no empty tokens (a trailing or doubled comma is a typo → an error); +- no duplicate names (the dependency list is a set, not a multiset — + the declared-I/O precedent, [system_registry.md](system_registry.md)); +- at most `kMaxSystemDependencies` (16) direct dependencies; +- `nullptr` or `""` means no dependencies. + +## Pre-run validation (`World::scheduleSystems`) + +`scheduleSystems` is a setup-phase operation (after all +registrations, before the loop); a pure read of the registry (the +method is `const`). Validation order — **first failure wins**, every +failure is one rate-limited structured warn (subsystem `system`, +LOG-004) plus a `Status` (FR-12.3: never silent): + +| # | Condition | Event | Result | +|---|-----------|-------|--------| +| 1 | A dependency name that is not registered in this world (first in ascending (system id, spec position) order) | `system/dep_missing` | `InvalidArgument` | +| 2 | A dependency cycle: Kahn's leaves systems with unsatisfied dependencies (one concrete cycle is reported — the deterministic walk from the smallest remaining id, following each system's first spec-listed dependency that is still remaining) | `system/dependency_cycle` | `InvalidArgument` | +| 3 | Two systems both declaring `Write` of the same component type in one tick (first conflict in ascending component-id, then ascending writer-id order; order-independent: the last write would silently win) | `system/double_writer` | `InvalidArgument` | +| 4 | (WARN ONLY — scheduling succeeds) a declared read that the computed order places BEFORE a declared write of the same component: the reader observes the previous tick's value, not this tick's write (each (reader, writer, component) triple warns once, in ascending component-id, reader-id, writer-id order) | `system/read_before_write` | ok + Warn | + +On success `out` is fully populated and **nothing is logged** +(LOG-003: the success path has no diagnostics). + +The event fields (LOG-001 stable keys, LOG-002 actionable): + +| Event | Fields | +|-------|--------| +| `system/dep_missing` | `system` (the system's name), `missing_dep` (the unregistered name, bounded to 64 chars), `position` (the token index in the spec) | +| `system/dependency_cycle` | `cycle` (the systems of one concrete cycle, comma-joined in walk order, bounded to 256 chars) | +| `system/double_writer` | `component_id`, `first_writer`, `second_writer` (registration names) | +| `system/read_before_write` | `reader`, `writer`, `component_id` | + +## Running (`World::runSystems`) + +One sim tick's system phase. The systems run **strictly one at a +time**, in schedule order, on the world's single owner thread (PRD +§10.2: simulation is single-threaded; API-004: the system phase is the +mutation phase). Each system gets a fresh `SystemContext` (a +non-owning view — never stored across ticks or systems). + +- **Stale schedule** — `schedule.systemCount !=` the world's current + `systemCount` (systems registered after the schedule was computed, + or a schedule from another world) → `InvalidArgument` + one + rate-limited warn `system/schedule_stale` (fields `scheduled_systems`, + `current_systems`). Recompute the schedule after any registration + change. +- **Malformed schedule** — an order entry that is 0, above + `systemCount`, or a duplicate id (a hand-built schedule) → + `InvalidArgument` + one rate-limited warn `system/schedule_invalid` + (fields `slot`, `id`). +- **Empty schedule** — `systemCount == 0` (the empty world, or a + moved-from world) → ok, runs nothing. +- **Iteration legality** — the M1-ECS-04 guard + ([query.md](query.md)) applies inside every system exactly as for a + direct `World::each`: a nested `each()` or an illegal mutation is + rejected per system. The declared-I/O validation above is the + cross-system complement: one writer per component (rejected at + scheduling time), and read-before-write surfaced as a warn. +- A system's run function is `void`: per-entity `Status` results from + its own `each()` calls are the system's to handle (check them, + CORE-008). `runSystems` itself reports only schedule-level failures. +- **Calling `runSystems` from inside a system is misuse**: nesting + system phases breaks the declared order contract (the + one-writer-per-component invariant still prevents state + corruption). + +## Determinism (ARCH-010) + +Scheduling is pure integer/string bookkeeping: id scans, string +comparisons over the registration names, and Kahn's with a min-id +rule. No floating point, no randomness, and no addresses enter the +order or the warning set. Two worlds that reach the same registry +(same registration order, specs, and declared I/O) produce +bit-identical schedules and identical warning sequences — the +`scheduler-order … fnv1a=0x…` known-answer line in the `scheduler` +CTest entry pins the property. + +## Performance + +- **`World::scheduleSystems`:** O(n·d·n + c·n²) in the system count + `n` (≤ `kMaxSystems` = 256), direct dependencies `d` (≤ + `kMaxSystemDependencies` = 16), and component count `c` (≤ + `kMaxComponentTypes` = 256) — bounded, setup path only, called once + per world setup. All state is fixed-size stack/world arrays; **no + allocation** (PERF-003), no logging on the success path (LOG-003). +- **`World::runSystems`:** O(n) dispatch (a staleness check, an O(n) + 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). +- **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 + constant through an ADR. + +## Threading and failure (CONC-001, API-004) + +`scheduleSystems` is a pure read (setup phase, owner thread); +`runSystems` is the per-tick mutation phase on the world's single +owner thread (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 + +- `depends_on` names registration names, not function addresses or + `SystemId`s (ids are per-world runtime values; names are the stable + identity). A dependency on a name that is never registered in this + world fails at scheduling time — a dependency on a system registered + in ANOTHER world is always such a failure (systems never cross + worlds). +- A schedule is computed for the registry it was computed with: + registering systems after `scheduleSystems()` and then `runSystems()` + with the old schedule is rejected (`system/schedule_stale`). + Recompute the schedule after any registration change. +- A system that reads a component written by a LATER system in the + computed order reads the previous tick's value: the scheduler warns + (`system/read_before_write`). If the read must see this tick's + write, declare `depends_on` (or register the writer earlier); a + deliberate cross-tick read is declared by not declaring the write + at all (undeclared I/O is invisible to the check — use it + consciously). +- A system that writes a component it did not declare is invisible to + the double-writer check ([system_registry.md](system_registry.md)): + declare the I/O the system really uses. diff --git a/docs/api/system_registry.md b/docs/api/system_registry.md index 307de73..208a3b8 100644 --- a/docs/api/system_registry.md +++ b/docs/api/system_registry.md @@ -5,13 +5,16 @@ API-006, PERF-003): plain, registered functions with declared time budgets and declared component I/O. Public header: `src/laige-sim/include/laige/sim/system.h` (`SystemId`, `SystemDef`, `SystemFn`, `SystemContext`, `Io`, `SystemInfo`, the -`LAIGE_SYSTEM` macro, the `detail::SystemRecord`/trait types, the -full contract) plus the `World::registerSystem`/`system`/`systemCount` -members in `src/laige-sim/include/laige/sim/entity.h`; implementation: +`SystemSchedule` and `kMaxSystemDependencies` of the M1-SYS-02 +scheduler, the `LAIGE_SYSTEM` macro, the `detail::SystemRecord`/trait +types, the full contract) plus the `World::registerSystem`/`system`/ +`systemCount` members in +`src/laige-sim/include/laige/sim/entity.h`; implementation: `src/laige-sim/systems.cpp` (the non-template World methods and the `SystemInfo` queries) + the header-defined `registerSystem` template (entity.h). Unit suite: `ctest -R system_registry` -(`tests/laige-sim/system_registry_tests.cpp`). +(`tests/laige-sim/system_registry_tests.cpp`). The scheduler built on +top of the registry is documented in [scheduler.md](scheduler.md). A game's setup path registers components and systems once, in one documented place: @@ -46,17 +49,18 @@ inheritance, no state object. The function plus its `SystemDef` to `World::each` (the PRD Appendix B sketch's `ctx.each<...>()`; identical semantics, visit order, and iteration-legality behavior — [query.md](query.md)). The context is built per system per tick by - the scheduler (M1-SYS-02); never store it across ticks. + the scheduler (M1-SYS-02, [scheduler.md](scheduler.md)); never + 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). -`LAIGE_SYSTEM(Name, budget_ms)` (namespace scope, directly above the -function) expands to the function declaration plus +`LAIGE_SYSTEM(Name, budget_ms, Dep..., ...)` (namespace scope, +directly above the function) expands to the function declaration plus ```cpp inline const laige::SystemDef Name##_Def = laige::SystemDef{ - #Name, &Name, laige::fpx16_16::fromFloat(budget_ms)}; + #Name, &Name, laige::fpx16_16::fromFloat(budget_ms), #__VA_ARGS__}; ``` so `Name` is both the C++ function name and the system's @@ -64,7 +68,12 @@ 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 -definition live in the same translation unit. +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 +verbatim into the def's `dependsOn` field — see +[scheduler.md](scheduler.md) for the format and the ordering +semantics. ## SystemIds and registration (component.h id contract) @@ -113,8 +122,8 @@ declared component: pure function of the sets). - M1-SYS-02 consumes these sets: two systems writing the same component type in one tick is rejected at scheduling time - (FR-12.3), and read-after-write orderings are warned where - declared. + (FR-12.3), and a declared read ordered before a declared write of + the same component is warned ([scheduler.md](scheduler.md)). ## Registration validation (FR-12.1, CORE-008) @@ -128,6 +137,7 @@ rate-limited structured warn (subsystem `system`, LOG-004) plus a | `def.name` null or empty | `InvalidArgument` + warn | `system/name_invalid` | | `def.run` null | `InvalidArgument` + warn | `system/run_invalid` | | `def.budgetMs` ≤ 0 | `InvalidArgument` + warn | `system/budget_invalid` | +| malformed `depends_on` spec (empty token, duplicate name, more than `kMaxSystemDependencies`) | `InvalidArgument` + warn | `system/dep_spec_invalid` | | duplicate name in this world | `InvalidArgument` + warn | `system/duplicate` | | `Io`: `T` not a Laige component | compile error | — | | `Io`: `T` not registered (this world) | `InvalidArgument` + warn | `system/io_unregistered` | diff --git a/laige-api.json b/laige-api.json index 3b8b39f..d31d994 100644 --- a/laige-api.json +++ b/laige-api.json @@ -423,62 +423,64 @@ {"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": 177, "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": 178, "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": 179, "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": 181, "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": 182, "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": 190, "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": 193, "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": 207, "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": 208, "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": 209, "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": 210, "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": 211, "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": 212, "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": 213, "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": 224, "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": 241, "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": 242, "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": 243, "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": 244, "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": 245, "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": 246, "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": 247, "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": 248, "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": 320, "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": 324, "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": 329, "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": 335, "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": 341, "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": 346, "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": 356, "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": 363, "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": 366, "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": 369, "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": 373, "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": 377, "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": 392, "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": 398, "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": 419, "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": 424, "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": 430, "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": 441, "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": 451, "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": 468, "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": 476, "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": 482, "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": 487, "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": 522, "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": 559, "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": 564, "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": 571, "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::clear", "kind": "method", "header": "src/laige-sim/include/laige/sim/entity.h", "line": 584, "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": 588, "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": 589, "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": 590, "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": 591, "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": 596, "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": 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::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}, @@ -486,27 +488,32 @@ {"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": 175, "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": 176, "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": 181, "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": 183, "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": 186, "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": 194, "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::SystemFn", "kind": "alias", "header": "src/laige-sim/include/laige/sim/system.h", "line": 200, "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": 212, "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). 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": 213, "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": 214, "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": 215, "signature": "fpx16_16 budgetMs", "summary": null, "budget": null, "experimental": false}, - {"name": "laige::SystemContext", "kind": "struct", "header": "src/laige-sim/include/laige/sim/system.h", "line": 225, "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": 227, "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": 236, "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": 253, "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": 256, "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": 266, "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": 267, "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": 268, "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": 272, "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": 276, "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": 306, "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). 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": 320, "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": 321, "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": 326, "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": 328, "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": 331, "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": 339, "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": 346, "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": 352, "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": 367, "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": 368, "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": 369, "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": 370, "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": 371, "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": 382, "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": 383, "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": 384, "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": 394, "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": 396, "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": 405, "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": 422, "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": 425, "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": 435, "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": 436, "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": 437, "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": 441, "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": 445, "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": 480, "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 76e0cea..f66c775 100644 --- a/roadmap/M1-heartbeat.md +++ b/roadmap/M1-heartbeat.md @@ -103,7 +103,7 @@ zero-allocation property (M1-ALLOC-01 enforces it once it exists; before that, A - **Verify:** `ctest -R system_registry` green. - **Size:** ~150 lines + tests -- [ ] **M1-SYS-02 · System scheduler** +- [x] **M1-SYS-02 · System scheduler** - **Refs:** FR-1.3 (engine enforces iteration legality); PRD §10.2 (sim thread) - **Depends:** M1-SYS-01 - **Scope:** diff --git a/roadmap/README.md b/roadmap/README.md index 6492506..e343c92 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 | 8 | 🚧 in progress (M1-SYS-01) | +| M1 | 25 | 9 | 🚧 in progress (M1-SYS-02) | | 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** | **28** | | +| **Total** | **193** | **29** | | --- @@ -203,6 +203,7 @@ One line per completed (or split/renumbered) step. | 2026-09-14 | M1-ECS-06 | `675fb94` | ECS guardrails (G-R3 entity-count thresholds, G-R4 per-frame component churn; M1-ECS-06 scope, nothing else): create() warns exactly when the live count REACHES 25/50/100% of the declared scene budget (integer thresholds capacity*pct/100; a level whose threshold computes to 0 never fires), at most once per level per frame (events `ecs/entity_budget_{25,50,100}`); the G-R4 per-frame churn counter (successful addComponent calls, including in-place overwrites, plus row-detaching removeComponent calls) warns when the per-frame total STRICTLY EXCEEDS `Options::churnPerFrameBudget` (default 256; 0 disables), at most once per frame (event `ecs/churn_per_frame`); both O(1) integer bookkeeping, no hot-path allocation (the warn paths are cold, LOG-003); NFR-13.3 5-field message grammar, build-stable; debug builds carry the PRD §9.3 advice as a structured `advice` FIELD (never message text); beginFrame() drives the per-frame windows (M1-LOOP-01 will wire it); guardrailStats() is the M1-PROF-01 feed; new EcsGuardrails suite (`ctest -R ecs_guardrails` — exact threshold firing, once-per-level-per-frame dedup, degenerate thresholds, churn budget exceed/strictness, per-frame reset, no-op removes uncounted, budget-0 disable, grammar parse, profiler feed, zero-alloc below-threshold window); local Verify: canonical g++ tree zero-warning, full ctest green, `ctest -R ecs_guardrails` green, sim suites green on build-asan/build-clang/build-release/build-shared/build-tsan, include-lint OK; (board/changelog row retroactively added 2026-09-14 by the M1-ECS-07 PR — the step merged as `675fb94`/PR #22 without updating this board or log) | | 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) | --- diff --git a/src/laige-sim/CMakeLists.txt b/src/laige-sim/CMakeLists.txt index 6855dae..3d4290c 100644 --- a/src/laige-sim/CMakeLists.txt +++ b/src/laige-sim/CMakeLists.txt @@ -26,7 +26,10 @@ # systems.cpp: the non-template World methods of the system registry # (systemCount, system + the SystemInfo queries); the registration # template (registerSystem) is header-defined in entity.h like -# registerComponent (the M1-ECS-02 pattern). +# registerComponent (the M1-ECS-02 pattern). M1-SYS-02 adds the +# 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). set(LAIGE_SIM_SOURCES entity.cpp archetype.cpp query.cpp guardrails.cpp systems.cpp) diff --git a/src/laige-sim/README.md b/src/laige-sim/README.md index c608585..8143af2 100644 --- a/src/laige-sim/README.md +++ b/src/laige-sim/README.md @@ -47,6 +47,15 @@ and declared component I/O (`Io`), `systems.cpp`; API contract in [docs/api/system_registry.md](../docs/api/system_registry.md), tests under [tests/laige-sim](../tests/laige-sim), CTest entry -`system_registry`). -The system loop (M1-SYS-02 onward) and the game loop (M1-LOOP) land in -the remaining M1 steps; physics, input, and animation in M3. +`system_registry`). M1-SYS-02 landed the system scheduler — the +execution order (the registration order plus the declared +`depends_on` edges, the stable topological sort), the pre-run +validation (unknown dependency, dependency cycle, double writer, +read-before-write warn), `SystemSchedule`, and +`World::scheduleSystems`/`runSystems` (`include/laige/sim/system.h`, +`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. diff --git a/src/laige-sim/include/laige/sim/entity.h b/src/laige-sim/include/laige/sim/entity.h index 26f5624..257d5d8 100644 --- a/src/laige-sim/include/laige/sim/entity.h +++ b/src/laige-sim/include/laige/sim/entity.h @@ -23,7 +23,11 @@ // tags..., fn) + the World-API mutation checks); M1-SYS-01 // adds the system registry (system.h: SystemDef, // SystemContext, the LAIGE_SYSTEM macro, -// World::registerSystem/system/systemCount). +// World::registerSystem/system/systemCount); M1-SYS-02 +// 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). // // --------------------------------------------------------------------------- // The handle contract (FR-1.2, CPP-007) @@ -542,6 +546,10 @@ class World { // def.budgetMs <= 0 -> InvalidArgument + warn // (system/budget_invalid) — // the budget must be explicit + // malformed depends_on spec (empty + // token, duplicate name, more than + // kMaxSystemDependencies) -> InvalidArgument + warn + // (system/dep_spec_invalid) // duplicate name in this world -> InvalidArgument + warn // (system/duplicate) // Io T not a Laige component -> compile error (static_assert) @@ -570,6 +578,47 @@ class World { // like componentInfo). [[nodiscard]] Result system(SystemId id) const noexcept; + // ------------------------------------------------------------- + // System scheduler (M1-SYS-02; full contract in system.h, + // "Scheduler") + // ------------------------------------------------------------- + + // 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. + [[nodiscard]] Status scheduleSystems(SystemSchedule& out) const noexcept; + + // 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). + // + // schedule.systemCount != the world's systemCount + // -> InvalidArgument + warn + // (system/schedule_stale) — + // the registry changed since + // the schedule was computed + // order entry 0, above systemCount, or a duplicate id + // -> InvalidArgument + warn + // (system/schedule_invalid) + // schedule.systemCount == 0 -> ok, runs nothing + [[nodiscard]] Status runSystems(const SystemSchedule& schedule) 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), @@ -1021,6 +1070,25 @@ Result World::registerSystem(const SystemDef& def, Ios...) laige::log::field("budget_raw", def.budgetMs.raw)); return ErrorCode::InvalidArgument; } + // The depends_on spec (M1-SYS-02, system.h "Scheduler"): a + // malformed name list is a def-level defect — a registration error, + // like a malformed name or budget. The names themselves are + // resolved against this world at SCHEDULING time, so forward + // dependencies (a system registered later) are legal. + detail::DepSpecParse parsedDep; + const detail::DepSpecError depErr = + detail::parseDepSpec(def.dependsOn, &parsedDep); + if (depErr != detail::DepSpecError::Ok) { + LAIGE_LOG_WARN("system", "dep_spec_invalid", + "System depends_on spec is malformed (empty token, " + "duplicate name, or more than " + "kMaxSystemDependencies dependencies); fix the " + "LAIGE_SYSTEM depends_on list", + laige::log::field("name", def.name), + laige::log::field("error", + detail::depSpecErrorName(depErr))); + return ErrorCode::InvalidArgument; + } for (std::uint32_t i = 0; i < systemCount_; ++i) { if (std::strcmp(systems_[i].def.name, def.name) == 0) { // Duplicate name is an error (M1-SYS-01 scope); the facade diff --git a/src/laige-sim/include/laige/sim/system.h b/src/laige-sim/include/laige/sim/system.h index a55b907..74d3230 100644 --- a/src/laige-sim/include/laige/sim/system.h +++ b/src/laige-sim/include/laige/sim/system.h @@ -21,9 +21,13 @@ // SystemInfo A registered system's snapshot (def + the declared // I/O membership queries) for the scheduler // (M1-SYS-02) and the profiler (M1-PROF-01). +// SystemSchedule The computed execution order of a world's systems +// (M1-SYS-02): the systemCount plus the SystemId +// values in execution order. // LAIGE_SYSTEM The one-line declaration of a system: the plain // function declaration plus the SystemDef, at -// namespace scope directly above the function. +// namespace scope directly above the function; +// optional trailing depends_on names (M1-SYS-02). // // --------------------------------------------------------------------------- // The system shape (FR-1.3: plain functions, no inheritance) @@ -50,7 +54,7 @@ // // void Movement(laige::World&, laige::SystemContext&); // inline const laige::SystemDef Movement_Def = laige::SystemDef{ -// "Movement", &Movement, laige::fpx16_16::fromFloat(1)}; +// "Movement", &Movement, laige::fpx16_16::fromFloat(1), ""}; // // so `Name` is both the C++ function name and the system's // registration name (stringified), and the def variable is `Name##Def`. @@ -60,6 +64,19 @@ // the exact fpx16_16 happens once, at program start (a setup path, // never a hot path). // +// M1-SYS-02 adds the optional trailing `depends_on` names: +// +// LAIGE_SYSTEM(Health, 1, Spawner) +// +// The names after `budget_ms` are the registration names of the +// systems `Health` must run after — stringified verbatim into the +// def's `dependsOn` spec ("Spawner"). They may name systems +// registered LATER in the same world (forward dependencies: the spec +// is validated against the world at scheduling time, not at +// registration). The empty list (no trailing names) is "" = no +// dependencies. See the "Scheduler" section below for the spec format +// and the ordering semantics. +// // --------------------------------------------------------------------------- // Registration and the id contract (FR-1.3, component.h precedent) // --------------------------------------------------------------------------- @@ -120,15 +137,125 @@ // and none per tick (the registry is read-only during the loop). // // --------------------------------------------------------------------------- +// Scheduler (M1-SYS-02): execution order, depends_on, validation +// --------------------------------------------------------------------------- +// +// The scheduler turns the registry (registration order + declared +// depends_on + declared component I/O) into the per-tick execution +// order, and runs the systems in that order: +// +// SystemSchedule sched; +// Status s = world.scheduleSystems(sched); // setup phase, once +// ... // before the loop +// for (tick) { // M1-LOOP-01 owns this +// world.beginFrame(); +// world.runSystems(sched); +// } +// +// Execution order: +// +// - The BASE order is the registration order (ascending SystemId). +// Without depends_on, the computed order is exactly that. +// - A depends_on edge (system X lists system Y in its spec) means +// X runs AFTER Y. Forward edges are legal (Y may be registered +// later: the spec is resolved against the world at scheduling +// time, not at registration). +// - The computed order is the STABLE topological sort: repeatedly +// pick the smallest unrun SystemId whose dependencies are all +// already placed (Kahn's algorithm with a min-id tie-break). The +// order is a pure function of the registration order and the +// declared edges — deterministic (ARCH-010) — and it never moves a +// system earlier than registration order would place it; a +// system only moves later, behind its dependencies. +// +// The depends_on spec (the def's `dependsOn` string; the macro builds +// it from the trailing names): a comma-separated list of registration +// names. Each token is trimmed of ASCII whitespace, must be non-empty, +// and must not repeat (the dependency list is a set, not a multiset — +// the declared-I/O precedent). At most kMaxSystemDependencies (16) +// direct dependencies: a longer list means the registration order +// should carry the ordering (a barrier is registration position, not a +// dependency list). nullptr or "" means no dependencies. +// +// Pre-run validation — World::scheduleSystems (normative order, first +// failure wins; every failure is one rate-limited structured warn, +// subsystem "system", LOG-004, plus a Status — FR-12.3: never silent): +// +// 1. A dependency name that is not registered in this world +// (first in ascending (system id, spec position) order) +// -> InvalidArgument +// (system/dep_missing) +// 2. A dependency cycle: Kahn's leaves systems with unsatisfied +// dependencies (one concrete cycle is reported — the +// deterministic walk from the smallest remaining id, following +// each system's first spec-listed dependency that is still +// remaining) +// -> InvalidArgument +// (system/dependency_cycle) +// 3. Two systems both declaring Write of the same component type in +// one tick (first conflict in ascending component-id, then +// ascending writer-id order; order-independent: the last write +// would silently win) +// -> InvalidArgument +// (system/double_writer) +// 4. (WARN ONLY — scheduling succeeds) a declared read that the +// computed order places BEFORE a declared write of the same +// component: the reader observes the previous tick's value, not +// this tick's write (each such (reader, writer, component) triple +// warns once, in ascending component-id, reader-id, writer-id +// order) +// -> Warn +// (system/read_before_write) +// +// On success `out` is fully populated and nothing is logged +// (LOG-003: the success path has no diagnostics). +// +// Running — World::runSystems(schedule): +// +// - The schedule must describe the CURRENT registry: a systemCount +// mismatch (systems registered after the schedule was computed) +// is rejected (system/schedule_stale); the order entries must be +// unique ids in 1..systemCount (a hand-built malformed schedule is +// rejected, system/schedule_invalid). Both are InvalidArgument + +// one rate-limited warn. +// - The systems run STRICTLY ONE AT A TIME, in schedule order, on +// the world's single owner thread (PRD §10.2: simulation is +// single-threaded; API-004: the system phase is the mutation +// phase). The per-tick SystemContext is built per system (a +// non-owning view — never stored). The M1-ECS-04 iteration +// legality guard (query.h) applies inside every system exactly as +// for a direct World::each: nested each() and illegal mutations +// are rejected per system, and the declared-I/O validation above +// is the cross-system complement (one writer per component, +// read-before-write surfaced). +// - A system's run function is void: per-entity Status results from +// its own each() calls are the system's to handle (check them, +// CORE-008). runSystems itself reports only schedule-level +// failures. Calling runSystems from inside a system (nesting +// system phases) is misuse — the declared order contract no +// longer holds (the one-writer-per-component invariant still +// prevents state corruption; see the misuse warnings). +// +// Determinism (ARCH-010): scheduling is pure integer/string +// bookkeeping — id scans, string comparisons over the registration +// names, Kahn's with a min-id rule. No floating point, no randomness, +// no addresses enter the order or the warning set. Two worlds (two +// process runs, two builds) with the same registration order, specs, +// and declared I/O produce bit-identical schedules and identical +// warning sequences. +// +// --------------------------------------------------------------------------- // Threading and failure // --------------------------------------------------------------------------- // -// Registration is a setup-phase operation on the world's single -// owner thread (CONC-001; API-004: mutation in an explicit phase). -// The run function is 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). +// Registration, scheduling, and running are all setup-phase or +// 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). // // --------------------------------------------------------------------------- // Misuse warnings @@ -145,6 +272,24 @@ // - A system's I/O may declare a component it does not touch (a // conservative declaration), never one it touches without // declaring. +// - depends_on names registration names, not function addresses or +// SystemIds (ids are per-world runtime values, names are the +// stable identity). A dependency on a name that is never +// registered in this world fails at scheduling time — a +// dependency on a system registered in ANOTHER world is always +// such a failure (systems never cross worlds). +// - A schedule is computed for the registry it was computed with: +// registering systems after scheduleSystems() and then +// runSystems() with the old schedule is rejected +// (system/schedule_stale). Recompute the schedule after any +// registration change. +// - A system that reads a component written by a LATER system in +// the computed order reads the previous tick's value: the +// scheduler warns (system/read_before_write). If the read must +// see this tick's write, declare `depends_on` (or register the +// writer earlier); a deliberate cross-tick read is declared by +// not declaring the write at all (undeclared I/O is invisible to +// the check — use it consciously). // - The LAIGE_SYSTEM macro is namespace-scope only: the def // variable it builds is `inline const` (block scope is // ill-formed) and the function it declares must match the @@ -193,6 +338,13 @@ inline bool operator!=(SystemId a, SystemId b) noexcept { // an ADR, not a knob). inline constexpr std::uint32_t kMaxSystems = 256; +// 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. +inline constexpr std::uint32_t kMaxSystemDependencies = 16; + // 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, @@ -204,15 +356,32 @@ using SystemFn = void (*)(World&, SystemContext&); // `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). 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. +// 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. struct SystemDef { const char* name; SystemFn run; fpx16_16 budgetMs; + const char* dependsOn; // nullptr or "" = no dependencies +}; + +// 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). +struct SystemSchedule { + std::uint32_t systemCount{}; + std::uint32_t order[kMaxSystems]{}; }; // The per-tick context handed to a system's run() (FR-1.3; the PRD @@ -292,18 +461,23 @@ struct SystemInfo { // 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). Expands to the function declaration plus +// 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 // // inline const laige::SystemDef Name##Def = laige::SystemDef{ -// #Name, &Name, laige::fpx16_16::fromFloat(budget_ms)}; +// #Name, &Name, laige::fpx16_16::fromFloat(budget_ms), +// #__VA_ARGS__}; // // The def variable (e.g. `Movement_Def`) is what World::registerSystem // takes, together with the Io<...> pack. The macro and the function // definition live in the same translation unit. -#define LAIGE_SYSTEM(Name, budget_ms) \ - void Name(laige::World&, laige::SystemContext&); \ +#define LAIGE_SYSTEM(Name, budget_ms, ...) \ + void Name(laige::World&, laige::SystemContext&); \ inline const laige::SystemDef Name##_Def = laige::SystemDef{ \ - #Name, &Name, laige::fpx16_16::fromFloat(budget_ms)}; + #Name, &Name, laige::fpx16_16::fromFloat(budget_ms), #__VA_ARGS__}; namespace detail { @@ -360,6 +534,38 @@ struct IoComponent> { static constexpr Access access = A; }; +// The parsed form of a depends_on spec (M1-SYS-02). `names[i]` +// points into the spec string itself (tokens are substrings — the +// spec is a compile-time string literal owned by the program, so the +// pointers are stable for the process lifetime), with `lengths[i]` +// the token length (tokens are NOT NUL-terminated). Setup path only +// — registration validation (registerSystem) and resolution +// (scheduleSystems); never on a tick. No copy, no allocation. +struct DepSpecParse { + std::uint32_t count{}; + const char* names[kMaxSystemDependencies]{}; + std::uint32_t lengths[kMaxSystemDependencies]{}; +}; + +// The outcome of parseDepSpec (the first failure wins; on any error +// the out parameter is left empty — count 0, no partial parse). +enum class DepSpecError : std::uint8_t { + Ok = 0, + EmptyToken = 1, // a token is empty or all whitespace + DuplicateToken = 2, // the same name listed twice (the list is a set) + TooMany = 3, // more than kMaxSystemDependencies dependencies +}; + +// Parse one depends_on spec (the raw SystemDef::dependsOn string): +// split on ',', trim ASCII whitespace (space, tab, CR, LF) around +// each token, and record the token pointers in `out`. nullptr or a +// whitespace-only spec is Ok with count 0 (no dependencies). +DepSpecError parseDepSpec(const char* spec, DepSpecParse* out) noexcept; + +// The stable token for a DepSpecError (the `error` field of the +// system/dep_spec_invalid warn; LOG-001 machine-searchable). +const char* depSpecErrorName(DepSpecError error) noexcept; + } // namespace detail } // namespace laige diff --git a/src/laige-sim/systems.cpp b/src/laige-sim/systems.cpp index 26bd9e9..ba02646 100644 --- a/src/laige-sim/systems.cpp +++ b/src/laige-sim/systems.cpp @@ -1,15 +1,24 @@ -// laige-sim system registry (M1-SYS-01). +// laige-sim system registry (M1-SYS-01) + system scheduler (M1-SYS-02). // -// The non-template World methods of the system registry (systemCount, -// system) and the SystemInfo snapshot queries. The registration -// template (registerSystem, with the resolveIoEntry helper) is -// header-defined in entity.h (the M1-ECS-02/03 template precedent). +// The non-template World methods of the system framework: +// - systemCount, system, the SystemInfo snapshot queries +// (M1-SYS-01) +// - the depends_on spec parse (detail::parseDepSpec / +// detail::depSpecErrorName — declared in system.h) and the +// scheduler (scheduleSystems, runSystems) — M1-SYS-02. The +// registration template (registerSystem, with the resolveIoEntry +// helper) is header-defined in entity.h (the M1-ECS-02 pattern). // See include/laige/sim/system.h for the full contract (the id and -// determinism contract, the validation order, the I/O sets). +// determinism contracts, the validation orders, the I/O sets, the +// scheduler section) and docs/api/scheduler.md for the API contract. #include "laige/sim/entity.h" +#include #include +#include + +#include "laige/logging.h" namespace laige { @@ -43,4 +52,373 @@ bool SystemInfo::declaresWrite(ComponentTypeId componentId) const noexcept { return writeComponents_.contains(componentId.value); } +namespace detail { + +const char* depSpecErrorName(DepSpecError error) noexcept { + switch (error) { + case DepSpecError::EmptyToken: return "empty_token"; + case DepSpecError::DuplicateToken: return "duplicate_token"; + case DepSpecError::TooMany: return "too_many"; + case DepSpecError::Ok: return "ok"; + } + return "ok"; // unreachable: the enum is exhaustive +} + +DepSpecError parseDepSpec(const char* spec, DepSpecParse* out) noexcept { + // No partial parse: the out parameter is empty on every error (the + // callers treat a non-Ok outcome as "no usable dependencies"). + out->count = 0; + if (spec == nullptr) return DepSpecError::Ok; + bool afterComma = false; + for (;;) { + // Trim leading ASCII whitespace (space, tab, CR, LF). + while (*spec != '\0' && + (*spec == ' ' || *spec == '\t' || *spec == '\r' || *spec == '\n')) { + ++spec; + } + const char* start = spec; + while (*spec != '\0' && *spec != ',') ++spec; + char* end = const_cast(spec); + // Trim trailing ASCII whitespace. + while (end > start && + (*(end - 1) == ' ' || *(end - 1) == '\t' || *(end - 1) == '\r' || + *(end - 1) == '\n')) { + --end; + } + if (end == start) { + // An empty token. Legal only as the whole spec (nullptr/""/ + // whitespace = no dependencies); after a comma it is a + // malformed spec (a trailing comma is a typo). + if (!afterComma && *spec == '\0') break; + return DepSpecError::EmptyToken; + } + const std::uint32_t len = static_cast(end - start); + // The dependency list is a set, not a multiset (the declared-I/O + // precedent, system.h). + for (std::uint32_t i = 0; i < out->count; ++i) { + if (out->lengths[i] == len && + std::memcmp(out->names[i], start, len) == 0) { + return DepSpecError::DuplicateToken; + } + } + if (out->count >= kMaxSystemDependencies) { + return DepSpecError::TooMany; + } + out->names[out->count] = start; // points into the spec (stable) + out->lengths[out->count] = len; + ++out->count; + if (*spec == '\0') break; + ++spec; // consume the comma + afterComma = true; + } + return DepSpecError::Ok; +} + +} // namespace detail + +namespace { + +// True when the (not necessarily NUL-terminated) token `token` of +// length `len` equals the NUL-terminated registered name `name`. +// A prefix of a longer name is NOT a match ("A" != "AB") — the +// length must agree exactly. +bool tokenMatchesName(const char* token, std::uint32_t len, + const char* name) noexcept { + for (std::uint32_t i = 0; i < len; ++i) { + if (token[i] != name[i]) return false; + } + return name[len] == '\0'; +} + +// Append the NUL-terminated `s` to `buf` (capacity `cap` including +// the NUL), truncating beyond it (LOG-005: user-controlled text is +// bounded — the names are compile-time string literals, but the log +// fields stay small). +void appendBounded(char* buf, std::size_t cap, const char* s) noexcept { + std::size_t len = 0; + while (buf[len] != '\0' && len + 1 < cap) ++len; + while (*s != '\0' && len + 1 < cap) { + buf[len++] = *s++; + } + buf[len] = '\0'; +} + +// Copy the first `len` characters of the (not NUL-terminated) token +// into `buf` (capacity `cap` including the NUL), truncating beyond it. +void copyTokenBounded(char* buf, std::size_t cap, const char* token, + std::uint32_t len) noexcept { + std::size_t i = 0; + while (i < len && i + 1 < cap) { + buf[i] = token[i]; + ++i; + } + buf[i] = '\0'; +} + +} // namespace + +// --------------------------------------------------------------------------- +// M1-SYS-02: the system scheduler. +// +// scheduleSystems: the stable topological sort of the registration +// order plus the declared depends_on edges (Kahn, min-id tie-break) +// plus the pre-run validation (system.h "Scheduler"; the normative +// order is documented there). All state is fixed-size and stack/ +// world-scoped: no allocation (setup path, PERF-003). +// --------------------------------------------------------------------------- + +Status World::scheduleSystems(SystemSchedule& out) const noexcept { + out = SystemSchedule{}; // zeroed: an error leaves a well-defined out + const std::uint32_t n = systemCount_; + out.systemCount = n; + if (n == 0) return Status{}; // the empty world schedules empty + + // 1) Resolve every system's dependency names (system id ascending; + // the first failure in ascending (system id, spec position) + // order wins — system/dep_missing). + std::uint16_t depIds[kMaxSystems][kMaxSystemDependencies]; + std::uint8_t depCount[kMaxSystems]{}; + for (std::uint32_t i = 1; i <= n; ++i) { + detail::DepSpecParse parsed; + // The spec was validated at registration (registerSystem) and the + // registry is immutable after setup: the parse cannot fail here. + // Fail loudly anyway (CORE-008: never trust). + const detail::DepSpecError err = + detail::parseDepSpec(systems_[i - 1].def.dependsOn, &parsed); + if (err != detail::DepSpecError::Ok) { + LAIGE_LOG_WARN("system", "dep_spec_invalid", + "System depends_on spec failed to re-parse during " + "scheduling (should be unreachable — the spec was " + "validated at registration); rebuild the def", + laige::log::field("name", systems_[i - 1].def.name), + laige::log::field("error", + detail::depSpecErrorName(err))); + return ErrorCode::InvalidArgument; + } + depCount[i - 1] = static_cast(parsed.count); + for (std::uint32_t d = 0; d < parsed.count; ++d) { + std::uint32_t resolved = 0; + for (std::uint32_t j = 1; j <= n; ++j) { + if (tokenMatchesName(parsed.names[d], parsed.lengths[d], + systems_[j - 1].def.name)) { + resolved = j; + break; + } + } + if (resolved == 0) { + // The dependency names a system that is not registered in + // this world (a name from another world, or a typo). + char missing[64]; // LOG-005 bound on the logged token + copyTokenBounded(missing, sizeof(missing), parsed.names[d], + parsed.lengths[d]); + LAIGE_LOG_WARN("system", "dep_missing", + "System depends on a name that is not registered " + "in this world; register the dependency in this " + "world or remove it from the depends_on list", + laige::log::field("system", + systems_[i - 1].def.name), + laige::log::field("missing_dep", missing), + laige::log::field("position", d)); + return ErrorCode::InvalidArgument; + } + depIds[i - 1][d] = static_cast(resolved); + } + } + + // 2) The stable topological sort (Kahn with a min-id tie-break): + // repeatedly place the smallest unrun system whose dependencies + // are all placed. A system only moves later, behind its + // dependencies; with no dependencies the order is exactly the + // registration order (system.h "Scheduler"). + std::uint16_t pending[kMaxSystems]; + std::uint8_t scheduled[kMaxSystems]{}; + for (std::uint32_t i = 0; i < n; ++i) { + pending[i] = static_cast(depCount[i]); + } + std::uint32_t placed = 0; + while (placed < n) { + std::uint32_t picked = 0; + for (std::uint32_t i = 1; i <= n; ++i) { + if (scheduled[i - 1] == 0 && pending[i - 1] == 0) { + picked = i; + break; + } + } + if (picked == 0) break; // the remainder is a cycle (below) + out.order[placed++] = picked; + scheduled[picked - 1] = 1; + for (std::uint32_t j = 1; j <= n; ++j) { + if (scheduled[j - 1] != 0) continue; + for (std::uint32_t d = 0; d < depCount[j - 1]; ++d) { + if (depIds[j - 1][d] == picked) { + --pending[j - 1]; + break; // the dep lists are sets (registration-validated) + } + } + } + } + if (placed < n) { + // 2b) Report one concrete cycle: 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 at least one remaining dependency — the + // Kahn invariant). + std::uint16_t path[kMaxSystems]{}; + std::uint32_t pathLen = 0; + std::uint32_t cycleStart = 0; + std::uint32_t cur = 0; + for (std::uint32_t i = 1; i <= n; ++i) { + if (scheduled[i - 1] == 0) { + cur = i; + break; + } + } + for (;;) { + for (std::uint32_t k = 0; k < pathLen; ++k) { + if (path[k] == cur) { + cycleStart = k; + cur = 0; // signal: cycle closed + break; + } + } + if (cur == 0) break; + path[pathLen++] = static_cast(cur); + std::uint32_t next = 0; + for (std::uint32_t d = 0; d < depCount[cur - 1]; ++d) { + const std::uint16_t dep = depIds[cur - 1][d]; + if (scheduled[dep - 1] == 0) { + next = dep; + break; + } + } + cur = next; // nonzero by the Kahn invariant + } + // Join the cycle's systems (walk order, starting at the cycle's + // smallest entry) into one bounded log field. + char cycleBuf[256]; + cycleBuf[0] = '\0'; + for (std::uint32_t k = cycleStart; k < pathLen; ++k) { + if (k > cycleStart) appendBounded(cycleBuf, sizeof(cycleBuf), ","); + appendBounded(cycleBuf, sizeof(cycleBuf), + systems_[path[k] - 1].def.name); + } + LAIGE_LOG_WARN("system", "dependency_cycle", + "The depends_on graph has a cycle; no execution " + "order can satisfy the dependencies — break the " + "cycle (the listed systems, in walk order)", + laige::log::field("cycle", cycleBuf)); + return ErrorCode::InvalidArgument; + } + + // 3) The double-writer check: 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 ascending writer-id order. + for (std::uint32_t c = 1; c <= kMaxComponentTypes; ++c) { + std::uint32_t firstWriter = 0; + std::uint32_t secondWriter = 0; + for (std::uint32_t i = 1; i <= n; ++i) { + if (systems_[i - 1].writeComponents.contains(c)) { + if (firstWriter == 0) { + firstWriter = i; + } else { + secondWriter = i; + break; + } + } + } + if (secondWriter != 0) { + LAIGE_LOG_WARN("system", "double_writer", + "Two systems both declare Write of the same " + "component in one tick; the last write would " + "silently win the component — split the write " + "between the systems or merge them", + laige::log::field("component_id", c), + laige::log::field("first_writer", + systems_[firstWriter - 1].def.name), + laige::log::field("second_writer", + systems_[secondWriter - 1].def.name)); + return ErrorCode::InvalidArgument; + } + } + + // 4) (Warn only — scheduling succeeds) a declared read that the + // computed order places BEFORE a declared write of the same + // component: the read observes the previous tick's value. Each + // (reader, writer, component) triple warns once, in ascending + // component-id, reader-id, writer-id order. + std::uint32_t pos[kMaxSystems]{}; // system id -> execution index + for (std::uint32_t k = 0; k < n; ++k) { + pos[out.order[k] - 1] = k; + } + for (std::uint32_t c = 1; c <= kMaxComponentTypes; ++c) { + for (std::uint32_t r = 1; r <= n; ++r) { + if (!systems_[r - 1].readComponents.contains(c)) continue; + for (std::uint32_t w = 1; w <= n; ++w) { + if (w == r) continue; + if (!systems_[w - 1].writeComponents.contains(c)) continue; + if (pos[r - 1] < pos[w - 1]) { + LAIGE_LOG_WARN("system", "read_before_write", + "System reads a component that another system " + "writes later in the same tick; the read sees " + "the previous tick's value, not this tick's " + "write — declare depends_on (or register the " + "writer earlier) when the read must see the " + "write", + laige::log::field("reader", + systems_[r - 1].def.name), + laige::log::field("writer", + systems_[w - 1].def.name), + laige::log::field("component_id", c)); + } + } + } + } + return Status{}; +} + +// 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. +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 + // computed (or it belongs to another world) — recompute it. + if (schedule.systemCount != systemCount_) { + LAIGE_LOG_WARN("system", "schedule_stale", + "The schedule was computed for a different registry; " + "the world's system count changed after " + "scheduleSystems — recompute the schedule", + laige::log::field("scheduled_systems", + schedule.systemCount), + laige::log::field("current_systems", systemCount_)); + return ErrorCode::InvalidArgument; + } + const std::uint32_t count = schedule.systemCount; + if (count == 0) return Status{}; // the empty world runs nothing + // The order entries must be unique ids in 1..count (a hand-built + // malformed schedule is a caller bug — reject it loudly, API-008). + detail::IdSet256 seen{}; + for (std::uint32_t k = 0; k < count; ++k) { + const std::uint32_t id = schedule.order[k]; + if (id == 0 || id > count || seen.contains(id)) { + LAIGE_LOG_WARN("system", "schedule_invalid", + "The schedule order contains a missing or " + "duplicate system id; rebuild it with " + "scheduleSystems", + laige::log::field("slot", k), + laige::log::field("id", id)); + return ErrorCode::InvalidArgument; + } + seen.set(id); + } + for (std::uint32_t k = 0; k < count; ++k) { + const detail::SystemRecord& rec = systems_[schedule.order[k] - 1]; + SystemContext ctx{*this}; // per-tick, per-system, non-owning + rec.def.run(*this, ctx); + } + return Status{}; +} + } // namespace laige diff --git a/tests/laige-sim/CMakeLists.txt b/tests/laige-sim/CMakeLists.txt index d246098..dfa5425 100644 --- a/tests/laige-sim/CMakeLists.txt +++ b/tests/laige-sim/CMakeLists.txt @@ -1,27 +1,30 @@ -# laige-sim tests (M1-ECS-01/02/03/04/05/06/07 + M1-SYS-01): entity +# 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, and the system registry (plain registered -# functions, declared budgets + component I/O). +# 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). # # 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`, and `system_registry` entries are the -# M1-ECS-01, M1-ECS-02, M1-ECS-03, M1-ECS-04, M1-ECS-05, M1-ECS-06, -# M1-ECS-07, and M1-SYS-01 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`), selecting exactly the -# suites below from the shared executable. +# `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 +# the suites below from the shared executable. set(LAIGE_SIM_TEST_SOURCES entity_tests.cpp component_registry_tests.cpp archetype_tests.cpp query_tests.cpp iter_order_tests.cpp ecs_guardrails_tests.cpp ecs_stress_tests.cpp - system_registry_tests.cpp) + system_registry_tests.cpp + scheduler_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, @@ -124,10 +127,19 @@ add_test(NAME system_registry COMMAND laige-sim_tests --gtest_filter=SystemRegistry.*) +# M1-SYS-02: system scheduler. The step's Verify command is +# `ctest -R scheduler`; this entry selects exactly the SystemScheduler +# suites from the shared laige-sim_tests executable (the machine- +# greppable scheduler-order / scheduler-zeroalloc lines land in the +# ctest output). +add_test(NAME scheduler + COMMAND laige-sim_tests + --gtest_filter=SystemScheduler.*) + 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 + query iter_order ecs_guardrails ecs_stress system_registry scheduler PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") endif() diff --git a/tests/laige-sim/scheduler_tests.cpp b/tests/laige-sim/scheduler_tests.cpp new file mode 100644 index 0000000..be85385 --- /dev/null +++ b/tests/laige-sim/scheduler_tests.cpp @@ -0,0 +1,1107 @@ +// laige-sim system scheduler suite (M1-SYS-02). +// +// Step Verify scope (roadmap/M1-heartbeat.md): +// - ordering: the computed execution order is the stable +// topological sort of the registration order plus the declared +// depends_on edges (Kahn, min-id tie-break); forward +// dependencies reorder, backward ones keep the registration +// order; no dependencies = exactly the registration order +// - running: runSystems executes the systems strictly one at a +// time in schedule order (state flows from an earlier writer to a +// later reader) +// - pre-run validation (first failure wins): unknown dependency +// name (system/dep_missing), dependency cycle +// (system/dependency_cycle, one concrete cycle reported), two +// systems writing the same component (system/double_writer); +// a declared read ordered before a declared write WARNs without +// failing (system/read_before_write) +// - registration validation of the depends_on spec (the def-level +// form): empty token, trailing comma, duplicate name, more than +// kMaxSystemDependencies (system/dep_spec_invalid); whitespace +// trimming is legal +// - schedule lifetime: a schedule is valid for the registry it was +// computed with (system/schedule_stale); a hand-built malformed +// schedule is rejected (system/schedule_invalid) +// - the empty world schedules and runs empty (moved-from world +// included); the LAIGE_SYSTEM macro carries the optional +// depends_on names (spec stringization) +// - determinism (ARCH-010): the order is identical across two +// worlds with the same registrations; a known-answer scenario +// pins the exact order hash (machine-greppable line) +// - no heap allocation at scheduling or per tick (setup path + +// hot path; PERF-003 — the operator-new counter on the +// non-sanitizer trees, the M1-ECS-03/07 pattern; the sanitizer +// trees prove it leak-free) +// +// Runs as CTest `scheduler` (the step's Verify command: +// `ctest -R scheduler`): 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, + "scheduler_tests must be built with exceptions disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#elif defined(__EXCEPTIONS) && __EXCEPTIONS +static_assert(false, + "scheduler_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, + "scheduler_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 SCHEDULER_TESTS_ACTIVE_CPLUSPLUS _MSVC_LANG +#else +# define SCHEDULER_TESTS_ACTIVE_CPLUSPLUS __cplusplus +#endif + +#if SCHEDULER_TESTS_ACTIVE_CPLUSPLUS < 202002L +static_assert(false, + "scheduler_tests must be built as C++20 " + "(NFR-8.10); see laige_apply_engine_policy()."); +#endif + +// --------------------------------------------------------------------------- +// Test component types (global scope on purpose) +// +// LAIGE_COMPONENT specializes laige::detail::ComponentTraits, which +// the C++ standard requires to be declared in the primary template's +// enclosing namespace — so the marks cannot sit in an anonymous +// namespace. +// --------------------------------------------------------------------------- + +struct SchPos { + std::int32_t x; + std::int32_t y; +}; +LAIGE_COMPONENT(SchPos); + +struct SchVel { + std::int64_t vx; +}; +LAIGE_COMPONENT(SchVel); + +struct SchTag { + std::int32_t v; +}; +LAIGE_COMPONENT(SchTag); + +// --------------------------------------------------------------------------- +// The plain systems (FR-1.3: plain functions, no class, no +// inheritance). The LAIGE_SYSTEM macro declares each function and +// builds its def (`Name##Def`) directly above the definition. The +// file-scope counters and the run log are test plumbing. +// --------------------------------------------------------------------------- + +namespace { + +// The run log: which systems executed, in order (token per system). +// A fixed array — the zero-alloc window must not see an allocation +// here. +constexpr std::uint32_t kRunLogCapacity = 64; +std::uint32_t runLog[kRunLogCapacity]; +std::uint32_t runLogCount = 0; + +void clearRunLog() { + runLogCount = 0; +} +void markRun(std::uint32_t token) { + if (runLogCount < kRunLogCapacity) { + runLog[runLogCount++] = token; + } +} + +std::uint64_t schReadVelSum = 0; +std::uint64_t schReadPosSum = 0; + +} // namespace + +// The per-tick write path: the query's Write reference (query.h +// "Iteration legality"). +LAIGE_SYSTEM(SchWriteVel, 1) +void SchWriteVel(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + markRun(2); + static_cast(ctx.each( + [](laige::Entity e, SchVel& v) { + static_cast(e); + v.vx = 0x1234; // the sentinel the tests read back + }, + laige::Write{})); +} + +// The per-tick read path: sums the velocities through the context's +// delegated each (reset per run — a stale read is observable). +LAIGE_SYSTEM(SchReadVel, 1) +void SchReadVel(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + markRun(3); + schReadVelSum = 0; + static_cast(ctx.each( + [](laige::Entity e, const SchVel& v) { + static_cast(e); + schReadVelSum += static_cast(v.vx); + }, + laige::Read{})); +} + +// A second writer of SchTag: paired with SchWriteTag it forms the +// double-writer conflict (one writer per component, M1-SYS-02). +LAIGE_SYSTEM(SchWriteTag, 1) +void SchWriteTag(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + markRun(4); + static_cast(ctx.each( + [](laige::Entity e, SchTag& t) { + static_cast(e); + t.v = 1; + }, + laige::Write{})); +} + +LAIGE_SYSTEM(SchWriteTag2, 1) +void SchWriteTag2(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + markRun(5); + static_cast(ctx.each( + [](laige::Entity e, SchTag& t) { + static_cast(e); + t.v = 2; + }, + laige::Write{})); +} + +// The Pos read path (the zero-alloc window exercises a second +// component through the same declared-I/O pattern). +LAIGE_SYSTEM(SchReadPos, 1) +void SchReadPos(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + markRun(6); + schReadPosSum = 0; + static_cast(ctx.each( + [](laige::Entity e, const SchPos& p) { + static_cast(e); + schReadPosSum += static_cast(static_cast(p.x)); + }, + laige::Read{})); +} + +// The no-op system (an ordering marker that declares no I/O). +LAIGE_SYSTEM(SchNoop, 1) +void SchNoop(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + static_cast(ctx); + markRun(1); +} + +// The macro with the optional depends_on names (M1-SYS-02): the +// trailing names are stringized verbatim into the def's spec — +// SchNoop before SchDepOnNoop, and SchNoop + SchWriteVel before +// SchDepOnTwo (one comma-separated list, as written). +LAIGE_SYSTEM(SchDepOnNoop, 1, SchNoop) +void SchDepOnNoop(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + static_cast(ctx); + markRun(7); +} + +LAIGE_SYSTEM(SchDepOnTwo, 1, SchNoop, SchWriteVel) +void SchDepOnTwo(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + static_cast(ctx); + markRun(8); +} + +namespace { + +// One world, taken out of its Result (Result::value() is const; +// takeValue() && moves the storage out — the documented +// ownership-transfer path, result.h). +laige::World makeWorld(std::uint32_t capacity) { + auto w = laige::World::create(laige::World::Options{capacity}); + if (!w.ok()) { + ADD_FAILURE() << "World::create(" << capacity + << ") failed: " << laige::errorName(w.error()); + abort(); + } + return std::move(w).takeValue(); +} + +// The manual SystemDef shape (the LAIGE_SYSTEM macro builds the same +// struct); used for the ordering/validation cases where the spec must +// be exact (whitespace, malformed tokens). +laige::SystemDef makeDef(const char* name, laige::SystemFn run, + laige::fpx16_16 budgetMs, + const char* dependsOn = nullptr) { + return laige::SystemDef{name, run, budgetMs, dependsOn}; +} + +// Plain helper functions (anonymous namespace: their addresses are +// taken directly, no macro involved). +void fnNoop(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + static_cast(ctx); +} +void fnNoop2(laige::World& world, laige::SystemContext& ctx) { + static_cast(world); + static_cast(ctx); +} + +// Distinct registration names for the bulk tests (static storage: the +// names must outlive the registration — system.h). +inline char* bulkName(std::uint32_t i) { + static char names[laige::kMaxSystems + 1][16]; + std::snprintf(names[i], sizeof(names[i]), "Dep%03u", i); + return names[i]; +} + +// 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 ComponentRegistry 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; +}; + +// Find one field of a recorded event (test plumbing). +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 ""; +} + +void restoreConsoleSink() { + laige::log::LoggerOptions defaults; + if (!laige::log::Logger::instance().init(std::move(defaults)).ok()) { + ADD_FAILURE() << "Logger init with the default console sink failed"; + abort(); + } +} + +// FNV-1a 64-bit, big-endian byte order per u64 (the docs/testing.md §4 +// KAT convention): the machine-greppable scenario hash. +std::uint64_t fnv1a64(const std::uint64_t* values, std::size_t n) { + std::uint64_t h = 0xcbf29ce484222325ull; // FNV offset basis (FNV-1a spec) + for (std::size_t i = 0; i < n; ++i) { + for (int shift = 56; shift >= 0; shift -= 8) { + h ^= (values[i] >> shift) & 0xFFull; + h *= 0x100000001b3ull; // FNV prime (FNV-1a spec) + } + } + return h; +} + +} // namespace + +// --------------------------------------------------------------------------- +// The LAIGE_SYSTEM macro with depends_on (M1-SYS-02) +// --------------------------------------------------------------------------- + +TEST(SystemScheduler, MacroCarriesDependsOnSpec) { + // No trailing names: the spec is "" (no dependencies). + EXPECT_STREQ(SchNoop_Def.dependsOn, ""); + // One trailing name, stringified verbatim: + EXPECT_STREQ(SchDepOnNoop_Def.dependsOn, "SchNoop"); + // Two trailing names: the comma-separated list as written. + EXPECT_STREQ(SchDepOnTwo_Def.dependsOn, "SchNoop, SchWriteVel"); +} + +// --------------------------------------------------------------------------- +// The computed execution order (the stable topological sort) +// --------------------------------------------------------------------------- + +TEST(SystemScheduler, RegistrationOrderIsTheExecutionOrder) { + laige::World world = makeWorld(0); + ASSERT_TRUE(world.registerSystem(SchNoop_Def).ok()); + ASSERT_TRUE(world.registerSystem(SchWriteVel_Def).ok()); + ASSERT_TRUE(world.registerSystem(SchReadVel_Def).ok()); + laige::SystemSchedule sched; + ASSERT_TRUE(world.scheduleSystems(sched).ok()); + EXPECT_EQ(sched.systemCount, 3u); + EXPECT_EQ(sched.order[0], 1u); + EXPECT_EQ(sched.order[1], 2u); + EXPECT_EQ(sched.order[2], 3u); +} + +TEST(SystemScheduler, BackwardDependencyKeepsRegistrationOrder) { + // B depends on A: the registration order already satisfies the edge. + laige::World world = makeWorld(0); + ASSERT_TRUE(world + .registerSystem(makeDef("OrderA", &fnNoop, + laige::fpx16_16::fromInt32(1))) + .ok()); + ASSERT_TRUE(world + .registerSystem( + makeDef("OrderB", &fnNoop2, + laige::fpx16_16::fromInt32(1), "OrderA")) + .ok()); + laige::SystemSchedule sched; + ASSERT_TRUE(world.scheduleSystems(sched).ok()); + EXPECT_EQ(sched.order[0], 1u); + EXPECT_EQ(sched.order[1], 2u); +} + +TEST(SystemScheduler, ForwardDependencyReorders) { + // A depends on C (registered LAST): the stable sort places C (and + // the independent B) before A — A only moves later, behind its + // dependency. + laige::World world = makeWorld(0); + ASSERT_TRUE(world + .registerSystem(makeDef("ReorderA", &fnNoop, + laige::fpx16_16::fromInt32(1), + "ReorderC")) + .ok()); + ASSERT_TRUE(world + .registerSystem(makeDef("ReorderB", &fnNoop2, + laige::fpx16_16::fromInt32(1))) + .ok()); + ASSERT_TRUE(world + .registerSystem(makeDef("ReorderC", &fnNoop, + laige::fpx16_16::fromInt32(1))) + .ok()); + laige::SystemSchedule sched; + ASSERT_TRUE(world.scheduleSystems(sched).ok()); + EXPECT_EQ(sched.order[0], 2u); // B (the smallest available) + EXPECT_EQ(sched.order[1], 3u); // C + EXPECT_EQ(sched.order[2], 1u); // A, behind C +} + +TEST(SystemScheduler, DependencyChainReorders) { + // Registered Z -> Y -> X, but Z depends on Y and Y depends on X: + // the chain forces X, Y, Z. + laige::World world = makeWorld(0); + ASSERT_TRUE(world + .registerSystem(makeDef("ChainZ", &fnNoop, + laige::fpx16_16::fromInt32(1), + "ChainY")) + .ok()); + ASSERT_TRUE(world + .registerSystem(makeDef("ChainY", &fnNoop2, + laige::fpx16_16::fromInt32(1), + "ChainX")) + .ok()); + ASSERT_TRUE(world + .registerSystem(makeDef("ChainX", &fnNoop, + laige::fpx16_16::fromInt32(1))) + .ok()); + laige::SystemSchedule sched; + ASSERT_TRUE(world.scheduleSystems(sched).ok()); + EXPECT_EQ(sched.order[0], 3u); // X + EXPECT_EQ(sched.order[1], 2u); // Y + EXPECT_EQ(sched.order[2], 1u); // Z +} + +TEST(SystemScheduler, DiamondDependencyOrdersDependeeLast) { + // D is registered first but depends on B and C: D runs last. The + // spec list order ("DiamondC, DiamondB" — reversed) must not + // matter: the dependency set is orderless, the tie-break is the min + // id. + laige::World world = makeWorld(0); + ASSERT_TRUE(world + .registerSystem(makeDef("DiamondD", &fnNoop, + laige::fpx16_16::fromInt32(1), + "DiamondC, DiamondB")) + .ok()); + ASSERT_TRUE(world + .registerSystem(makeDef("DiamondB", &fnNoop2, + laige::fpx16_16::fromInt32(1))) + .ok()); + ASSERT_TRUE(world + .registerSystem(makeDef("DiamondC", &fnNoop, + laige::fpx16_16::fromInt32(1))) + .ok()); + laige::SystemSchedule sched; + ASSERT_TRUE(world.scheduleSystems(sched).ok()); + EXPECT_EQ(sched.order[0], 2u); // B + EXPECT_EQ(sched.order[1], 3u); // C + EXPECT_EQ(sched.order[2], 1u); // D, behind both +} + +TEST(SystemScheduler, MacroForwardDependencyReorders) { + // The macro-declared dependency (SchDepOnNoop lists SchNoop) is a + // forward dependency: registered first, it must run after SchNoop. + laige::World world = makeWorld(0); + ASSERT_TRUE(world.registerSystem(SchDepOnNoop_Def).ok()); + ASSERT_TRUE(world.registerSystem(SchNoop_Def).ok()); + laige::SystemSchedule sched; + ASSERT_TRUE(world.scheduleSystems(sched).ok()); + EXPECT_EQ(sched.order[0], 2u); // SchNoop (the dependency) + EXPECT_EQ(sched.order[1], 1u); // SchDepOnNoop + // And it actually runs in that order: + clearRunLog(); + ASSERT_TRUE(world.runSystems(sched).ok()); + ASSERT_EQ(runLogCount, 2u); + EXPECT_EQ(runLog[0], 1u); // SchNoop's token + EXPECT_EQ(runLog[1], 7u); // SchDepOnNoop's token +} + +// --------------------------------------------------------------------------- +// Running: strictly one system at a time, in schedule order +// --------------------------------------------------------------------------- + +TEST(SystemScheduler, RunExecutesInTheScheduledOrder) { + laige::World world = makeWorld(4); + ASSERT_TRUE(world.registerComponent().ok()); + auto e = world.create(); + ASSERT_TRUE(e.ok()); + ASSERT_TRUE(world.addComponent(e.value(), SchVel{0}).ok()); + // SchNoop declares no I/O (it is an ordering marker only): no + // declared read, so no read_before_write warn can fire here. + ASSERT_TRUE(world.registerSystem(SchNoop_Def).ok()); + ASSERT_TRUE(world + .registerSystem(SchWriteVel_Def, + laige::Io{}) + .ok()); + ASSERT_TRUE(world + .registerSystem(SchReadVel_Def, + laige::Io{}) + .ok()); + laige::SystemSchedule sched; + ASSERT_TRUE(world.scheduleSystems(sched).ok()); + clearRunLog(); + ASSERT_TRUE(world.runSystems(sched).ok()); + ASSERT_EQ(runLogCount, 3u); + EXPECT_EQ(runLog[0], 1u); // SchNoop + EXPECT_EQ(runLog[1], 2u); // SchWriteVel + EXPECT_EQ(runLog[2], 3u); // SchReadVel + // The writer ran before the reader: the reader saw the fresh value. + EXPECT_EQ(schReadVelSum, 0x1234u); +} + +// --------------------------------------------------------------------------- +// The declared-I/O pre-run validation +// --------------------------------------------------------------------------- + +TEST(SystemScheduler, ReaderBeforeWriterWarnsButSchedules) { + laige::World world = makeWorld(4); + ASSERT_TRUE(world.registerComponent().ok()); + auto e = world.create(); + ASSERT_TRUE(e.ok()); + ASSERT_TRUE(world.addComponent(e.value(), SchVel{0x9999}).ok()); + // The reader is registered FIRST: it will run before the writer. + ASSERT_TRUE(world + .registerSystem(SchReadVel_Def, + laige::Io{}) + .ok()); + ASSERT_TRUE(world + .registerSystem(SchWriteVel_Def, + laige::Io{}) + .ok()); + // The sink is installed AFTER the setup events (the G-R3 entity + // budget and archetype_created infos are world setup, not scheduler + // events): it sees only the scheduler's own diagnostics. + auto sink = std::make_unique(); + MemorySink* sinkPtr = sink.get(); + laige::log::LoggerOptions opts; + opts.sink = std::move(sink); + opts.rateWindow = std::chrono::seconds(60); + ASSERT_TRUE(laige::log::Logger::instance().init(std::move(opts)).ok()); + // A warn, not an error: the schedule succeeds. + laige::SystemSchedule sched; + ASSERT_TRUE(world.scheduleSystems(sched).ok()); + EXPECT_EQ(sched.order[0], 1u); + EXPECT_EQ(sched.order[1], 2u); + // The one warn identifies the reader, the writer, the component: + ASSERT_EQ(sinkPtr->entries.size(), 1u); + EXPECT_EQ(sinkPtr->entries[0].severity, laige::log::Severity::Warn); + EXPECT_EQ(sinkPtr->entries[0].subsystem, "system"); + EXPECT_EQ(sinkPtr->entries[0].event, "read_before_write"); + EXPECT_STREQ(fieldValue(sinkPtr->entries[0], "reader"), "SchReadVel"); + EXPECT_STREQ(fieldValue(sinkPtr->entries[0], "writer"), "SchWriteVel"); + EXPECT_STREQ(fieldValue(sinkPtr->entries[0], "component_id"), "1"); + // And the consequence is observable: the reader ran first and read + // the previous tick's value (0x9999), not the write (0x1234). + clearRunLog(); + ASSERT_TRUE(world.runSystems(sched).ok()); + ASSERT_EQ(runLogCount, 2u); + EXPECT_EQ(runLog[0], 3u); // SchReadVel + EXPECT_EQ(runLog[1], 2u); // SchWriteVel + EXPECT_EQ(schReadVelSum, 0x9999u); // the stale value + + laige::log::Logger::instance().shutdown(); + restoreConsoleSink(); +} + +TEST(SystemScheduler, WriterBeforeReaderIsClean) { + // The opposite order (writer first) is the healthy pattern: no warn. + laige::World world = makeWorld(4); + ASSERT_TRUE(world.registerComponent().ok()); + auto e = world.create(); + ASSERT_TRUE(e.ok()); + ASSERT_TRUE(world.addComponent(e.value(), SchVel{0}).ok()); + ASSERT_TRUE(world + .registerSystem(SchWriteVel_Def, + laige::Io{}) + .ok()); + ASSERT_TRUE(world + .registerSystem(SchReadVel_Def, + laige::Io{}) + .ok()); + // The sink is installed AFTER the setup events (the G-R3 entity + // budget and archetype_created infos are world setup, not scheduler + // events): it must stay EMPTY — the success path logs nothing. + auto sink = std::make_unique(); + MemorySink* sinkPtr = sink.get(); + laige::log::LoggerOptions opts; + opts.sink = std::move(sink); + opts.rateWindow = std::chrono::seconds(60); + ASSERT_TRUE(laige::log::Logger::instance().init(std::move(opts)).ok()); + laige::SystemSchedule sched; + ASSERT_TRUE(world.scheduleSystems(sched).ok()); + // Nothing logged on the success path (LOG-003) — and no + // read_before_write (the reader is ordered after the writer). + EXPECT_EQ(sinkPtr->entries.size(), 0u); + clearRunLog(); + ASSERT_TRUE(world.runSystems(sched).ok()); + EXPECT_EQ(schReadVelSum, 0x1234u); // the fresh value + + laige::log::Logger::instance().shutdown(); + restoreConsoleSink(); +} + +TEST(SystemScheduler, DoubleWriterRejectedWarnsOnceThenRateLimited) { + auto sink = std::make_unique(); + MemorySink* sinkPtr = sink.get(); + laige::log::LoggerOptions opts; + opts.sink = std::move(sink); + opts.rateWindow = std::chrono::seconds(60); // the burst stays in-window + ASSERT_TRUE(laige::log::Logger::instance().init(std::move(opts)).ok()); + + laige::World world = makeWorld(4); + ASSERT_TRUE(world.registerComponent().ok()); + ASSERT_TRUE(world + .registerSystem(SchWriteTag_Def, + laige::Io{}) + .ok()); + ASSERT_TRUE(world + .registerSystem(SchWriteTag2_Def, + laige::Io{}) + .ok()); + // Two writers of the same component in one tick: a scheduling error + // (FR-12.3: fail loudly, never silent). + laige::SystemSchedule sched; + for (int i = 0; i < 3; ++i) { + auto s = world.scheduleSystems(sched); + EXPECT_FALSE(s.ok()); + if (s.isError()) { + EXPECT_EQ(s.error(), laige::ErrorCode::InvalidArgument); + } + } + // The first failure emits the warn with the identifying fields + // (LOG-002); the repeats are rate-limited (LOG-004) and surface as + // the rate_limited summary on shutdown. + ASSERT_EQ(sinkPtr->entries.size(), 1u); + EXPECT_EQ(sinkPtr->entries[0].severity, laige::log::Severity::Warn); + EXPECT_EQ(sinkPtr->entries[0].subsystem, "system"); + EXPECT_EQ(sinkPtr->entries[0].event, "double_writer"); + EXPECT_STREQ(fieldValue(sinkPtr->entries[0], "component_id"), "1"); + EXPECT_STREQ(fieldValue(sinkPtr->entries[0], "first_writer"), + "SchWriteTag"); + EXPECT_STREQ(fieldValue(sinkPtr->entries[0], "second_writer"), + "SchWriteTag2"); + laige::log::Logger::instance().shutdown(); + EXPECT_EQ(sinkPtr->entries.size(), 2u); + EXPECT_EQ(sinkPtr->entries[1].event, laige::log::kRateLimitedEvent); + EXPECT_STREQ(fieldValue(sinkPtr->entries[1], "suppressed"), "2"); + restoreConsoleSink(); +} + +// --------------------------------------------------------------------------- +// The depends_on graph validation (missing names, cycles) +// --------------------------------------------------------------------------- + +TEST(SystemScheduler, MissingDependencyRejected) { + auto sink = std::make_unique(); + MemorySink* sinkPtr = sink.get(); + laige::log::LoggerOptions opts; + opts.sink = std::move(sink); + opts.rateWindow = std::chrono::seconds(60); + ASSERT_TRUE(laige::log::Logger::instance().init(std::move(opts)).ok()); + + laige::World world = makeWorld(0); + ASSERT_TRUE(world + .registerSystem(makeDef("GhostHunt", &fnNoop, + laige::fpx16_16::fromInt32(1), + "GhostSystem")) + .ok()); + laige::SystemSchedule sched; + auto s = world.scheduleSystems(sched); + EXPECT_FALSE(s.ok()); + if (s.isError()) { + EXPECT_EQ(s.error(), laige::ErrorCode::InvalidArgument); + } + // The warn names the system and the missing name (LOG-002): + ASSERT_EQ(sinkPtr->entries.size(), 1u); + EXPECT_EQ(sinkPtr->entries[0].event, "dep_missing"); + EXPECT_STREQ(fieldValue(sinkPtr->entries[0], "system"), "GhostHunt"); + EXPECT_STREQ(fieldValue(sinkPtr->entries[0], "missing_dep"), + "GhostSystem"); + laige::log::Logger::instance().shutdown(); + restoreConsoleSink(); +} + +TEST(SystemScheduler, DependencyCycleRejected) { + auto sink = std::make_unique(); + MemorySink* sinkPtr = sink.get(); + laige::log::LoggerOptions opts; + opts.sink = std::move(sink); + opts.rateWindow = std::chrono::seconds(60); + ASSERT_TRUE(laige::log::Logger::instance().init(std::move(opts)).ok()); + + laige::World world = makeWorld(0); + ASSERT_TRUE(world + .registerSystem(makeDef("CycA", &fnNoop, + laige::fpx16_16::fromInt32(1), + "CycB")) + .ok()); + ASSERT_TRUE(world + .registerSystem(makeDef("CycB", &fnNoop2, + laige::fpx16_16::fromInt32(1), + "CycA")) + .ok()); + laige::SystemSchedule sched; + auto s = world.scheduleSystems(sched); + EXPECT_FALSE(s.ok()); + if (s.isError()) { + EXPECT_EQ(s.error(), laige::ErrorCode::InvalidArgument); + } + // The warn reports one concrete cycle (walk order from the smallest + // remaining id): CycA -> CycB -> CycA. + ASSERT_EQ(sinkPtr->entries.size(), 1u); + EXPECT_EQ(sinkPtr->entries[0].event, "dependency_cycle"); + EXPECT_STREQ(fieldValue(sinkPtr->entries[0], "cycle"), "CycA,CycB"); + laige::log::Logger::instance().shutdown(); + restoreConsoleSink(); +} + +TEST(SystemScheduler, SelfDependencyIsACycle) { + laige::World world = makeWorld(0); + ASSERT_TRUE(world + .registerSystem(makeDef("CycSelf", &fnNoop, + laige::fpx16_16::fromInt32(1), + "CycSelf")) + .ok()); + laige::SystemSchedule sched; + auto s = world.scheduleSystems(sched); + EXPECT_FALSE(s.ok()); + if (s.isError()) { + EXPECT_EQ(s.error(), laige::ErrorCode::InvalidArgument); + } +} + +TEST(SystemScheduler, CycleReportedAmongIndependentSystems) { + // A and D are not part of the cycle: A schedules first (no deps), + // D after A. The reported cycle is the concrete B <-> C pair only — + // the deterministic walk starts at the smallest REMAINING id (B), + // not at A (which is already scheduled). + auto sink = std::make_unique(); + MemorySink* sinkPtr = sink.get(); + laige::log::LoggerOptions opts; + opts.sink = std::move(sink); + opts.rateWindow = std::chrono::seconds(60); + ASSERT_TRUE(laige::log::Logger::instance().init(std::move(opts)).ok()); + + laige::World world = makeWorld(0); + ASSERT_TRUE(world + .registerSystem(makeDef("CycA", &fnNoop, + laige::fpx16_16::fromInt32(1))) + .ok()); + ASSERT_TRUE(world + .registerSystem(makeDef("CycB", &fnNoop2, + laige::fpx16_16::fromInt32(1), + "CycC")) + .ok()); + ASSERT_TRUE(world + .registerSystem(makeDef("CycC", &fnNoop, + laige::fpx16_16::fromInt32(1), + "CycB")) + .ok()); + ASSERT_TRUE(world + .registerSystem(makeDef("CycD", &fnNoop2, + laige::fpx16_16::fromInt32(1), + "CycA")) + .ok()); + laige::SystemSchedule sched; + auto s = world.scheduleSystems(sched); + EXPECT_FALSE(s.ok()); + if (s.isError()) { + EXPECT_EQ(s.error(), laige::ErrorCode::InvalidArgument); + } + ASSERT_EQ(sinkPtr->entries.size(), 1u); + EXPECT_EQ(sinkPtr->entries[0].event, "dependency_cycle"); + EXPECT_STREQ(fieldValue(sinkPtr->entries[0], "cycle"), "CycB,CycC"); + laige::log::Logger::instance().shutdown(); + restoreConsoleSink(); +} + +// --------------------------------------------------------------------------- +// The depends_on spec validation at registration (the def-level form) +// --------------------------------------------------------------------------- + +TEST(SystemScheduler, DepSpecEmptyTokenRejected) { + auto sink = std::make_unique(); + MemorySink* sinkPtr = sink.get(); + laige::log::LoggerOptions opts; + opts.sink = std::move(sink); + opts.rateWindow = std::chrono::seconds(60); + ASSERT_TRUE(laige::log::Logger::instance().init(std::move(opts)).ok()); + + laige::World world = makeWorld(0); + auto r = world.registerSystem(makeDef("BadSpec", &fnNoop, + laige::fpx16_16::fromInt32(1), + "TrimA, , TrimB")); + EXPECT_FALSE(r.ok()); + if (r.isError()) { + EXPECT_EQ(r.error(), laige::ErrorCode::InvalidArgument); + } + // The world's registry is unchanged (the validation runs before the + // record is written): + EXPECT_EQ(world.systemCount(), 0u); + ASSERT_EQ(sinkPtr->entries.size(), 1u); + EXPECT_EQ(sinkPtr->entries[0].event, "dep_spec_invalid"); + EXPECT_STREQ(fieldValue(sinkPtr->entries[0], "name"), "BadSpec"); + EXPECT_STREQ(fieldValue(sinkPtr->entries[0], "error"), "empty_token"); + laige::log::Logger::instance().shutdown(); + restoreConsoleSink(); +} + +TEST(SystemScheduler, DepSpecTrailingCommaRejected) { + laige::World world = makeWorld(0); + auto r = world.registerSystem(makeDef("TrailingComma", &fnNoop, + laige::fpx16_16::fromInt32(1), + "TrimA,")); + EXPECT_FALSE(r.ok()); + if (r.isError()) { + EXPECT_EQ(r.error(), laige::ErrorCode::InvalidArgument); + } + EXPECT_EQ(world.systemCount(), 0u); +} + +TEST(SystemScheduler, DepSpecDuplicateRejected) { + laige::World world = makeWorld(0); + auto r = world.registerSystem(makeDef("DupDep", &fnNoop, + laige::fpx16_16::fromInt32(1), + "TrimA, TrimA")); + EXPECT_FALSE(r.ok()); + if (r.isError()) { + EXPECT_EQ(r.error(), laige::ErrorCode::InvalidArgument); + } + EXPECT_EQ(world.systemCount(), 0u); +} + +TEST(SystemScheduler, DepSpecTooManyRejected) { + // kMaxSystemDependencies + 1 distinct names: the bound is honored. + std::string spec; + for (std::uint32_t i = 0; i < laige::kMaxSystemDependencies + 1; ++i) { + if (i != 0) spec += ", "; + spec += bulkName(i); + } + laige::World world = makeWorld(0); + auto r = world.registerSystem(makeDef("TooMany", &fnNoop, + laige::fpx16_16::fromInt32(1), + spec.c_str())); + EXPECT_FALSE(r.ok()); + if (r.isError()) { + EXPECT_EQ(r.error(), laige::ErrorCode::InvalidArgument); + } + EXPECT_EQ(world.systemCount(), 0u); +} + +TEST(SystemScheduler, DepSpecWhitespaceTrimmed) { + // Whitespace around the names is legal (the macro stringizes the + // list as written); the trimmed names resolve against the world. + laige::World world = makeWorld(0); + ASSERT_TRUE(world + .registerSystem(makeDef("TrimA", &fnNoop, + laige::fpx16_16::fromInt32(1))) + .ok()); + ASSERT_TRUE(world + .registerSystem(makeDef("TrimB", &fnNoop2, + laige::fpx16_16::fromInt32(1))) + .ok()); + ASSERT_TRUE(world + .registerSystem( + makeDef("TrimC", &fnNoop, + laige::fpx16_16::fromInt32(1), + " TrimA , TrimB ")) + .ok()); + laige::SystemSchedule sched; + ASSERT_TRUE(world.scheduleSystems(sched).ok()); + EXPECT_EQ(sched.order[0], 1u); // TrimA + EXPECT_EQ(sched.order[1], 2u); // TrimB + EXPECT_EQ(sched.order[2], 3u); // TrimC, behind both +} + +// --------------------------------------------------------------------------- +// Schedule lifetime and malformed schedules +// --------------------------------------------------------------------------- + +TEST(SystemScheduler, EmptyWorldSchedulesAndRunsEmpty) { + laige::World world = makeWorld(0); + laige::SystemSchedule sched; + ASSERT_TRUE(world.scheduleSystems(sched).ok()); + EXPECT_EQ(sched.systemCount, 0u); + ASSERT_TRUE(world.runSystems(sched).ok()); + clearRunLog(); + EXPECT_EQ(runLogCount, 0u); + // A moved-from world is a valid empty world (the registry is gone): + laige::World moved = std::move(world); + laige::SystemSchedule empty; + ASSERT_TRUE(moved.scheduleSystems(empty).ok()); + EXPECT_EQ(empty.systemCount, 0u); + ASSERT_TRUE(moved.runSystems(empty).ok()); +} + +TEST(SystemScheduler, StaleScheduleRejected) { + laige::World world = makeWorld(0); + ASSERT_TRUE(world.registerSystem(SchNoop_Def).ok()); + laige::SystemSchedule sched; + ASSERT_TRUE(world.scheduleSystems(sched).ok()); + // A system registered AFTER the schedule was computed: the old + // schedule no longer describes the registry. + ASSERT_TRUE(world + .registerSystem(makeDef("LateSystem", &fnNoop, + laige::fpx16_16::fromInt32(1))) + .ok()); + auto s = world.runSystems(sched); + EXPECT_FALSE(s.ok()); + if (s.isError()) { + EXPECT_EQ(s.error(), laige::ErrorCode::InvalidArgument); + } + clearRunLog(); + EXPECT_EQ(runLogCount, 0u); // nothing ran + // The recomputed schedule is usable: + ASSERT_TRUE(world.scheduleSystems(sched).ok()); + EXPECT_EQ(sched.systemCount, 2u); + ASSERT_TRUE(world.runSystems(sched).ok()); + EXPECT_EQ(runLogCount, 1u); +} + +TEST(SystemScheduler, InvalidScheduleRejected) { + laige::World world = makeWorld(0); + ASSERT_TRUE(world.registerSystem(SchNoop_Def).ok()); + ASSERT_TRUE(world + .registerSystem(makeDef("Second", &fnNoop2, + laige::fpx16_16::fromInt32(1))) + .ok()); + // A duplicate id in the order (the count matches the registry, so + // the failure is the order validation, not the staleness check): + laige::SystemSchedule dup; + dup.systemCount = 2; + dup.order[0] = 1; + dup.order[1] = 1; + auto s1 = world.runSystems(dup); + EXPECT_FALSE(s1.ok()); + if (s1.isError()) { + EXPECT_EQ(s1.error(), laige::ErrorCode::InvalidArgument); + } + // An id above the count: + laige::SystemSchedule big; + big.systemCount = 2; + big.order[0] = 1; + big.order[1] = 5; + auto s2 = world.runSystems(big); + EXPECT_FALSE(s2.ok()); + if (s2.isError()) { + EXPECT_EQ(s2.error(), laige::ErrorCode::InvalidArgument); + } + // Nothing ran: + clearRunLog(); + EXPECT_EQ(runLogCount, 0u); +} + +// --------------------------------------------------------------------------- +// Determinism (ARCH-010) and the known-answer pin +// --------------------------------------------------------------------------- + +// The shared registration sequence (two worlds, same order and +// specs — the ARCH-010 comparison input). +bool buildStableWorld(laige::World& world) { + if (!world.registerSystem(SchDepOnTwo_Def).ok()) return false; + if (!world + .registerSystem(makeDef("StableX", &fnNoop, + laige::fpx16_16::fromInt32(1))) + .ok()) { + return false; + } + if (!world.registerSystem(SchNoop_Def).ok()) return false; + if (!world.registerSystem(SchWriteVel_Def).ok()) return false; + return world + .registerSystem(makeDef("StableY", &fnNoop2, + laige::fpx16_16::fromInt32(1), + "StableX, SchNoop")) + .ok(); +} + +TEST(SystemScheduler, ScheduleIdenticalAcrossTwoWorlds) { + laige::World w1 = makeWorld(0); + laige::World w2 = makeWorld(0); + ASSERT_TRUE(buildStableWorld(w1)); + ASSERT_TRUE(buildStableWorld(w2)); + laige::SystemSchedule s1; + laige::SystemSchedule s2; + ASSERT_TRUE(w1.scheduleSystems(s1).ok()); + ASSERT_TRUE(w2.scheduleSystems(s2).ok()); + EXPECT_EQ(s1.systemCount, s2.systemCount); + EXPECT_EQ(s1.systemCount, 5u); + // Bit-identical orders (the pure-function-of-registry property): + EXPECT_EQ(std::memcmp(s1.order, s2.order, + s1.systemCount * sizeof(s1.order[0])), + 0); +} + +TEST(SystemScheduler, KnownAnswerScheduleOrder) { + // The fixed scenario (the KAT; machine-greppable line on every run): + // 1. KatA depends on KatB (a FORWARD dependency: A is + // registered before B and must run after it) + // 2. KatB (no dependencies) + // 3. KatC depends on KatB, KatA + // 4. KatD (no dependencies) + // 5. KatE depends on KatD, KatC + // The stable sort places B, A, C, D, E (min-id tie-break: the + // forward edge moves A behind B; nothing else reorders). + laige::World world = makeWorld(0); + ASSERT_TRUE(world + .registerSystem(makeDef("KatA", &fnNoop, + laige::fpx16_16::fromInt32(1), + "KatB")) + .ok()); + ASSERT_TRUE(world + .registerSystem(makeDef("KatB", &fnNoop2, + laige::fpx16_16::fromInt32(1))) + .ok()); + ASSERT_TRUE(world + .registerSystem(makeDef("KatC", &fnNoop, + laige::fpx16_16::fromInt32(1), + "KatB, KatA")) + .ok()); + ASSERT_TRUE(world + .registerSystem(makeDef("KatD", &fnNoop2, + laige::fpx16_16::fromInt32(1))) + .ok()); + ASSERT_TRUE(world + .registerSystem(makeDef("KatE", &fnNoop, + laige::fpx16_16::fromInt32(1), + "KatD, KatC")) + .ok()); + laige::SystemSchedule sched; + ASSERT_TRUE(world.scheduleSystems(sched).ok()); + std::uint64_t words[5]; + for (std::uint32_t i = 0; i < 5; ++i) { + words[i] = sched.order[i]; + } + const std::uint64_t hash = fnv1a64(words, 5); + std::printf("scheduler-order systems=%u fnv1a=0x%016llx\n", + sched.systemCount, static_cast(hash)); + // The exact order (not just the hash): B, A, C, D, E. + EXPECT_EQ(sched.order[0], 2u); + EXPECT_EQ(sched.order[1], 1u); + EXPECT_EQ(sched.order[2], 3u); + EXPECT_EQ(sched.order[3], 4u); + EXPECT_EQ(sched.order[4], 5u); + // The pinned KAT hash (docs/testing.md §4; byte-identical across + // trees and platforms — pure integer order, no addresses). + EXPECT_EQ(hash, 0xaef3282f393ab332ull); +} + +// --------------------------------------------------------------------------- +// Zero allocation: scheduling and the per-tick run (PERF-003) +// --------------------------------------------------------------------------- + +#if defined(LAIGE_ALLOC_COUNTER) +TEST(SystemScheduler, SchedulingAndTicksAllocateNothing) { + // The schedule is fixed-size stack/world state and runSystems builds + // a trivial SystemContext per system: nothing in the window may + // touch the heap. + laige::World world = makeWorld(8); + ASSERT_TRUE(world.registerComponent().ok()); + ASSERT_TRUE(world.registerComponent().ok()); + for (std::uint32_t i = 0; i < 4; ++i) { + auto e = world.create(); + ASSERT_TRUE(e.ok()); + ASSERT_TRUE(world.addComponent(e.value(), SchVel{0}).ok()); + ASSERT_TRUE(world.addComponent(e.value(), SchPos{1, 1}).ok()); + } + ASSERT_TRUE(world + .registerSystem(SchWriteVel_Def, + laige::Io{}) + .ok()); + ASSERT_TRUE(world + .registerSystem(SchReadVel_Def, + laige::Io{}) + .ok()); + ASSERT_TRUE(world + .registerSystem(SchReadPos_Def, + laige::Io{}) + .ok()); + laige::SystemSchedule sched; + laige::test::resetAllocCounter(); + ASSERT_TRUE(world.scheduleSystems(sched).ok()); + for (int tick = 0; tick < 100; ++tick) { + world.beginFrame(); + ASSERT_TRUE(world.runSystems(sched).ok()); + } + // Every tick: the writer ran before the readers — 4 entities each + // reading the fresh 0x1234, and x=1 for each of the 4 Pos carriers. + EXPECT_EQ(schReadVelSum, 4u * 0x1234u); + EXPECT_EQ(schReadPosSum, 4u); + const std::uint64_t allocs = laige::test::allocCounter(); + std::printf("scheduler-zeroalloc ticks=100 allocs=%llu\n", + static_cast(allocs)); + EXPECT_EQ(allocs, 0u); +} +#endif