From 3cf8f91f93265337f4ce503284a28dff5eaa2943 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Mon, 14 Sep 2026 12:31:32 +0200 Subject: [PATCH 1/2] [M1-ECS-05] Deterministic iteration order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The documented iteration contract over the World::each visit order: - visit order pinned: archetypes in ascending archetype id (= the order a component set is first seen by a mutation — creation order), entities within an archetype in ascending slot id, empty query ascending slot id; the order is a pure function of the world state, never of the operation history - dense-id-order scheme documented: rows stay in ascending slot-id order through insert/remove (binary search + memmove shift, archetype.h invariants I1-I4); two histories converging on the same state therefore visit identically - no unordered containers in the iteration path: the iteration touches only the fixed 256-record archetype table (id-ordered scan), packed slot columns, per-slot direct-index records, and membership-only 256-bit guard sets — the entity->archetype map the step anticipated is direct indexing, not even a hash (a stricter reading of the allowance); the one hash structure in laige-sim (the component type-key index) is lookup-only and never iterated, and sits on the setup path, not any tick - convergence property test: two worlds whose operation sequences interleave create/destroy differently (serial scripts + end destroys vs. PRNG-scattered dead-destroys + round-robin component steps + PRNG-placed scratch pairs) converge on the identical final state — including the entity->id assignment — and iterate identically for five queries, verified per-visit against an independent oracle (free-list simulation + first-seen archetype order); component moves are structural throughout (adds of absent / removes of present components), so the dense-id scheme is pinned, not assumed - KAT test pins the exact degenerate visit sequences; machine-greppable iter-order lines record seed, PRNG pair count, visit counts, and the FNV-1a hash of the visit sequences — byte-identical across g++/Clang/ASan/TSan/release trees, default and LAIGE_TEST_SEED-overridden seeds New: tests/laige-sim/iter_order_tests.cpp (IterOrder.* in the shared laige-sim_tests executable), CTest entry iter_order (the step's Verify command; added to the TSan property list); no public API added — laige-api.json unchanged (452 symbols, scanner rerun clean); no src include-graph change (comments only; include lint clean). Docs: docs/api/iteration_order.md (the contract: order, dense-id scheme, no-unordered-containers table, convergence property, ARCH-010 determinism scope, Performance section per DOC-004) + cross-refs in query.md/entity.md/archetype.md/ component_registry.md, docs/README.md, src/laige-sim/README.md; roadmap checkbox + progress board updated. --- docs/README.md | 11 +- docs/api/archetype.md | 7 +- docs/api/component_registry.md | 5 +- docs/api/entity.md | 5 +- docs/api/iteration_order.md | 218 ++++++ docs/api/query.md | 20 +- roadmap/M1-heartbeat.md | 2 +- roadmap/README.md | 4 +- src/laige-sim/README.md | 12 +- tests/laige-sim/CMakeLists.txt | 28 +- tests/laige-sim/iter_order_tests.cpp | 963 +++++++++++++++++++++++++++ 11 files changed, 1243 insertions(+), 32 deletions(-) create mode 100644 docs/api/iteration_order.md create mode 100644 tests/laige-sim/iter_order_tests.cpp diff --git a/docs/README.md b/docs/README.md index 697e224..7d011ff 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,7 +4,8 @@ Documentation index and navigation (DOC-001). The engine is at **M1** (heartbeat): `laige-core` holds the M0 foundations, and `laige-sim` has started (M1-ECS-01: the entity handle and world entity storage; M1-ECS-02: the component type registry; M1-ECS-03: archetype SoA -component storage; M1-ECS-04: the query API + iteration legality). +component storage; M1-ECS-04: the query API + iteration legality; +M1-ECS-05: the deterministic iteration contract). Every section of the AGENTS §13 `docs/` tree exists; each entry below links what is written and the "not yet written" section marks what is still to land. @@ -43,6 +44,11 @@ still to land. rows: superset match, per-component access, the stack-scoped iteration-legality guard, and the 10k-entity zero-allocation window (M1-ECS-04; `laige-sim`). +- [Deterministic iteration order](api/iteration_order.md) — the + `World::each` visit contract: archetypes in first-seen (creation) + order, entities in ascending slot id, the dense-id-order scheme + under moves, no unordered containers in the iteration path, and the + convergence property test (M1-ECS-05; `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, @@ -130,7 +136,8 @@ still to land. [entity.md](api/entity.md), [component_registry.md](api/component_registry.md), [archetype.md](api/archetype.md), - [query.md](api/query.md).) + [query.md](api/query.md), + [iteration_order.md](api/iteration_order.md).) ## Related diff --git a/docs/api/archetype.md b/docs/api/archetype.md index b3539ed..816ef8d 100644 --- a/docs/api/archetype.md +++ b/docs/api/archetype.md @@ -231,9 +231,10 @@ world.removeComponent(e.value()); // component-less, still alive `World::each` with per-component `Read`/`Write` access and the stack-scoped iteration-legality guard — see [query.md](query.md). -- **M1-ECS-05:** the deterministic iteration contract over the stored - row order (the slot-ordered scheme above is what the query - iterates). +- **M1-ECS-05 (done):** the deterministic iteration contract over + the stored row order (the slot-ordered scheme above is what the + query iterates) — see + [iteration_order.md](iteration_order.md). - **M1-ECS-06:** the G-R3 warn thresholds read the same slot tables. - **M1-PROF-01 / G-R4:** `archetypeStats()` feeds the profiler. - **M1-ALLOC-01:** the standing zero-allocation assertion over the diff --git a/docs/api/component_registry.md b/docs/api/component_registry.md index 25d4428..f9685ef 100644 --- a/docs/api/component_registry.md +++ b/docs/api/component_registry.md @@ -200,8 +200,9 @@ if (pos.isError()) { - **M1-ECS-04 (done):** the query API resolves the listed component types to their ids (`world.each` — unregistered types match nothing) — see [query.md](query.md). -- **M1-ECS-05:** deterministic iteration orders the component sets by - registration order (`operator<`). +- **M1-ECS-05 (done):** deterministic iteration visits the component + sets (archetypes) in first-seen (creation) order — see + [iteration_order.md](iteration_order.md). - **M1-SYS-01:** system I/O declarations reference these ids. - **M1-DET-02:** the replay header's component-schema hash is computed from the registered (id, size, alignment) triples in id order. diff --git a/docs/api/entity.md b/docs/api/entity.md index d92882e..b02cea5 100644 --- a/docs/api/entity.md +++ b/docs/api/entity.md @@ -183,7 +183,8 @@ if (!world.isValid(handle)) { /* stale — drop it, log if unexpected */ } `World::each(fn, Read/Write tags...)` iterates the archetype rows with a stack-scoped guard (no hidden allocations); see [query.md](query.md). -- **M1-ECS-05:** deterministic iteration (archetype order, entity id - order — PRD §10.3) over the visit order M1-ECS-04 pins. +- **M1-ECS-05 (done):** deterministic iteration (archetype order, + entity id order — PRD §10.3) over the visit order M1-ECS-04 pins — + see [iteration_order.md](iteration_order.md). - **M1-ECS-06:** the G-R3 warn thresholds (25%/50%/100% of the declared budget) pull `stats()`. diff --git a/docs/api/iteration_order.md b/docs/api/iteration_order.md new file mode 100644 index 0000000..e27f292 --- /dev/null +++ b/docs/api/iteration_order.md @@ -0,0 +1,218 @@ +# Deterministic iteration order (`World::each` visit contract) + +The deterministic iteration contract (M1-ECS-05; PRD §10.3, ARCH-010, +AGENTS PERF-006, CORE-001). This document pins the *visit order* of +the query API — the order in which `World::each(fn, +tags...)` invokes `fn` — and proves, by property test, that the order +is a **pure function of the world state**, never of the operation +history that produced it. The API itself is [query.md](query.md); +the storage whose rows are visited is +[archetype.md](archetype.md). + +Deterministic replays (M1-DET-02/04) and bit-exact state hashes +(M1-DET-03) build directly on this contract: two runs that reach the +same state must visit the same rows in the same order, on every +platform, or the replay and hash machinery have nothing to pin. + +## The contract + +`World::each` (superset match; unregistered types match +nothing — [query.md](query.md)) visits its matches in exactly this +order: + +1. **Archetypes in ascending archetype id.** The id is assigned + dense from 1, in the order a component set is **first seen** by a + component mutation (`addComponent`/`removeComponent` moving an + entity into a set not yet stored) — i.e. creation order, *not* + the component-type registration order. A set seen first at tick 0 + has a smaller id than a set seen first at tick 1, regardless of + how many entities ever carry it. (Archetypes are never destroyed; + an empty archetype keeps its id and simply contributes no rows.) +2. **Entities within an archetype in ascending entity slot id** — + the dense-id order of the row storage (below). +3. **The empty query** (`each<>`) iterates no archetype rows: it + visits every live entity (component-less ones included) in + ascending slot id, with no archetype grouping. + +Concretely, for a query `Q`, the visit sequence is: + +```text +for each archetype a in ascending id, with Q ⊆ set(a): + for each live entity e in a, in ascending slot id: + fn(e, ) +``` + +The order depends only on: which slots are live, which archetype each +live slot is in, and the archetype id assignment. Nothing else — no +creation order of the entities, no operation history, no per-process +addresses, no randomness. + +## The dense-id order scheme (row order under moves) + +Rows of every archetype are kept in **ascending slot-id order** +(archetype.h, invariant I2): `slotCol_a[r]` is strictly ascending in +`r`. The scheme survives every move the storage supports: + +- **insertion** (an entity entering the archetype by + `addComponent`, or by `removeComponent` into a smaller set): the + row is binary-searched into the ascending slot column and the tail + is `memmove`d — the entity keeps its slot id and lands exactly where + the ascending order requires; +- **removal** (an entity leaving by `removeComponent` to a smaller + set, by `destroy`, or by `clear`): the row is shifted out and the + tail moves left — every remaining row keeps its slot id and its + relative order; +- **in-place `addComponent`** (the component is already present): no + row movement at all. + +Because a row's position is always the rank of its slot id among the +archetype's live slots, the row layout after any sequence of +moves is the same layout the final state alone determines. Two +histories that converge on the same live slots, archetype +assignment, and archetype id assignment therefore produce +bit-identical visit sequences — the convergence property pinned by +the `iter_order` suite below. + +## No unordered containers in the iteration path + +PRD §10.3 bans unordered containers in sim hot paths ("hash tables +use deterministic hash + fixed iteration, or are banned in sim"). +The iteration path touches exactly: + +| Structure | Access in the visit path | Determinism | +|---|---|---| +| the archetype table (fixed 256 records) | scanned in ascending table (= id) order before the first callback | pure table order | +| each archetype's packed `slotCol` + component columns | one sequential walk, ascending slot order | stored-data order (the dense-id scheme) | +| the per-slot record tables (`archetypeOf_`, `rowOf_`) | direct index by the 16-bit slot id | pure index | +| the iteration-guard sets (`detail::IdSet256`) | membership tests only — **never iterated** | fixed 4 × 64-bit words | + +The **entity→archetype map** the step anticipated as "the one +internal hash structure" is *not a hash at all*: it is the per-slot +direct index above (archetype.h) — one table load, no hash, nothing +unordered to iterate. That is strictly stronger than the allowance. + +The **only hash structure in `laige-sim`** is the component type-key +index (component.h): an open-addressing table mapping a +compile-time type-identity token to the world's `ComponentTypeId`, +hashed by a deterministic `splitmix64` of the token's address value. +It is **never iterated** — lookup and insert only, correctness rests +on key equality — so its per-process layout (the address input is +ASLR-dependent) is not observable and contributes nothing to the +visit order. It is a setup-path structure +(`registerComponent`), not part of any tick. + +The structural ban is verified here by inspection and will be +enforced in CI by the M1-DET-01 sim source scan; the observable +consequence — order purity — is what the suite pins at runtime. + +## Convergence property and its test + +**Property:** two worlds that converge on the same final state — +same live slots, same entity→slot (id) assignment, same archetype +assignment, same archetype id assignment, same component values — +iterate identically: every query returns the same visit sequence +(handles and component values) in both worlds. + +`tests/laige-sim/iter_order_tests.cpp` (CTest `iter_order`; suites +`IterOrder.*`) pins it with a fixed PRNG seed (docs/testing.md §4 — +`TestPrng` substreams 1005-1007; override via `LAIGE_TEST_SEED`): + +- **Scenario:** a 20-slot world, 16 logical entities (entity `i` + takes slot `19-i` — the LIFO free list), dead set `i % 5 == 2`, + three component types registered A, B, C. Every entity runs a + component script that moves it between the archetypes + `{A} → {A,B} → {A,B,C} → …` (adds of absent components, removes of + present ones — rows churn between archetypes on every step); one + live entity ends component-less. +- **Sequence A (serial):** create all 16; every entity runs its + script in turn (the dead ones hold their rows until the final + destroy); the dead entities are destroyed last, in index order. +- **Sequence B (interleaved):** the *same* create phase — the LIFO + free list requires it, and it is what pins the identical + entity→id assignment (an earlier dead-slot destroy would have + pushed the slot back on top of the free list). The difference is + *when* the dead destroys happen (PRNG-assigned round boundaries, + scattered through the component phase instead of at its end), the + component steps run **round-robin** over the live entities in + per-round PRNG permutations, and PRNG-placed component-less + scratch create/destroy pairs ride the round boundaries. +- **Oracle:** an independent model recomputes, per sequence, the + final state (free-list simulation + per-entity sets/values) and + the first-seen archetype order, and derives the expected visit + sequences for five queries: `each<>`, `each`, `each`, + `each`, `each`. +- **Assertions:** world A's and world B's observed visits equal + each other *and* the oracle, per visit (slot, generation, and each + queried component's value); the entity→slot assignment matches the + oracle for every live entity; archetype 1's visit group is a + strictly ascending-slot prefix, archetype 2's a strictly + ascending suffix, and the boundary is *not* a global slot order + (archetype 2 opens at slot 4, below archetype 1's top slot 19 — + so the test distinguishes archetype order from a globally + slot-sorted walk). + +The only state the two sequences may differ in is bookkeeping the +iteration cannot observe: the churn counters, the free-list *order* +of the pushed dead slots (the slot a hypothetical *next* `create()` +would pop), and the generation counters of never-created slots a +scratch pair pops (dead slots, handle-generation bookkeeping). The +suite's machine-greppable `iter-order … fnv1a=0x…` line records the +seed, the PRNG-placed pair count, the visit counts, and the hash of +the visit sequences — byte-identical across CI runs, compiler trees, +and scenario instantiations (verified: g++/Clang/ASan/release, +default and overridden seeds). + +A committed known-answer test (`IterOrder.DeterministicVisitOrderKAT`) +pins the exact visit sequences of the degenerate interleaving +(no PRNG draws), so a change to the order contract fails the KAT +before it can reach the replay machinery. + +## Determinism scope (ARCH-010) + +The visit order is pure integer ordering over fixed-width ids; no +floats, addresses, wall clocks, or platform intrinsics enter it. The +scope the contract promises is therefore the widest ARCH-010 allows: +**the same state visited identically on every supported platform, +architecture, and compiler** (same build). The state *hash* that +M1-DET-03 computes over the visit order inherits this scope once the +component-value encoding is pinned there. + +## Performance (DOC-004) + +- **Cost:** the matched-archetype scan is O(256 × N) over the fixed + archetype table (N = the number of queried components) and finishes + before the first callback; the visit itself is one O(1) column + index per reference — O(1) amortized per entity, no tail work on + the read path. +- **Allocation:** none in the query path (M1-ECS-04's 10k-entity + window proves zero heap: the matched-id array, the column index + array, and the guard sets are all stack-scoped fixed arrays). +- **Budget:** the measured 10k-visit baseline lives in + [query.md](query.md#performance-doc-004) (≈ 0.044 µs/visit, g++ + 16.2.1 Debug, `-O0`; the flat shape is the tested property). +- **Traps:** + - Do not assume the visit order is creation order or a global + slot order — archetype groups interleave in slot space by + design (the KAT's boundary above). Systems that need a global + order iterate `each<>` and index, or keep their own ordered + structure (game policy, M1-SYS-01+). + - Do not `std::sort` a captured visit list to "normalize" order + for comparison across runs — the order *is* the contract; + re-sorting hides a regression the replay hash exists to catch. + - A query over many archetypes pays the O(256 × N) scan even when + few rows match (the M1-ECS-04 trap); keep per-tick queries small. + +## Roadmap context + +- **M1-ECS-01/02/03/04 (done):** the entity handle, the component + registry, the archetype rows, and the query API this contract + governs — [entity.md](entity.md), + [component_registry.md](component_registry.md), + [archetype.md](archetype.md), [query.md](query.md). +- **M1-ECS-05 (this step):** the contract above + the convergence + property test. +- **M1-DET-02/03/04:** the replay header, the state hash (computed in + visit order), and the bit-exact CI replay build on this contract. +- **M1-SYS-01/02:** systems iterate through `SystemContext::each` + (a delegate to `World::each`), so they inherit this order; system + I/O declarations (M1-SYS-01) reuse the `Read`/`Write` tags. diff --git a/docs/api/query.md b/docs/api/query.md index eee0738..17adfeb 100644 --- a/docs/api/query.md +++ b/docs/api/query.md @@ -54,9 +54,11 @@ world.each(fn, laige::Read{}, laige::Write{}); Ascending archetype id (creation order), then ascending slot id within the archetype (the slot-ordered rows of archetype.h — invariant -I2). The empty query visits ascending slot id directly. M1-ECS-05 -documents this as the deterministic iteration contract; the suite -pins the exact sequence. +I2). The empty query visits ascending slot id directly. The full +deterministic iteration contract — including the dense-id-order +scheme under moves, the no-unordered-containers rule, and the +convergence property pinned by the `iter_order` suite — is +[iteration_order.md](iteration_order.md) (M1-ECS-05). ## Iteration legality (API-004, FR-12.3) @@ -135,8 +137,8 @@ assert fires before the log). - **Traps:** - The visit order is not sorted by component values and is not the entity-creation order — the archetype-id/slot-id scheme is - the contract (M1-ECS-05 documents it; deterministic replay is - what M1-ECS-05 builds on). + the contract ([iteration_order.md](iteration_order.md), + M1-ECS-05; deterministic replay is what M1-DET-02/03/04 build on). - A query over many archetypes pays the O(256 × N) scan even when few rows match — M1 keeps N small by design; the per-tick system pattern is few components, many entities (API-002). @@ -210,9 +212,11 @@ world.each<>([](laige::Entity e) { /* e.g. despawn sweep bookkeeping */ }); [archetype.md](archetype.md). - **M1-ECS-04 (this step):** the query API + iteration legality above. -- **M1-ECS-05:** the deterministic iteration contract over the visit - order pinned by this step (replay/hash tests at the promised scope, - ARCH-010). +- **M1-ECS-05 (done):** the deterministic iteration contract over + the visit order pinned by this step — the convergence property and + the dense-id-order scheme — see + [iteration_order.md](iteration_order.md) (replay/hash tests at the + promised scope land in M1-DET-02/03/04, ARCH-010). - **M1-SYS-01/02:** the system loop and `SystemContext` — the `ctx.each` of the PRD sketch delegates to `World::each` (one world, one owner thread). diff --git a/roadmap/M1-heartbeat.md b/roadmap/M1-heartbeat.md index ae60d83..d28f833 100644 --- a/roadmap/M1-heartbeat.md +++ b/roadmap/M1-heartbeat.md @@ -60,7 +60,7 @@ zero-allocation property (M1-ALLOC-01 enforces it once it exists; before that, A - **Verify:** `ctest -R query` green; illegal-mutation test asserts in debug build. - **Size:** ~250 lines + tests -- [ ] **M1-ECS-05 · Deterministic iteration order** +- [x] **M1-ECS-05 · Deterministic iteration order** - **Refs:** PRD §10.3 (archetype order, entity id order); AGENTS ARCH-010 - **Depends:** M1-ECS-04 - **Scope:** diff --git a/roadmap/README.md b/roadmap/README.md index 011dc37..1d17c2d 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 | 4 | 🚧 in progress (M1-ECS-04) | +| M1 | 25 | 5 | 🚧 in progress (M1-ECS-05) | | 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** | **26** | | +| **Total** | **193** | **27** | | --- diff --git a/src/laige-sim/README.md b/src/laige-sim/README.md index ffc7e7e..f99bba4 100644 --- a/src/laige-sim/README.md +++ b/src/laige-sim/README.md @@ -28,6 +28,12 @@ release), and the 10k-entity zero-allocation iteration window (`include/laige/sim/query.h`, `query.cpp`; API contract in [docs/api/query.md](../docs/api/query.md), tests under [tests/laige-sim](../tests/laige-sim), CTest entry `query`). -The deterministic iteration contract (M1-ECS-05), the system loop -(M1-SYS), and the game loop (M1-LOOP) land in the remaining M1 steps; -physics, input, and animation in M3. +M1-ECS-05 landed the deterministic iteration contract — the +`World::each` visit order (archetypes in first-seen/creation order, +entities in ascending slot id), the dense-id-order scheme under +component moves, the no-unordered-containers rule for the iteration +path, and the convergent-worlds property test (API contract in +[docs/api/iteration_order.md](../docs/api/iteration_order.md), tests +under [tests/laige-sim](../tests/laige-sim), CTest entry `iter_order`). +The system loop (M1-SYS) and the game loop (M1-LOOP) land in the +remaining M1 steps; physics, input, and animation in M3. diff --git a/tests/laige-sim/CMakeLists.txt b/tests/laige-sim/CMakeLists.txt index 075f3ad..658198f 100644 --- a/tests/laige-sim/CMakeLists.txt +++ b/tests/laige-sim/CMakeLists.txt @@ -1,18 +1,19 @@ -# laige-sim tests (M1-ECS-01/02/03/04): entity handle + World entity +# laige-sim tests (M1-ECS-01/02/03/04/05): entity handle + World entity # storage, component type registry, archetype SoA storage, query API + -# iteration legality. +# iteration legality, deterministic iteration order. # # 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`, and `query` entries are the -# M1-ECS-01, M1-ECS-02, M1-ECS-03, and M1-ECS-04 Verify commands -# (`ctest -R entity`, `ctest -R component_registry`, `ctest -R archetype`, -# `ctest -R query`), selecting exactly the suites below from the shared -# executable. +# `component_registry`, `archetype`, `query`, and `iter_order` entries +# are the M1-ECS-01, M1-ECS-02, M1-ECS-03, M1-ECS-04, and M1-ECS-05 +# Verify commands (`ctest -R entity`, `ctest -R component_registry`, +# `ctest -R archetype`, `ctest -R query`, `ctest -R iter_order`), +# selecting exactly the suites below from the shared executable. set(LAIGE_SIM_TEST_SOURCES entity_tests.cpp component_registry_tests.cpp - archetype_tests.cpp query_tests.cpp) + archetype_tests.cpp query_tests.cpp + iter_order_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, @@ -81,9 +82,18 @@ add_test(NAME query COMMAND laige-sim_tests --gtest_filter=Query*) +# M1-ECS-05: deterministic iteration order. The step's Verify command is +# `ctest -R iter_order`; this entry selects exactly the IterOrder* +# suites from the shared laige-sim_tests executable (the machine- +# greppable iter-order scenario lines land in the ctest output, +# docs/testing.md §4). +add_test(NAME iter_order + COMMAND laige-sim_tests + --gtest_filter=IterOrder.*) + 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 PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") + query iter_order PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") endif() diff --git a/tests/laige-sim/iter_order_tests.cpp b/tests/laige-sim/iter_order_tests.cpp new file mode 100644 index 0000000..632bf60 --- /dev/null +++ b/tests/laige-sim/iter_order_tests.cpp @@ -0,0 +1,963 @@ +// laige-sim deterministic iteration order suite (M1-ECS-05). +// +// Step scope (roadmap/M1-heartbeat.md; contract documented in +// docs/api/iteration_order.md): +// - the documented iteration contract: archetypes in registration +// (first-seen/creation) order, entities within an archetype in id +// order, the dense-id order surviving moves +// - the convergence property: two worlds whose operation sequences +// interleave create/destroy in different ways but converge on the +// identical final state — including the entity->id assignment — +// iterate identically +// - component moves (add/remove) are exercised throughout the +// histories so the dense-id-order scheme is pinned, not assumed +// +// The "no unordered containers in the iteration path" half of the step +// is a structural property of the implementation (archetype.h: the +// entity->archetype map is direct indexing, not even a hash; the one +// hash structure, the component type-key index, is lookup-only and +// never iterated). It is verified by inspection here and will be +// enforced in CI by the M1-DET-01 sim source scan; this suite pins the +// observable consequence — the visit order is a pure function of the +// world state, never of the operation history. +// +// Runs as CTest `iter_order` (the step's Verify command: +// `ctest -R iter_order`): a filtered view of the shared +// laige-sim_tests executable selecting exactly the IterOrder* suites. +// +// Scenario (fixed PRNG seed, docs/testing.md §4): a 20-slot world, 16 +// logical entities created in index order (entity i takes slot 19-i — +// the LIFO free list pops 19, 18, ...), dead set i % 5 == 2 = {2, 7, +// 12}, component types IterA/IterB/IterC registered in that order +// (ids 1, 2, 3). Per-entity component scripts (values 100*i + {1,2,3}, +// distinct per entity): +// +// even i (i != 4): add A, add B, add C, remove C, remove B -> {A} +// i == 4: the same + remove A -> {} +// odd i: add A, add B, remove B, add B -> {A,B} +// +// The first-seen archetype order is {A}=1, {A,B}=2, {A,B,C}=3 under +// every interleaving the builder can produce (the round-robin reaches +// each script step in script order; the within-round order never +// introduces a new set out of round order). Final live state (both +// sequences, always): archetype 1 holds the even live entities in +// slots {5, 9, 11, 13, 19}; archetype 2 the odd live entities in slots +// {4, 6, 8, 10, 14, 16, 18}; entity 4 is live and component-less in +// slot 15 — 13 live entities, archetype 3 ({A,B,C}) empty. +// +// Sequence A (serial): create all 16 in order; every entity (the dead +// ones included) runs its script in turn; the dead entities are +// destroyed last, in index order. +// Sequence B (interleaved): the SAME create phase — the LIFO free list +// REQUIRES it for the identical entity->id assignment: a live entity's +// slot is whatever the free list's top holds at its create, and any +// earlier dead-slot destroy would have pushed that slot back on top. +// The difference is WHEN the dead destroys happen: in B each dead +// entity is destroyed at a PRNG-assigned round boundary (scattered +// through the component phase, instead of A's end), the component +// steps are applied round-robin over the live entities in per-round +// PRNG permutations (the per-entity script order is preserved), and +// PRNG-placed scratch create/destroy pairs (component-less, free-list- +// neutral, placed only on the round boundaries — after every live +// create) ride the round boundaries. +// The two histories converge on an identical final state in every +// field the iteration contract covers: live slots, entity->slot +// assignment, archetype assignment, archetype id assignment, component +// values, and even the dead slots' generation counters (each dead slot +// is created once and destroyed once in both sequences). They differ +// only in bookkeeping the iteration cannot observe: the churn +// counters (totalCreated/totalAdds/totalRemoves), the free-list ORDER +// of the pushed dead slots (and hence the slot a hypothetical NEXT +// create() would pop — not part of any iteration), and the +// generation counters of the never-created slots a scratch pair pops +// (still dead, still generation-irrelevant). The oracle below +// recomputes the expected visit sequences from the final state and +// from each sequence's op walk (first-seen archetype order); the tests +// assert observed(world A) == observed(world B) == oracle for five +// queries. + +#include +#include +#include +#include +#include +#include + +#include "gtest/gtest.h" +#include "laige/errors.h" +#include "laige/prng.h" +#include "laige/sim/entity.h" + +#include "laige_test_seed.h" + +// --------------------------------------------------------------------------- +// NFR-8.10 policy self-checks (compile-time; a violation fails the build) +// --------------------------------------------------------------------------- + +#if defined(__cpp_exceptions) +static_assert(false, + "iter_order_tests must be built with exceptions disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#elif defined(__EXCEPTIONS) && __EXCEPTIONS +static_assert(false, + "iter_order_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, + "iter_order_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 ITER_ORDER_TESTS_ACTIVE_CPLUSPLUS _MSVC_LANG +#else +# define ITER_ORDER_TESTS_ACTIVE_CPLUSPLUS __cplusplus +#endif + +#if ITER_ORDER_TESTS_ACTIVE_CPLUSPLUS < 202002L +static_assert(false, + "iter_order_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 must +// sit in the primary template's enclosing namespace) +// --------------------------------------------------------------------------- + +struct IterA { + std::int32_t v; +}; +LAIGE_COMPONENT(IterA) + +struct IterB { + std::int32_t v; +}; +LAIGE_COMPONENT(IterB) + +struct IterC { + std::int32_t v; +}; +LAIGE_COMPONENT(IterC) + +namespace { + +// --------------------------------------------------------------------------- +// Scenario constants (CORE-005: named; the KAT below is pinned to them) +// --------------------------------------------------------------------------- + +constexpr std::uint32_t kScenarioCapacity = 20; +constexpr std::uint32_t kScenarioEntities = 16; +constexpr std::uint32_t kDeadModulo = 5; +constexpr std::uint32_t kDeadRemainder = 2; +constexpr std::uint32_t kComponentlessEntity = 4; +constexpr std::uint32_t kMaxScriptSteps = 6; // the i == 4 script length +constexpr std::uint32_t kLiveEntityCount = 13; // 16 - 3 dead +// Entity i is created at slot kScenarioCapacity - 1 - i (the LIFO free +// list pops 19, 18, ... first). +constexpr std::uint16_t slotOf(std::uint32_t i) { + return static_cast(kScenarioCapacity - 1 - i); +} + +// Component-set masks (registration order: A = 1, B = 2, C = 3). +constexpr std::uint32_t kSetA = 1u << 0; +constexpr std::uint32_t kSetB = 1u << 1; +constexpr std::uint32_t kSetC = 1u << 2; + +// Substream ids for the property test's three scenario instantiations +// (docs/testing.md §4: stable test identities; 1003 belongs to the +// archetype suite, so this file owns 1005-1007). +constexpr std::uint32_t kScenarioStreamIds[3] = {1005u, 1006u, 1007u}; + +// --------------------------------------------------------------------------- +// The operation model +// --------------------------------------------------------------------------- + +struct Op { + enum class Kind : std::uint8_t { + Create, + Destroy, + AddA, + AddB, + AddC, + RemA, + RemB, + RemC, + ScratchCreate, + ScratchDestroy, + } kind; + std::uint32_t entity{}; // logical index; unused for scratch ops + std::int32_t value{}; // component value for add ops; 0 otherwise +}; + +inline bool isDead(std::uint32_t i) { + return i % kDeadModulo == kDeadRemainder; +} + +// The per-entity component script (see the file preamble). Every step +// is a structural move (adds of absent components, removes of present +// ones) — no in-place overwrites, no no-ops — so both sequences churn +// rows between archetypes continuously. +std::vector scriptFor(std::uint32_t i) { + const std::int32_t a = static_cast(100 * i + 1); + const std::int32_t b = static_cast(100 * i + 2); + const std::int32_t c = static_cast(100 * i + 3); + if (i % 2 == 1) { + return {{Op::Kind::AddA, i, a}, + {Op::Kind::AddB, i, b}, + {Op::Kind::RemB, i, 0}, + {Op::Kind::AddB, i, b}}; + } + if (i == kComponentlessEntity) { + return {{Op::Kind::AddA, i, a}, + {Op::Kind::AddB, i, b}, + {Op::Kind::AddC, i, c}, + {Op::Kind::RemC, i, 0}, + {Op::Kind::RemB, i, 0}, + {Op::Kind::RemA, i, 0}}; + } + return {{Op::Kind::AddA, i, a}, + {Op::Kind::AddB, i, b}, + {Op::Kind::AddC, i, c}, + {Op::Kind::RemC, i, 0}, + {Op::Kind::RemB, i, 0}}; +} + +// --------------------------------------------------------------------------- +// Sequence construction +// --------------------------------------------------------------------------- + +struct Scenario { + std::vector seqA; + std::vector seqB; + std::uint32_t extraPairs{}; // PRNG-placed scratch pairs (greppable line) +}; + +// Build the two convergent operation sequences. `deterministic` skips +// every PRNG draw (all dead destroys land at the final boundary in +// index order, identity round order, no extra pairs) — the KAT's +// degenerate interleaving; the rng is then never read. +Scenario buildScenario(laige::Prng rng, bool deterministic) { + Scenario s; + // Sequence A: create all, run every script in turn (the dead + // entities included — they hold their archetype rows until the final + // destroy), destroy the dead entities last, in index order. + for (std::uint32_t i = 0; i < kScenarioEntities; ++i) { + s.seqA.push_back({Op::Kind::Create, i, 0}); + } + for (std::uint32_t i = 0; i < kScenarioEntities; ++i) { + for (const Op& op : scriptFor(i)) { + s.seqA.push_back(op); + } + } + for (std::uint32_t i = 0; i < kScenarioEntities; ++i) { + if (isDead(i)) { + s.seqA.push_back({Op::Kind::Destroy, i, 0}); + } + } + // Sequence B: the SAME create phase as A — a live entity's slot is + // whatever the free list's top holds at its create, and an earlier + // dead-slot destroy would have pushed that slot back on top (the + // LIFO requirement, file preamble). The difference is WHEN the dead + // destroys happen: in B each dead entity is destroyed at a + // PRNG-assigned round boundary (0 = before round 1, ..., 6 = after + // round 6), interleaved THROUGH the component phase instead of at + // its end. The component steps run round-robin over the live + // entities (the dead ones carry no script in B), each round in a + // Fisher-Yates permutation (the per-entity script order is + // preserved); optional scratch pairs (component-less, free-list- + // neutral — no live create follows any of them) ride the boundaries + // ahead of the dead destroys. + std::vector deadBoundaries(kScenarioEntities, + kMaxScriptSteps); + for (std::uint32_t i = 0; i < kScenarioEntities; ++i) { + if (isDead(i) && !deterministic) { + deadBoundaries[i] = rng.next_range(0u, kMaxScriptSteps + 1u); + } + } + for (std::uint32_t i = 0; i < kScenarioEntities; ++i) { + s.seqB.push_back({Op::Kind::Create, i, 0}); + } + for (std::uint32_t step = 1; step <= kMaxScriptSteps; ++step) { + const std::uint32_t boundary = step - 1; + if (!deterministic && rng.next_range(0u, 4u) == 0u) { + s.seqB.push_back({Op::Kind::ScratchCreate, 0, 0}); + s.seqB.push_back({Op::Kind::ScratchDestroy, 0, 0}); + ++s.extraPairs; + } + for (std::uint32_t i = 0; i < kScenarioEntities; ++i) { + if (isDead(i) && deadBoundaries[i] == boundary) { + s.seqB.push_back({Op::Kind::Destroy, i, 0}); + } + } + std::vector live; + live.reserve(kScenarioEntities); + for (std::uint32_t i = 0; i < kScenarioEntities; ++i) { + if (!isDead(i)) live.push_back(i); + } + if (!deterministic) { + for (std::uint32_t j = live.size(); j > 1; --j) { + const std::uint32_t k = rng.next_range(0u, j); + std::swap(live[j - 1], live[k]); + } + } + for (std::uint32_t i : live) { + const std::vector script = scriptFor(i); + if (step <= script.size()) { + s.seqB.push_back(script[step - 1]); + } + } + } + if (!deterministic && rng.next_range(0u, 4u) == 0u) { + s.seqB.push_back({Op::Kind::ScratchCreate, 0, 0}); + s.seqB.push_back({Op::Kind::ScratchDestroy, 0, 0}); + ++s.extraPairs; + } + for (std::uint32_t i = 0; i < kScenarioEntities; ++i) { + if (isDead(i) && deadBoundaries[i] == kMaxScriptSteps) { + s.seqB.push_back({Op::Kind::Destroy, i, 0}); + } + } + return s; +} + +// --------------------------------------------------------------------------- +// The oracle: an independent model of the final state and of the +// first-seen archetype order (ids), per sequence +// --------------------------------------------------------------------------- + +struct EntityState { + std::uint16_t slot{}; + std::uint16_t generation{}; + bool alive{false}; + std::uint32_t set{0}; // component-set mask + std::int32_t valueA{0}; + std::int32_t valueB{0}; + std::int32_t valueC{0}; +}; + +struct OracleResult { + std::vector entities; + std::vector archetypeOrder; // masks, first-seen order +}; + +OracleResult runOracle(const std::vector& seq) { + OracleResult r; + r.entities.resize(kScenarioEntities); + std::vector gen(kScenarioCapacity, 1u); + std::vector freeStack; + freeStack.reserve(kScenarioCapacity); + for (std::uint32_t i = 0; i < kScenarioCapacity; ++i) { + freeStack.push_back(static_cast(i)); + } + std::uint16_t scratchSlot{}; + auto noteSet = [&r](std::uint32_t set) { + if (set == 0) return; + for (std::uint32_t m : r.archetypeOrder) { + if (m == set) return; + } + r.archetypeOrder.push_back(set); + }; + for (const Op& op : seq) { + EntityState* e = nullptr; + switch (op.kind) { + case Op::Kind::Create: + case Op::Kind::ScratchCreate: { + const std::uint16_t slot = freeStack.back(); + freeStack.pop_back(); + if (op.kind == Op::Kind::Create) { + e = &r.entities[op.entity]; + e->slot = slot; + e->generation = gen[slot]; + e->alive = true; + } else { + scratchSlot = slot; + } + break; + } + case Op::Kind::Destroy: + case Op::Kind::ScratchDestroy: { + std::uint16_t slot; + if (op.kind == Op::Kind::Destroy) { + e = &r.entities[op.entity]; + slot = e->slot; + e->alive = false; + e->set = 0; + e->valueA = 0; + e->valueB = 0; + e->valueC = 0; + } else { + slot = scratchSlot; + } + gen[slot] = static_cast(gen[slot] + 1u); + if (gen[slot] == 0) gen[slot] = 1u; // the engine's reserved-0 skip + freeStack.push_back(slot); + break; + } + case Op::Kind::AddA: + e = &r.entities[op.entity]; + e->set |= kSetA; + e->valueA = op.value; + break; + case Op::Kind::AddB: + e = &r.entities[op.entity]; + e->set |= kSetB; + e->valueB = op.value; + break; + case Op::Kind::AddC: + e = &r.entities[op.entity]; + e->set |= kSetC; + e->valueC = op.value; + break; + case Op::Kind::RemA: + e = &r.entities[op.entity]; + e->set &= ~kSetA; + e->valueA = 0; + break; + case Op::Kind::RemB: + e = &r.entities[op.entity]; + e->set &= ~kSetB; + e->valueB = 0; + break; + case Op::Kind::RemC: + e = &r.entities[op.entity]; + e->set &= ~kSetC; + e->valueC = 0; + break; + default: + break; + } + // The engine creates an archetype for the op's TARGET set (its + // state after the op; empty = the entity leaves the archetypes). + if (e != nullptr && + (op.kind == Op::Kind::AddA || op.kind == Op::Kind::AddB || + op.kind == Op::Kind::AddC || op.kind == Op::Kind::RemA || + op.kind == Op::Kind::RemB || op.kind == Op::Kind::RemC)) { + noteSet(e->set); + } + } + return r; +} + +// --------------------------------------------------------------------------- +// The contract's prediction: the visit sequence of a query, computed +// from the final state + first-seen archetype order (no operation +// history) +// --------------------------------------------------------------------------- + +struct Visit { + std::uint16_t slot{}; + std::uint16_t generation{}; + std::int32_t a{0}; + std::int32_t b{0}; + std::int32_t c{0}; +}; + +inline bool operator==(const Visit& x, const Visit& y) noexcept { + return x.slot == y.slot && x.generation == y.generation && x.a == y.a && + x.b == y.b && x.c == y.c; +} + +std::vector expectedVisits(const OracleResult& o, std::uint32_t queryMask) { + std::vector out; + if (queryMask == 0) { + // The empty query: every live entity, ascending slot order, no + // archetype grouping. + std::vector liveIdx; + liveIdx.reserve(kScenarioEntities); + for (std::uint32_t i = 0; i < kScenarioEntities; ++i) { + if (o.entities[i].alive) liveIdx.push_back(i); + } + std::sort(liveIdx.begin(), liveIdx.end(), + [&o](std::uint32_t x, std::uint32_t y) { + return o.entities[x].slot < o.entities[y].slot; + }); + for (std::uint32_t i : liveIdx) { + const EntityState& e = o.entities[i]; + out.push_back( + Visit{e.slot, e.generation, e.valueA, e.valueB, e.valueC}); + } + return out; + } + // Non-empty query: the matching archetypes (superset match) in + // ascending archetype id = first-seen order; within each, the live + // entities in ascending slot order (the dense-id scheme). + std::vector entityIdx; + entityIdx.reserve(kScenarioEntities); + for (std::uint32_t ai = 0; ai < o.archetypeOrder.size(); ++ai) { + const std::uint32_t archSet = o.archetypeOrder[ai]; + if ((archSet & queryMask) != queryMask) continue; + entityIdx.clear(); + for (std::uint32_t i = 0; i < kScenarioEntities; ++i) { + if (o.entities[i].alive && o.entities[i].set == archSet) { + entityIdx.push_back(i); + } + } + std::sort(entityIdx.begin(), entityIdx.end(), + [&o](std::uint32_t x, std::uint32_t y) { + return o.entities[x].slot < o.entities[y].slot; + }); + for (std::uint32_t i : entityIdx) { + const EntityState& e = o.entities[i]; + out.push_back( + Visit{e.slot, e.generation, e.valueA, e.valueB, e.valueC}); + } + } + return out; +} + +// --------------------------------------------------------------------------- +// Running a sequence on a real World and collecting the observed visits +// --------------------------------------------------------------------------- + +struct WorldRun { + laige::World world; + std::vector handles; + laige::Entity scratch{}; +}; + +WorldRun makeRun() { + auto w = laige::World::create(laige::World::Options{kScenarioCapacity}); + if (!w.ok()) { + ADD_FAILURE() << "World::create(" << kScenarioCapacity << ") failed: " + << laige::errorName(w.error()); + std::abort(); + } + laige::World world = std::move(w).takeValue(); + auto ra = world.registerComponent(); + auto rb = world.registerComponent(); + auto rc = world.registerComponent(); + if (!ra.ok() || !rb.ok() || !rc.ok()) { + ADD_FAILURE() << "registerComponent failed"; + std::abort(); + } + // WorldRun is an aggregate (World is move-only, no default ctor — + // the factory is its only construction path, entity.h). + return WorldRun{std::move(world), + std::vector(kScenarioEntities), + laige::Entity{}}; +} + +bool applyOps(WorldRun& run, const std::vector& seq) { + for (const Op& op : seq) { + switch (op.kind) { + case Op::Kind::Create: { + auto r = run.world.create(); + if (!r.ok()) return false; + run.handles[op.entity] = r.value(); + break; + } + case Op::Kind::ScratchCreate: { + auto r = run.world.create(); + if (!r.ok()) return false; + run.scratch = r.value(); + break; + } + case Op::Kind::Destroy: + if (!run.world.destroy(run.handles[op.entity]).ok()) return false; + break; + case Op::Kind::ScratchDestroy: + if (!run.world.destroy(run.scratch).ok()) return false; + break; + case Op::Kind::AddA: + if (!run.world.addComponent(run.handles[op.entity], + IterA{op.value}) + .ok()) { + return false; + } + break; + case Op::Kind::AddB: + if (!run.world.addComponent(run.handles[op.entity], + IterB{op.value}) + .ok()) { + return false; + } + break; + case Op::Kind::AddC: + if (!run.world.addComponent(run.handles[op.entity], + IterC{op.value}) + .ok()) { + return false; + } + break; + case Op::Kind::RemA: + if (!run.world.removeComponent(run.handles[op.entity]).ok()) { + return false; + } + break; + case Op::Kind::RemB: + if (!run.world.removeComponent(run.handles[op.entity]).ok()) { + return false; + } + break; + case Op::Kind::RemC: + if (!run.world.removeComponent(run.handles[op.entity]).ok()) { + return false; + } + break; + default: + return false; + } + } + return true; +} + +// A rejected iteration (a nested each — impossible in this synchronous +// single-threaded flow, but never silently ignored: CORE-008) records +// a test failure; the visit vector is then incomplete and the +// comparisons below fail as well. +void expectEachOk(const char* label, const laige::Status& s) { + EXPECT_TRUE(s.ok()) << "World::each rejected the iteration: " << label; +} + +std::vector collectAll(laige::World& w) { + std::vector out; + expectEachOk("each<>", + w.each<>([&](laige::Entity e) { + out.push_back(Visit{e.id, e.generation, 0, 0, 0}); + })); + return out; +} + +std::vector collectA(laige::World& w) { + std::vector out; + expectEachOk( + "each", + w.each( + [&](laige::Entity e, const IterA& a) { + out.push_back(Visit{e.id, e.generation, a.v, 0, 0}); + }, + laige::Read{})); + return out; +} + +std::vector collectB(laige::World& w) { + std::vector out; + expectEachOk( + "each", + w.each( + [&](laige::Entity e, const IterB& b) { + out.push_back(Visit{e.id, e.generation, 0, b.v, 0}); + }, + laige::Read{})); + return out; +} + +std::vector collectC(laige::World& w) { + std::vector out; + expectEachOk( + "each", + w.each( + [&](laige::Entity e, const IterC& c) { + out.push_back(Visit{e.id, e.generation, 0, 0, c.v}); + }, + laige::Read{})); + return out; +} + +std::vector collectAB(laige::World& w) { + std::vector out; + expectEachOk( + "each", + w.each( + [&](laige::Entity e, const IterA& a, const IterB& b) { + out.push_back(Visit{e.id, e.generation, a.v, b.v, 0}); + }, + laige::Read{}, laige::Read{})); + return out; +} + +// Per-visit comparison of slot, generation, and the query's components +// (the unqueried components of a Visit are 0 on the observed side and +// are never compared). +void expectVisits(const char* label, const std::vector& observed, + const std::vector& expected, std::uint32_t queryMask, + const char* context) { + EXPECT_EQ(observed.size(), expected.size()) + << label << " (" << context << ")"; + for (std::size_t i = 0; i < observed.size() && i < expected.size(); ++i) { + EXPECT_EQ(observed[i].slot, expected[i].slot) + << label << " visit " << i << " (" << context << ")"; + EXPECT_EQ(observed[i].generation, expected[i].generation) + << label << " visit " << i << " (" << context << ")"; + if ((queryMask & kSetA) != 0) { + EXPECT_EQ(observed[i].a, expected[i].a) + << label << " visit " << i << " (" << context << ")"; + } + if ((queryMask & kSetB) != 0) { + EXPECT_EQ(observed[i].b, expected[i].b) + << label << " visit " << i << " (" << context << ")"; + } + if ((queryMask & kSetC) != 0) { + EXPECT_EQ(observed[i].c, expected[i].c) + << label << " visit " << i << " (" << context << ")"; + } + } +} + +// 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; +} + +std::uint64_t hashVisits(const std::vector& v) { + std::vector words; + words.reserve(v.size() * 4); + for (const Visit& x : v) { + words.push_back(static_cast(x.slot) | + (static_cast(x.generation) << 16)); + words.push_back(static_cast( + static_cast(x.a))); + words.push_back(static_cast( + static_cast(x.b))); + words.push_back(static_cast( + static_cast(x.c))); + } + return fnv1a64(words.data(), words.size()); +} + +// The dense-id-order pin over an `each` sequence: archetype 1's +// group is a strictly ascending-slot prefix, archetype 2's group a +// strictly ascending suffix, and the boundary is not a global slot +// order (archetype 2's first slot 4 is smaller than archetype 1's last +// slot 19 — a globally-ordered walk would visit them differently). The +// sequence must be the ORACLE's expected visits (full component state): +// the observed each visits do not expose IterB, so the group +// boundary is invisible in them. +void expectArchetypeOrder(const std::vector& visitsA) { + std::size_t boundary = 0; + while (boundary < visitsA.size() && visitsA[boundary].b == 0) { + ++boundary; // archetype 1 = {A}: no IterB value + } + EXPECT_EQ(boundary, 5u) << "archetype-1 group size"; + for (std::size_t g = 0; g < 2; ++g) { + const std::size_t start = (g == 0) ? 0u : boundary; + const std::size_t end = (g == 0) ? boundary : visitsA.size(); + for (std::size_t i = start + 1; i < end; ++i) { + EXPECT_LT(visitsA[i - 1].slot, visitsA[i].slot) + << "dense-id order broken in group " << g << " at visit " << i; + } + } + // The scenario distinguishes archetype order from a global slot + // order (the pin is not vacuous): archetype 2 opens below the top of + // archetype 1. + ASSERT_LT(boundary, visitsA.size()); + EXPECT_LT(visitsA[boundary].slot, visitsA[boundary - 1].slot) + << "scenario must interleave the two archetype groups"; +} + +} // namespace + +// --------------------------------------------------------------------------- +// Known-answer test: the degenerate interleaving (no PRNG draws) with +// the committed visit sequences. +// --------------------------------------------------------------------------- + +TEST(IterOrder, DeterministicVisitOrderKAT) { + const std::uint64_t seed = laige::testing::TestSeed(); + const Scenario s = buildScenario(laige::Prng(seed), /*deterministic=*/true); + const OracleResult oa = runOracle(s.seqA); + const OracleResult ob = runOracle(s.seqB); + + // Convergence at the oracle level, incl. the entity->slot assignment. + EXPECT_EQ(oa.archetypeOrder, ob.archetypeOrder); + EXPECT_EQ(oa.archetypeOrder, + (std::vector{kSetA, kSetA | kSetB, + kSetA | kSetB | kSetC})); + for (std::uint32_t i = 0; i < kScenarioEntities; ++i) { + const EntityState& ea = oa.entities[i]; + const EntityState& eb = ob.entities[i]; + EXPECT_EQ(ea.alive, eb.alive) << "entity " << i; + if (ea.alive) { + EXPECT_EQ(ea.slot, slotOf(i)) << "entity " << i; + EXPECT_EQ(ea.slot, eb.slot) << "entity " << i; + EXPECT_EQ(ea.generation, eb.generation) << "entity " << i; + EXPECT_EQ(ea.set, eb.set) << "entity " << i; + EXPECT_EQ(ea.valueA, eb.valueA) << "entity " << i; + EXPECT_EQ(ea.valueB, eb.valueB) << "entity " << i; + EXPECT_EQ(ea.valueC, eb.valueC) << "entity " << i; + } + } + + WorldRun ra = makeRun(); + WorldRun rb = makeRun(); + ASSERT_TRUE(applyOps(ra, s.seqA)); + ASSERT_TRUE(applyOps(rb, s.seqB)); + EXPECT_EQ(ra.world.archetypeCount(), oa.archetypeOrder.size()); + EXPECT_EQ(rb.world.archetypeCount(), ob.archetypeOrder.size()); + EXPECT_EQ(ra.world.entityCount(), kLiveEntityCount); + EXPECT_EQ(rb.world.entityCount(), kLiveEntityCount); + + // The committed known-answer visit sequences (KAT; the oracle must + // reproduce them — a mismatch in either direction fails loudly). + // The empty query carries the full component state of every live + // entity (entity i is at slot 19-i; e4 at slot 15 is component-less). + const std::vector katAll = { + {4, 1, 1501, 1502, 0}, {5, 1, 1401, 0, 0}, {6, 1, 1301, 1302, 0}, + {8, 1, 1101, 1102, 0}, {9, 1, 1001, 0, 0}, {10, 1, 901, 902, 0}, + {11, 1, 801, 0, 0}, {13, 1, 601, 0, 0}, {14, 1, 501, 502, 0}, + {15, 1, 0, 0, 0}, {16, 1, 301, 302, 0}, {18, 1, 101, 102, 0}, + {19, 1, 1, 0, 0}}; + const std::vector katA = { + {5, 1, 1401, 0, 0}, {9, 1, 1001, 0, 0}, {11, 1, 801, 0, 0}, + {13, 1, 601, 0, 0}, {19, 1, 1, 0, 0}, {4, 1, 1501, 1502, 0}, + {6, 1, 1301, 1302, 0}, {8, 1, 1101, 1102, 0}, {10, 1, 901, 902, 0}, + {14, 1, 501, 502, 0}, {16, 1, 301, 302, 0}, {18, 1, 101, 102, 0}}; + const std::vector katB = { + {4, 1, 1501, 1502, 0}, {6, 1, 1301, 1302, 0}, {8, 1, 1101, 1102, 0}, + {10, 1, 901, 902, 0}, {14, 1, 501, 502, 0}, {16, 1, 301, 302, 0}, + {18, 1, 101, 102, 0}}; + const std::vector katC = {}; + + const std::vector expAll = expectedVisits(oa, 0); + const std::vector expA = expectedVisits(oa, kSetA); + const std::vector expAB = expectedVisits(oa, kSetA | kSetB); + const std::vector expB = expectedVisits(oa, kSetB); + const std::vector expC = expectedVisits(oa, kSetC); + EXPECT_EQ(expAll, katAll); + EXPECT_EQ(expA, katA); + EXPECT_EQ(expAB, katB); + EXPECT_EQ(expB, katB); + EXPECT_EQ(expC, katC); + + const std::vector allA = collectAll(ra.world); + const std::vector allB = collectAll(rb.world); + const std::vector aA = collectA(ra.world); + const std::vector aB = collectA(rb.world); + const std::vector abA = collectAB(ra.world); + const std::vector abB = collectAB(rb.world); + const std::vector bA = collectB(ra.world); + const std::vector bB = collectB(rb.world); + const std::vector cA = collectC(ra.world); + const std::vector cB = collectC(rb.world); + + expectVisits("each<> world A vs oracle", allA, expAll, 0, "KAT"); + expectVisits("each<> world B vs world A", allB, allA, 0, "KAT"); + expectVisits("each world A vs oracle", aA, expA, kSetA, "KAT"); + expectVisits("each world B vs world A", aB, aA, kSetA, "KAT"); + expectVisits("each world A vs oracle", abA, expAB, kSetA | kSetB, "KAT"); + expectVisits("each world B vs world A", abB, abA, kSetA | kSetB, "KAT"); + expectVisits("each world A vs oracle", bA, expB, kSetB, "KAT"); + expectVisits("each world B vs world A", bB, bA, kSetB, "KAT"); + expectVisits("each world A vs oracle", cA, expC, kSetC, "KAT"); + expectVisits("each world B vs world A", cB, cA, kSetC, "KAT"); + + expectArchetypeOrder(expA); + + const std::uint64_t hash = + hashVisits(allA) * 31u + hashVisits(aA) * 31u + hashVisits(abA); + std::printf("iter-order kat seed=0x%016llx extra_pairs=0 live=%u " + "visits=%zu/%zu/%zu/%zu/%zu fnv1a=0x%016llx\n", + static_cast(seed), kLiveEntityCount, + allA.size(), aA.size(), abA.size(), bA.size(), cA.size(), + static_cast(hash)); + std::fflush(stdout); +} + +// --------------------------------------------------------------------------- +// Property test: convergent worlds with interleaved create/destroy and +// component moves iterate identically (fixed PRNG seed, three +// instantiations). +// --------------------------------------------------------------------------- + +TEST(IterOrder, ConvergentWorldsIterateIdentically) { + const std::uint64_t seed = laige::testing::TestSeed(); + for (std::uint32_t streamId : kScenarioStreamIds) { + const char* ctx = "property"; + laige::Prng rng = laige::testing::TestPrng(streamId); + const Scenario s = buildScenario(rng, /*deterministic=*/false); + const OracleResult oa = runOracle(s.seqA); + const OracleResult ob = runOracle(s.seqB); + + // The two histories converge on the identical iteration-relevant + // final state: archetype id assignment, live slots (the + // entity->id assignment), archetype assignment, component values. + EXPECT_EQ(oa.archetypeOrder, ob.archetypeOrder) << ctx; + EXPECT_EQ(oa.archetypeOrder, + (std::vector{kSetA, kSetA | kSetB, + kSetA | kSetB | kSetC})) + << ctx; + for (std::uint32_t i = 0; i < kScenarioEntities; ++i) { + const EntityState& ea = oa.entities[i]; + const EntityState& eb = ob.entities[i]; + EXPECT_EQ(ea.alive, eb.alive) << "entity " << i << " (" << ctx << ")"; + if (ea.alive) { + EXPECT_EQ(ea.slot, slotOf(i)) << "entity " << i << " (" << ctx << ")"; + EXPECT_EQ(ea.slot, eb.slot) + << "entity->id assignment, entity " << i << " (" << ctx << ")"; + EXPECT_EQ(ea.generation, eb.generation) + << "entity " << i << " (" << ctx << ")"; + EXPECT_EQ(ea.set, eb.set) << "entity " << i << " (" << ctx << ")"; + EXPECT_EQ(ea.valueA, eb.valueA) << "entity " << i << " (" << ctx << ")"; + EXPECT_EQ(ea.valueB, eb.valueB) << "entity " << i << " (" << ctx << ")"; + EXPECT_EQ(ea.valueC, eb.valueC) << "entity " << i << " (" << ctx << ")"; + } + } + + WorldRun ra = makeRun(); + WorldRun rb = makeRun(); + ASSERT_TRUE(applyOps(ra, s.seqA)) << "sequence A op failed (" << ctx << ")"; + ASSERT_TRUE(applyOps(rb, s.seqB)) << "sequence B op failed (" << ctx << ")"; + EXPECT_EQ(ra.world.archetypeCount(), oa.archetypeOrder.size()) << ctx; + EXPECT_EQ(rb.world.archetypeCount(), ob.archetypeOrder.size()) << ctx; + EXPECT_EQ(ra.world.entityCount(), kLiveEntityCount) << ctx; + EXPECT_EQ(rb.world.entityCount(), kLiveEntityCount) << ctx; + + const std::vector expAll = expectedVisits(oa, 0); + const std::vector expA = expectedVisits(oa, kSetA); + const std::vector expAB = expectedVisits(oa, kSetA | kSetB); + const std::vector expB = expectedVisits(oa, kSetB); + const std::vector expC = expectedVisits(oa, kSetC); + + const std::vector allA = collectAll(ra.world); + const std::vector allB = collectAll(rb.world); + const std::vector aA = collectA(ra.world); + const std::vector aB = collectA(rb.world); + const std::vector abA = collectAB(ra.world); + const std::vector abB = collectAB(rb.world); + const std::vector bA = collectB(ra.world); + const std::vector bB = collectB(rb.world); + const std::vector cA = collectC(ra.world); + const std::vector cB = collectC(rb.world); + + expectVisits("each<> world A vs oracle", allA, expAll, 0, ctx); + expectVisits("each<> world B vs world A", allB, allA, 0, ctx); + expectVisits("each world A vs oracle", aA, expA, kSetA, ctx); + expectVisits("each world B vs world A", aB, aA, kSetA, ctx); + expectVisits("each world A vs oracle", abA, expAB, kSetA | kSetB, ctx); + expectVisits("each world B vs world A", abB, abA, kSetA | kSetB, ctx); + expectVisits("each world A vs oracle", bA, expB, kSetB, ctx); + expectVisits("each world B vs world A", bB, bA, kSetB, ctx); + expectVisits("each world A vs oracle", cA, expC, kSetC, ctx); + expectVisits("each world B vs world A", cB, cA, kSetC, ctx); + + expectArchetypeOrder(expA); + + // Machine-greppable identity line (docs/testing.md §4): the seed, + // the PRNG-placed extra pairs, the visit counts, and the FNV-1a 64 + // hash of world A's five visit sequences — byte-identical across + // CI runs of the same commit. + const std::uint64_t hash = + hashVisits(allA) * 31u + hashVisits(aA) * 31u + hashVisits(abA); + std::printf("iter-order scenario=%u seed=0x%016llx extra_pairs=%u " + "live=%u visits=%zu/%zu/%zu/%zu/%zu fnv1a=0x%016llx\n", + streamId, static_cast(seed), + s.extraPairs, kLiveEntityCount, allA.size(), aA.size(), + abA.size(), bA.size(), cA.size(), + static_cast(hash)); + std::fflush(stdout); + } +} From 3c01f47c834e53b77b27174f972e59bf8a564c40 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Mon, 14 Sep 2026 12:32:18 +0200 Subject: [PATCH 2/2] Update roadmap change log: M1-ECS-05 row --- roadmap/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/roadmap/README.md b/roadmap/README.md index 1d17c2d..92d9541 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -199,6 +199,7 @@ One line per completed (or split/renumbered) step. | 2026-09-13 | M1-ECS-02 | `db13770` | Component type registry (M1-ECS-02 scope, nothing else): `ComponentTypeId` (32-bit, dense ids assigned in registration order from 1, 0 reserved as `kInvalidComponentTypeId`, per-world, `operator<` = registration order) + `LAIGE_COMPONENT(Type)` macro (compile-time trait mark, data-only — replication/inspector traits land in M4) + `World::registerComponent()` recording `sizeof(T)`/`alignof(T)` for the M1-ECS-03 SoA layout; user-defined structs register through the same path (S-8 data-carrier case); type identity without RTTI/unordered (per-type `inline static` marker address, NFR-8.10/PERF-006); engine-level budget `kMaxComponentTypes` = 256 (`BudgetExhausted` beyond it); duplicate registration → `InvalidArgument` + rate-limited warn `ecs/component_duplicate`; moved-from world → no registry (`InvalidArgument`); `static_assert` guards: `LAIGE_COMPONENT` mark + trivially-copyable (actionable compile errors); setup-phase O(n) no-allocation operation; registry moves with `World`, `clear()` leaves it untouched; new public header `src/laige-sim/include/laige/sim/component.h`; `component_registry` CTest entry (12 cases: id order, size/alignment incl. 8-byte alignment, duplicate error + unchanged registry, id stability across two worlds with the same registration order, order-determines-ids, 256-type budget boundary, `componentInfo` validation, move, clear, warn-once rate-limit summary via a memory sink); API contract in `docs/api/component_registry.md` (+ cross-refs in docs/README, entity.md, sim README); `laige-api.json` regenerated (421 symbols, api-real-tree green); local Verify: canonical g++ tree zero-warning, full `ctest` green (35 tests) | | 2026-09-13 | M1-ECS-03 | `cad0594` | Archetype SoA component storage (M1-ECS-03 scope, nothing else): archetype = an ordered component set stored SoA — one packed `T[]` column per component, rows in ascending slot-id order (a pure function of the world state; the convergence property M1-ECS-05 iterates), entity→archetype map as two dense per-slot tables (`archetypeOf_` 2 B + `rowOf_` 4 B — no hash; per-slot bookkeeping 5 → 11 B, entity.md) with `get`/`has` O(1) (slot → record → column binary search ≤ 32 → row); `addComponent` create-or-update (in-place overwrite when present) and `removeComponent` no-op-ok, both pool-backed moves (tail memmove + `rowOf_` re-sync) with **zero heap allocation per operation** — the reserve policy (initial `min(16, capacity)` rows, ×2 growth capped at world capacity, one accounted+logged reserve per growth, bounded by `log2(capacity/16)+1`); budgets `kMaxArchetypes` = 256 / `kMaxArchetypeComponents` = 32 (`BudgetExhausted` + rate-limited warns `ecs/archetype_budget`, `ecs/component_limit`), unregistered type → `InvalidArgument` + `ecs/component_unregistered`, stale handle → the M1-ECS-01 contract (nullptr + warn-once), archetypes never destroyed (empty sets persist, accounted in `bytesReserved`); type→id via splitmix64 open addressing (512 slots, lookup-only — never iterated, no pointer-order portability issue, no 256-scan in the hot path); signature match via FNV-1a 32 short-circuit + lexicographic verify; `destroy`/`clear` now detach rows first (documented O(tail × row-stride) cost, still no allocation); 25 cases in the `archetype` CTest entry (basics, access/stale matrix, layout properties: per-column contiguity + 32 B alignment + slot-ordered addresses + shift semantics, move data preservation, two-world layout convergence, 256-set/32-component/growth-cap-at-18 budget boundaries, warn-once via memory sink, stats/bytes tracking, **10k-entity churn: 20k add/remove ops in seeded random order — zero failures, zero reservation delta, zero process-wide allocations (test-only `operator new` counter, non-sanitizer trees; sanitizer trees prove it leak-free), p99/p50 ≈ 1.98 (flat), machine-greppable `archetype-churn ` line on every ctest run** — measured baseline g++ 16.2.1: Debug p50 0.123 ms / Release p50 0.0021 ms per op); API contract in `docs/api/archetype.md` (+ cross-refs in entity.md, component_registry.md, docs/README, sim README); `laige-api.json` regenerated (443 symbols, api-real-tree green); local Verify: canonical g++ tree zero-warning, full `ctest` green (36 tests), `ctest -R archetype` green, sim suites green on `build-asan` (ASan+UBSan, leak-free churn), `build-clang`, `build-release`, `build-shared`, `build-tsan`, `tools/laige-include-lint` OK | | 2026-09-14 | M1-ECS-04 | `f2f57e8` | Query API + iteration legality (M1-ECS-04 scope, nothing else): `World::each(fn, Read/Write tags...)` over the M1-ECS-03 SoA rows — superset match (extra components do not exclude), per-component access declared by tag TYPE (`Read` → `const T&`, `Write` → `T&`, decided at compile time; count/type checked by static_assert), the access tags follow the callable (a pack of parameters must be last to be deducible — the PRD sketch's `(access_flags, fn)` order settled here and documented in query.h), `each<>(fn)` visits all live entities ascending slot order, an unregistered listed type matches nothing (ok Status, zero visits); the iteration-legality guard — two stack-scoped membership-only `detail::IdSet256` sets (the matched-archetype set, complete before the first callback, plus the Read-declared component set) — enforces the query.h legality table: in-place write of a Read-declared queried component, structural add/remove/destroy/clear touching a matched source/target archetype, and a nested `each()` all **assert in debug** (six forked SIGABRT children) and in release return `ErrorCode::InvalidArgument` (no new codes — the registry stays additive-only) + one rate-limited warn + **skip-without-applying** while the iteration continues over the unmutated storage (FR-12.3; events `ecs/iteration_write_during_read`, `ecs/iteration_mutation`, `ecs/iteration_clear`, `ecs/iteration_nested`); legal paths: `create()` always, structural moves outside the matched set, writes through `Write` references, in-place overwrites of Write-declared components; no hidden allocations — iteration state is stack-scoped (`ids[N]`/`cols[N]`/`matchedIds[256]`/two 256-bit sets), the 10k-entity × 2-pass window (a Write pass storing per visit + a Read pass) measured **zero process-wide heap allocations** (test-only `operator new` counter, non-sanitizer trees; sanitizer trees leak-free) and zero reservation delta (pool-steady), ≈0.044 µs/visit on the `-O0` tree (machine-greppable `query-iteration ` lines per ctest run — CORE-001, M1-BENCH-01 baseline input); `clear()` is now `Status` (guard check first — a clear under a live iteration is rejected whole, never partial; the dtor never sees an active iteration) and all call sites honor `[[nodiscard]]`; compile-time machinery: the component/access packs ride as single tuple types (`std::tuple`, `std::tuple`) because an explicit template argument list cannot partition between consecutive packs, `rowRef` returns one `conditional_t` reference type (a `decltype(auto)` if/constexpr pair of returns forces inconsistent deduction), `visitRowRec` carries the accumulated references as a `Refs&...` reference pack (forwarded each level — no component copies); new public header `src/laige-sim/include/laige/sim/query.h` (`Access`/`Read`/`Write`, `detail::IdSet256`, the full contract) + `src/laige-sim/query.cpp` (the four guard helpers); 17 cases in the `query` CTest entry (exact mixed sets incl. superset, empty-query slot order + legal concurrent destroy, unregistered no-match, access-tag reference kinds, pinned archetype-then-slot visit order, guard release after iteration, the legal-mutation matrix, the release skip matrix (state unchanged + iteration continues + the same op succeeds afterwards), six debug assert cases, warn-once + `rate_limited` summary via a memory sink, the zero-alloc window); API contract in `docs/api/query.md` (+ cross-refs in entity.md, archetype.md, component_registry.md, docs/README, sim README); `laige-api.json` regenerated (451 symbols, api-real-tree green); local Verify: canonical g++ tree zero-warning, full `ctest` green (37 tests), `ctest -R query` green (15 passed + 2 release-only skips; 11 passed + 6 debug-only skips on `build-release`), sim suites green on `build-asan` (ASan+UBSan, leak-free), `build-clang`, `build-release`, `build-shared`, `build-tsan`, `tools/laige-include-lint` OK | +| 2026-09-14 | M1-ECS-05 | `3cf8f91` | Deterministic iteration order (M1-ECS-05 scope, nothing else): the documented contract over the `World::each` visit order — archetypes in ascending archetype id (= the order a component set is first seen by a mutation, i.e. creation order), entities within an archetype in ascending slot id, the empty query ascending slot id; the order is a pure function of the world state, never of the operation history; the dense-id-order scheme (rows kept in ascending slot-id order through insert/remove — archetype.h invariants I1–I4) documented as what makes convergent histories visit identically; no unordered containers in the iteration path (only the fixed 256-record archetype table scanned in id order, packed slot columns, per-slot direct-index records, and membership-only 256-bit guard sets — the anticipated entity→archetype "one internal hash structure" is direct indexing, not even a hash, a stricter reading of the allowance; the one hash structure in `laige-sim`, the component type-key index, is lookup-only and never iterated, and sits on the setup path, not any tick); convergence property test: two worlds whose operation sequences interleave create/destroy differently (sequence A: serial per-entity scripts + end-phase dead destroys; sequence B: the same create phase — LIFO requires it for the identical entity→id assignment — with PRNG-scattered dead-destroys at round boundaries, round-robin component steps over the live entities in per-round PRNG permutations, and PRNG-placed component-less scratch pairs) converge on the identical final state including the entity→id assignment and iterate identically for five queries (`each<>`, `each`, `each`, `each`, `each`), verified per-visit (slot, generation, component values) against an independent oracle (free-list simulation + first-seen archetype order); component moves are structural throughout (adds of absent / removes of present components — the dense-id scheme pinned, not assumed); the archetype-1/archetype-2 boundary is non-vacuously distinguished from a global slot order (archetype 2 opens at slot 4, below archetype 1's top slot 19); KAT test pins the exact degenerate visit sequences; fixed PRNG seed (`TestPrng` substreams 1005–1007, `LAIGE_TEST_SEED` overridable) with machine-greppable `iter-order … fnv1a=0x…` lines — the visit hash is byte-identical across all four scenario instantiations and across g++/Clang/ASan/TSan/release trees and overridden seeds; new `iter_order` CTest entry (the step's Verify command, added to the TSan property list) over the shared `laige-sim_tests` executable; no public API added — `laige-api.json` unchanged (452 symbols, scanner rerun clean), no include-graph change (comments only; `tools/laige-include-lint` OK); API contract in `docs/api/iteration_order.md` (+ cross-refs in query.md, entity.md, archetype.md, component_registry.md, docs/README, sim README); local Verify: `ctest -R iter_order` green on `build` (g++), `build-asan`, `build-tsan`, `build-clang`, `build-release`; full `laige-sim_tests` suite green on `build` | ---