The M1 query/iteration API over the archetype SoA columns
(M1-ECS-04; PRD §9.1 S-3/S-4, §10.3, FR-12.3, AGENTS PERF-003/004/006,
API-004). Public headers: src/laige-sim/include/laige/sim/query.h
(Access, the Read/Write access tags, the detail::IdSet256
guard sets, the full contract) plus the World::each member template
in src/laige-sim/include/laige/sim/entity.h; implementation:
src/laige-sim/query.cpp (the iteration-guard helpers) + the
header-defined each body. Unit suite: ctest -R query
(tests/laige-sim/query_tests.cpp), including the 10k-entity
zero-allocation window whose machine-greppable stats lines land in
the ctest output on every run (CORE-001).
The PRD Appendix B sketch calls this ctx.each<Tag<Player>, Health>();
in M1 the query lives on the World that owns the storage, and
M1-SYS-01's SystemContext delegates to it (one world, one owner
thread). The sketch's argument order (access flags first, callable
last) is not the final syntax: C++ cannot deduce a parameter pack
that is not last, so the access tags follow the callable — one tag
per listed component, in template order, still declared per component.
world.each<ArchPos, ArchVel>(fn, laige::Read{}, laige::Write{});- Superset match: an archetype matches when every listed
component type is in its set; extra components do not exclude an
entity (an entity with
{Pos, Vel, Flag}is visited byeach<Pos, Vel>). - Reference hand-off:
fnis invoked once per matching entity asfn(Entity, R1, ..., RN)— one reference per listed component, in template order: aconst T&where the tag isRead, aT&where it isWrite(compile-time; a write through aReadreference requires a cast — a bug the compiler rejects, API-008). TheEntityhandle is the generation-checked live handle of the row's slot. - Tag discipline: exactly one
Read/Writetag per listed component, in template order; a mismatched type or count is a compile error (static_asserts ineach), not a runtime surprise. - Empty query:
world.each<>(fn)visits every live entity (the component-less ones included) in ascending slot-id order, with no component references. It iterates no archetype rows, so its guard matches no archetype and structural mutations remain legal under it. - Unregistered type: a listed component not registered in this
world matches nothing — the iteration runs zero times and returns
ok (a pure query, like
has<T>readingfalse). No archetype is created for the query.
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. 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 (M1-ECS-05).
A live each iteration carries a guard (two membership-only
256-bit sets, detail::IdSet256, stack-owned): the matched-archetype
set (computed before the first callback, so complete for the whole
iteration) and the read-component set (the queried components
declared Read). Mutations are checked before any side effect —
a rejected mutation is skipped, never applied, and the
iteration continues over the unmutated storage.
Mutation inside a live each |
Legal when |
|---|---|
in-place addComponent<T> (create-or-update overwrite) |
T is declared Write by the query, or T is not listed at all |
structural addComponent / removeComponent (archetype move) |
both the source AND the target archetype are outside the matched set |
destroy(e) |
e's archetype (0 if component-less) is outside the matched set |
clear() |
no matched archetype holds live rows |
create() |
always (touches no rows) |
write through a Write reference; in-place overwrite of a Write-declared component |
always (the intended mutation path) |
a nested each |
never |
The guard is live from the first callback to the last; after
each returns, the same structural mutation is legal again (the
suite pins both sides).
Debug builds assert (SIGABRT) on every violation — a loud
misuse crash, in the stale-handle precedent (CPP-012, S-9).
Release builds return ErrorCode::InvalidArgument from the
mutating call, log one rate-limited warn, and skip the mutation
(FR-12.3 degradation).
| Violation | Release return | Event (subsystem ecs) |
|---|---|---|
in-place write of a Read-declared queried component |
InvalidArgument (2) from the mutating call |
iteration_write_during_read |
structural move / destroy touching a matched archetype |
InvalidArgument (2) from the mutating call |
iteration_mutation |
clear() with live rows in a matched archetype |
InvalidArgument (2) from clear |
iteration_clear |
a nested each |
InvalidArgument (2) from the nested each |
iteration_nested |
No new ErrorCode values: the registry is stable and additive-only,
and all iteration-legality failures reuse InvalidArgument (the
stale-handle precedent). Every event carries the correlating fields:
entity_id, generation, plus component_id (write case) and
archetype_id / source_archetype / target_archetype / rows
(structural cases) — LOG-002.
Repeats are rate-limited per (subsystem, event, severity) with the
suppressed-count summary on shutdown (LOG-004); the suite pins
warn-once + the rate_limited drain (release builds — in debug the
assert fires before the log).
- Hot path: the archetype scan is O(kMaxArchetypes × N) with
N ≤ 32 listed components (N
columnIndexOfprobes per archetype, each a ≤ 32-entry lexicographic scan); visits are one cache-line stride per row. No heap allocation: the iteration state is stack-scoped and bounded —ids[N],cols[N],matchedIds[kMaxArchetypes], and two 256-bit guard sets (PERF-003, PERF-006: nostd::function, no hash map, no lock in the loop —fnis a template parameter, inlined). - Zero-alloc evidence (CORE-001):
QueryZeroAllocruns 10k entities ×{Pos, Vel}through a Read/Write pass (a legal in-place write per visit) and a Read/Read pass in a reset allocation-counter window (non-sanitizer trees): zero process-wide heap allocations, zero reservation delta (pool-steady), and prints the machine- greppablequery-iteration <stats>lines to the ctest output. The sanitizer trees cover the same loop leak-free; M1-ALLOC-01 lands the standing assertion. - Measured baseline (g++ 16.2.1, 2026-09, single-threaded
headless, Debug tree): 10k visits × 2 passes ≈ 0.88 ms total
(≈ 0.044 µs/visit,
-O0) — the M1-BENCH-01 tick baseline input; numbers are machine-dependent, the shape (flat, no spike, no allocation) is the tested property. - 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 (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).
- The callback runs synchronously on the owner thread with the guard live: the legal-mutation table above is the whole reentrancy contract (API-005) — everything else asserts in debug.
- Single owner thread (CONC-001);
eachis not thread-safe (M1 is single-threaded simulation — PRD §10.2). - Determinism (ARCH-010): the visit order is a pure function of the world state (archetype creation order × slot order); the guard sets are membership-only and never iterated; no floating point, no platform intrinsics. The same operation sequence produces bit-identical visit sequences on every platform.
// Setup (once): register the component types (M1-ECS-02).
ASSERT(world.registerComponent<ArchPos>().ok());
ASSERT(world.registerComponent<ArchVel>().ok());
// Per tick: iterate every {ArchPos, ArchVel} entity and update.
// Read/Read — a pure read pass (the guard rejects any write).
world.each<ArchPos, ArchVel>(
[&](laige::Entity e, const ArchPos& p, const ArchVel& v) {
/* read p, v — no mutation allowed while this runs */
},
laige::Read{}, laige::Read{});
// Per tick: integrate (Write-declared component: the reference store
// and an in-place addComponent overwrite are the intended paths).
world.each<ArchPos, ArchVel>(
[&](laige::Entity e, const ArchPos& p, ArchVel& v) {
v.v += static_cast<std::int64_t>(p.x); // legal: Write tag
},
laige::Read{}, laige::Write{});
// Empty query: every live entity, ascending slot order.
world.each<>([](laige::Entity e) { /* e.g. despawn sweep bookkeeping */ });- Writing through a
Readreference compiles only with a cast — that cast is a bug the debug guard will assert on (and the release guard will skip) if it reaches the storage through a World call; declare the componentWriteinstead. - Moving, destroying, or clearing an entity of a matched archetype from inside the callback invalidates the iteration the engine is mid-way through: it is rejected by design (assert/skip) — defer such mutations to an explicit despawn phase (API-004).
- The nested
eachis rejected because the outer guard's state would be clobbered by the inner scan; flatten the work into one query. eachreturnsStatus: the empty/valid iteration returns ok — the error path is the guard's rejection of a nested iteration only (a callback's own mutating call surfaces its ownStatus).- Holding references from a callback past the callback's end is
dangling once any mutation moves rows — copy out what you need
(the row storage is the same SoA columns
get<T>exposes).
- M1-ECS-01/02/03 (done): the entity handle, the component registry, and the archetype SoA columns this API iterates — entity.md, component_registry.md, archetype.md.
- M1-ECS-04 (this step): the query API + iteration legality above.
- 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 (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— thectx.eachof the PRD sketch delegates toWorld::each(one world, one owner thread). - M1-ALLOC-01: the standing zero-allocation assertion over the window this step measured.
- M1-BENCH-01: the tick budget uses the measured visit cost above as the iteration baseline.