ecs: std/ecs data layer: entities, columns, stores, queries, hierarchy, events (step 1 of #89) #453

Closed
opened 2026-10-24 00:18:39 +00:00 by orion · 7 comments
Collaborator

Context

Step 1 of 3 of #89 (ECS slice 1). The specification is ECS.md sections 3, 4, 5, 7 (data layer, queries, structural change, hierarchy) and section 6, "Resources and events"; the owner's decisions are section 15 (Store/Columns, pairs/triples/quadruples, Events<Event> in the package, no World in std/ecs). The code form exists in examples/ecs-probe-2/src/ecs.trb and space.trb (depthOrder); this issue moves it into a new std package, completes it and pins it with tests. System/Stage/Schedule (step 2) and std/transform (step 3) are not part of it.

Goal

A new package std/ecs (std/ecs/project.trb, name = "std/ecs", no dependency beyond std/core and std/collections; no shared type, nothing that knows a position) with the data layer of a world: Entity, Entities, Column<Component>, Store<Component>, Parent and depthOrder, the four query arities, Events<Event> and ComponentKey.

Depends on

Nothing unpublished. Verified on main (bc504c24): compiler gaps 1, 3, 4, 5 and 11 of ECS.md section 12 are closed, package imports resolve from a conformance program (use ... from "std/collections" is used by tests/conformance), and std packages need no registration in the compiler (a directory under std/ with a project.trb, as std/linear). No seed impact: no syntax, no native, no driver flag, no std name the compiler looks up by string.

Decisions (refinement)

  • Layout: std/ecs/src/lib.trb re-exports (public use ... from "./x") from entity.trb (Entity, Entities), column.trb, store.trb (Store, ComponentKey), query.trb, hierarchy.trb (Parent, depthOrder), events.trb. Tests in std/ecs/tests/*.test.trb.
  • Entity exactly as ECS.md section 3 (index, generation = 0, Equals, Hash, Show). Column and Store as in the probe, plus each(body: (var value: Component) => Void) on the column (a closure binds var, closures.md rule 9); a row visited by each counts as written (stamped with one clock tick), as Bevy marks mutable access.
  • Entities is the allocator and the only place a generation changes: var fn spawn(): Entity (a freed index is reused first, last freed first, with generation + 1; otherwise the next new index, starting at 0), var fn despawn(entity: Entity): Bool (a stale or unknown handle changes nothing and answers false), fn isAlive(entity: Entity): Bool, fn length(): Int (live entities). Deterministic given the same operations, which is the lockstep rule of section 3 "Determinism". It does not touch columns: removing components on despawn is the program's world.
  • Query order: query, pairs, triples, quadruples walk the column of the first argument in insertion order and probe the others (never iterate an index). The user chooses the driving column by argument order; the order is a function of the operations, not of sizes. A query holds a copy of the groups for its duration (ECS.md section 5): for ... in pairs(...) may spawn, attach and detach on the live groups. Signatures follow the call sites of the document, one group argument per column (pairs(space, motion)), not the single Space the document prints in section 4; correct that signature block in the document in the same PR.
  • Generic queries are not proven by the probe (its queries are concrete, moving(world); its generic system names the component in the bound). Write query<Space, Component>(space: Space): Iterate<(Entity, Component)> where Space: Store<Component> and the three others first. If the checker cannot infer Component from the bound, ship the form with explicit type arguments at the call site (query<Motions, Lifetime>(motion)), state this in the PR and in ECS.md section 4, and do not touch the checker in this PR; the inference is then a note on #89, not new work here.
  • depthOrder: a free function depthOrder(parents: Column<Parent>, entities: Iterate<Entity>): List<Entity> (the group that owns the tree passes its own column and the entities it wants ordered; the probe's place recursion, made iterative or kept recursive with a depth that a test pins at 1 000 links). Every parent before its children, an entity listed once, order of the input otherwise. A cycle panics with the entity in the message (ECS.md section 7).
  • Events<Event>: two buffers, Bevy's shape. type EventCursor with Equals, Show { var seen: Int = 0 } is held by the consumer. var fn send(event: Event), fn read(var cursor: EventCursor): List<Event> (the events sent since the cursor, oldest first; moves the cursor to now; does not mutate the events, so a read-only system may take Events by value), var fn advance() (the frame boundary: the older buffer is dropped, the newer becomes the older), fn length(): Int. An event survives exactly two advance calls; a consumer that reads at least once per frame sees every event exactly once; one that skips frames loses the oldest and gets the rest.
  • ComponentKey: public type ComponentKey with Equals, Hash, Show { name: String }, written by hand by the package (ComponentKey("acme/game/Position")). No componentKey<Component>(); that is #90 and needs a compiler intrinsic.
  • Doc comments on every public item with an # Examples block (the probe's are the model); no semicolons, command calls where the canon says so.

Acceptance criteria

  • std/ecs exists as above; torb check std/ecs and torb format --check are clean; tools/check-commits.sh clean.
  • Entities: spawn/despawn/isAlive/length as decided; a despawned handle is not alive, its index comes back with generation + 1, the old handle misses in Column.get and has, and despawning it again answers false.
  • Column: put, remove (swap with the last row, rows fixed up, including removing the last row and removing a missing entity), get, has, length, ownerAt, valueAt, version, changedSince, each; insertion order is kept and a removal moves exactly one row. Tests cover changedSince after put, after each, after remove.
  • Store and the four query arities: tests with two hand-written column groups (a Places and a Motions, as the probe) and a generic system bounded by two Store bounds; query, pairs, triples and quadruples each answer the right tuples in the first column's order; a missing component drops the row; the with/without filter forms of ECS.md section 4 (pairs(...).filter { ... }) run.
  • Structural change during a query (ECS.md section 5): a system that spawns bullets while querying shooters, and one that detaches while querying lifetimes, are tests; the iteration is unaffected and the live groups hold the change. A snapshot is const snapshot = world, a rollback is an assignment (test on a program-shaped World value).
  • Parent and depthOrder: parent-first on a three-level tree given in child-first order, a forest, and an entity without a parent. A cycle panics naming the entity, pinned by tests/conformance/ecs-parent-cycle.trb with .exit and .stderr.
  • Events<Event> as decided, including the skipped-frame case and two independent cursors.
  • Gate, step 1 half: tests/conformance/ecs-data.trb (a world with two groups: spawn, attach, a system over two Store bounds, pairs, structural change in a query, depthOrder, an event round, snapshot and rollback) prints its result; tier B runs it in the VM and natively and both must give ecs-data.expected byte for byte. The .allocations budget is taken from the first forge CI run's report; a leak run is clean.
  • docs/standard-library/ecs.md and its line in docs/standard-library/index.md; skills/ regenerated (torb docs skill docs skills, --check is a tier A gate). ECS.md: the pairs signature block corrected, and section 13 slice 1 marked "data layer landed" with this issue.
  • Tier A and B green in forge CI.

Seed impact

None (see "Depends on").

Size

One PR, two Conventional Commits: feat(std/ecs): entities, columns, stores, queries, hierarchy and events (package, tests, the two conformance programs) and docs(std/ecs): standard-library page, skills and the design record (docs, regenerated skills/, ECS.md edits).

Part of #89.

## Context Step 1 of 3 of #89 (ECS slice 1). The specification is [ECS.md sections 3, 4, 5, 7](https://git.torb.dev/torbscript/language/src/commit/bc504c243bbc6c247fdac1b6776100c2d7ebd044/docs/design/ECS.md#L404) (data layer, queries, structural change, hierarchy) and [section 6, "Resources and events"](https://git.torb.dev/torbscript/language/src/commit/bc504c243bbc6c247fdac1b6776100c2d7ebd044/docs/design/ECS.md#L894); the owner's decisions are section 15 (`Store`/`Columns`, `pairs`/`triples`/`quadruples`, `Events<Event>` in the package, no `World` in `std/ecs`). The code form exists in [`examples/ecs-probe-2/src/ecs.trb`](https://git.torb.dev/torbscript/language/src/commit/bc504c243bbc6c247fdac1b6776100c2d7ebd044/examples/ecs-probe-2/src/ecs.trb) and `space.trb` (`depthOrder`); this issue moves it into a new std package, completes it and pins it with tests. `System`/`Stage`/`Schedule` (step 2) and `std/transform` (step 3) are not part of it. ## Goal A new package `std/ecs` (`std/ecs/project.trb`, `name = "std/ecs"`, no dependency beyond `std/core` and `std/collections`; no `shared type`, nothing that knows a position) with the data layer of a world: `Entity`, `Entities`, `Column<Component>`, `Store<Component>`, `Parent` and `depthOrder`, the four query arities, `Events<Event>` and `ComponentKey`. ## Depends on Nothing unpublished. Verified on `main` (bc504c24): compiler gaps 1, 3, 4, 5 and 11 of ECS.md section 12 are closed, package imports resolve from a conformance program (`use ... from "std/collections"` is used by `tests/conformance`), and std packages need no registration in the compiler (a directory under `std/` with a `project.trb`, as `std/linear`). **No seed impact**: no syntax, no native, no driver flag, no std name the compiler looks up by string. ## Decisions (refinement) - **Layout**: `std/ecs/src/lib.trb` re-exports (`public use ... from "./x"`) from `entity.trb` (`Entity`, `Entities`), `column.trb`, `store.trb` (`Store`, `ComponentKey`), `query.trb`, `hierarchy.trb` (`Parent`, `depthOrder`), `events.trb`. Tests in `std/ecs/tests/*.test.trb`. - **`Entity`** exactly as ECS.md section 3 (`index`, `generation = 0`, `Equals, Hash, Show`). `Column` and `Store` as in the probe, plus `each(body: (var value: Component) => Void)` on the column (a closure binds `var`, closures.md rule 9); a row visited by `each` counts as written (stamped with one clock tick), as Bevy marks mutable access. - **`Entities`** is the allocator and the only place a generation changes: `var fn spawn(): Entity` (a freed index is reused first, last freed first, with `generation + 1`; otherwise the next new index, starting at 0), `var fn despawn(entity: Entity): Bool` (a stale or unknown handle changes nothing and answers `false`), `fn isAlive(entity: Entity): Bool`, `fn length(): Int` (live entities). Deterministic given the same operations, which is the lockstep rule of section 3 "Determinism". It does not touch columns: removing components on despawn is the program's world. - **Query order**: `query`, `pairs`, `triples`, `quadruples` walk the column of the **first** argument in insertion order and probe the others (never iterate an index). The user chooses the driving column by argument order; the order is a function of the operations, not of sizes. A query holds a copy of the groups for its duration (ECS.md section 5): `for ... in pairs(...)` may spawn, attach and detach on the live groups. Signatures follow the call sites of the document, one group argument per column (`pairs(space, motion)`), not the single `Space` the document prints in section 4; correct that signature block in the document in the same PR. - **Generic queries are not proven by the probe** (its queries are concrete, `moving(world)`; its generic system names the component in the bound). Write `query<Space, Component>(space: Space): Iterate<(Entity, Component)> where Space: Store<Component>` and the three others first. If the checker cannot infer `Component` from the bound, ship the form with explicit type arguments at the call site (`query<Motions, Lifetime>(motion)`), state this in the PR and in ECS.md section 4, and do not touch the checker in this PR; the inference is then a note on #89, not new work here. - **`depthOrder`**: a free function `depthOrder(parents: Column<Parent>, entities: Iterate<Entity>): List<Entity>` (the group that owns the tree passes its own column and the entities it wants ordered; the probe's `place` recursion, made iterative or kept recursive with a depth that a test pins at 1 000 links). Every parent before its children, an entity listed once, order of the input otherwise. A cycle panics with the entity in the message (ECS.md section 7). - **`Events<Event>`**: two buffers, Bevy's shape. `type EventCursor with Equals, Show { var seen: Int = 0 }` is held by the consumer. `var fn send(event: Event)`, `fn read(var cursor: EventCursor): List<Event>` (the events sent since the cursor, oldest first; moves the cursor to now; does not mutate the events, so a read-only system may take `Events` by value), `var fn advance()` (the frame boundary: the older buffer is dropped, the newer becomes the older), `fn length(): Int`. An event survives exactly two `advance` calls; a consumer that reads at least once per frame sees every event exactly once; one that skips frames loses the oldest and gets the rest. - **`ComponentKey`**: `public type ComponentKey with Equals, Hash, Show { name: String }`, written by hand by the package (`ComponentKey("acme/game/Position")`). No `componentKey<Component>()`; that is #90 and needs a compiler intrinsic. - **Doc comments** on every public item with an `# Examples` block (the probe's are the model); no semicolons, command calls where the canon says so. ## Acceptance criteria - [ ] `std/ecs` exists as above; `torb check std/ecs` and `torb format --check` are clean; `tools/check-commits.sh` clean. - [ ] `Entities`: spawn/despawn/isAlive/length as decided; a despawned handle is not alive, its index comes back with `generation + 1`, the old handle misses in `Column.get` and `has`, and despawning it again answers `false`. - [ ] `Column`: `put`, `remove` (swap with the last row, `rows` fixed up, including removing the last row and removing a missing entity), `get`, `has`, `length`, `ownerAt`, `valueAt`, `version`, `changedSince`, `each`; insertion order is kept and a removal moves exactly one row. Tests cover `changedSince` after `put`, after `each`, after `remove`. - [ ] `Store` and the four query arities: tests with two hand-written column groups (a `Places` and a `Motions`, as the probe) and a generic system bounded by two `Store` bounds; `query`, `pairs`, `triples` and `quadruples` each answer the right tuples in the first column's order; a missing component drops the row; the with/without filter forms of ECS.md section 4 (`pairs(...).filter { ... }`) run. - [ ] Structural change during a query (ECS.md section 5): a system that spawns bullets while querying shooters, and one that detaches while querying lifetimes, are tests; the iteration is unaffected and the live groups hold the change. A snapshot is `const snapshot = world`, a rollback is an assignment (test on a program-shaped `World` value). - [ ] `Parent` and `depthOrder`: parent-first on a three-level tree given in child-first order, a forest, and an entity without a parent. A cycle panics naming the entity, pinned by `tests/conformance/ecs-parent-cycle.trb` with `.exit` and `.stderr`. - [ ] `Events<Event>` as decided, including the skipped-frame case and two independent cursors. - [ ] **Gate, step 1 half**: `tests/conformance/ecs-data.trb` (a world with two groups: spawn, attach, a system over two `Store` bounds, `pairs`, structural change in a query, `depthOrder`, an event round, snapshot and rollback) prints its result; tier B runs it in the VM and natively and both must give `ecs-data.expected` byte for byte. The `.allocations` budget is taken from the first forge CI run's report; a leak run is clean. - [ ] `docs/standard-library/ecs.md` and its line in `docs/standard-library/index.md`; `skills/` regenerated (`torb docs skill docs skills`, `--check` is a tier A gate). ECS.md: the `pairs` signature block corrected, and section 13 slice 1 marked "data layer landed" with this issue. - [ ] Tier A and B green in forge CI. ## Seed impact None (see "Depends on"). ## Size One PR, two Conventional Commits: `feat(std/ecs): entities, columns, stores, queries, hierarchy and events` (package, tests, the two conformance programs) and `docs(std/ecs): standard-library page, skills and the design record` (docs, regenerated `skills/`, ECS.md edits). Part of #89.
Author
Collaborator

[loop:torbscript] decided by the loop: step 1 of the split of #89. Slice 1 plus std/transform is a package, a scheduler with a native gate and a second package, far beyond one PR of one or two commits, so the first step is the data layer of std/ecs and the rest is the checklist of #89 (issues filed when they start; the backlog gate is off). Technical choices made here, all inside ECS.md and without a question to the owner: Entities allocator semantics (reuse with generation + 1, stale handle despawns to false), query order = the first argument's column in insertion order (deterministic, no size-dependent order), depthOrder as a free function over Column<Parent>, Events with consumer-held EventCursor and a two-buffer advance, each stamps the rows it visits. Finding: the probe's queries are concrete, so inferring Component from a Store<Component> bound in a generic query is unproven; the issue gives the explicit-type-argument form as the fallback instead of a checker change. No seed impact (no syntax, native, driver flag or looked-up std name), no collision with a design record, no owner decision needed (every product choice for slice 1 is in ECS.md section 15). Definition of ready met, labelled ready. Part of #89.

[loop:torbscript] decided by the loop: step 1 of the split of #89. Slice 1 plus `std/transform` is a package, a scheduler with a native gate and a second package, far beyond one PR of one or two commits, so the first step is the data layer of `std/ecs` and the rest is the checklist of #89 (issues filed when they start; the backlog gate is off). Technical choices made here, all inside ECS.md and without a question to the owner: `Entities` allocator semantics (reuse with `generation + 1`, stale handle despawns to `false`), query order = the first argument's column in insertion order (deterministic, no size-dependent order), `depthOrder` as a free function over `Column<Parent>`, `Events` with consumer-held `EventCursor` and a two-buffer `advance`, `each` stamps the rows it visits. Finding: the probe's queries are concrete, so inferring `Component` from a `Store<Component>` bound in a generic `query` is unproven; the issue gives the explicit-type-argument form as the fallback instead of a checker change. No seed impact (no syntax, native, driver flag or looked-up std name), no collision with a design record, no owner decision needed (every product choice for slice 1 is in ECS.md section 15). Definition of ready met, labelled `ready`. Part of #89.
Author
Collaborator

[loop:torbscript] claim implement by session:loop-torbscript-20261024 until 2026-10-24T02:25:29Z

[loop:torbscript] claim implement by session:loop-torbscript-20261024 until 2026-10-24T02:25:29Z
Author
Collaborator

[loop:torbscript] implement: std/ecs data layer (76 tests, two conformance programs, docs page, skills) is in #456 (Closes #453); forge CI was still queued at 02:52. Open: tests/conformance/ecs-data.allocations from the first CI report, and the native panic-frame format of ecs-parent-cycle.stderr. next: waiting (CI green and the allocation budget)

[loop:torbscript] implement: std/ecs data layer (76 tests, two conformance programs, docs page, skills) is in https://git.torb.dev/torbscript/language/pulls/456 (Closes #453); forge CI was still queued at 02:52. Open: tests/conformance/ecs-data.allocations from the first CI report, and the native panic-frame format of ecs-parent-cycle.stderr. next: waiting (CI green and the allocation budget)
Author
Collaborator

[loop:torbscript] claim fix by session:loop-torbscript-20261024 until 2026-10-24T04:46:21Z

[loop:torbscript] claim fix by session:loop-torbscript-20261024 until 2026-10-24T04:46:21Z
Author
Collaborator

[loop:torbscript] fix: tier A of b26ebea2 had two red gates, both docs: docs check (docs/standard-library/ecs.md summary 242 > 240 characters, and sum += ... in a check block, which the parser rejects; 3e3119fa/a2a3c192 shorten the summary and write sum = sum + position.x, generated skills/index copies synced) and docs source --panics (depthOrder had no # Panics section; 3e3119fa). Everything else in tier A was green. Still open, only visible once tier B runs (it was skipped behind tier A): the ecs-data.allocations count and the ecs-parent-cycle.stderr panic frames. next: waiting (forge CI of a2a3c192)

[loop:torbscript] fix: tier A of b26ebea2 had two red gates, both docs: `docs check` (docs/standard-library/ecs.md summary 242 > 240 characters, and `sum += ...` in a check block, which the parser rejects; 3e3119fa/a2a3c192 shorten the summary and write `sum = sum + position.x`, generated skills/index copies synced) and `docs source --panics` (depthOrder had no `# Panics` section; 3e3119fa). Everything else in tier A was green. Still open, only visible once tier B runs (it was skipped behind tier A): the `ecs-data.allocations` count and the `ecs-parent-cycle.stderr` panic frames. next: waiting (forge CI of a2a3c192)
Author
Collaborator

[loop:torbscript] claim fix by session:loop-torbscript-20261024 until 2026-10-24T07:26:45Z

[loop:torbscript] claim fix by session:loop-torbscript-20261024 until 2026-10-24T07:26:45Z
Author
Collaborator

[loop:torbscript] fix: Argus's REQUEST_CHANGES on #456 (review 384) has one blocker: tests/conformance/ecs-data.allocations is missing and ecs-data / ecs-parent-cycle have never run natively (tier B skipped by the path filter). This box has no C compiler, so the count comes from forge CI: the throwaway probe PR #462 (head e9c6269a, on top of a2a3c192, widens the tier B trigger) is queued. Its runs 1616/1617 are still waiting behind runs 1611-1615 (two main pushes and PRs #460/#457) on the single runner, so tier B has not started. Nothing was changed on #456 this round.

Remaining, once probe tier B has run: (1) take the ecs-data allocation count and commit tests/conformance/ecs-data.allocations on loop/453-std-ecs-data-layer; (2) check the ecs-parent-cycle native and --vm result and fix .stderr frames if tier B shows a diff; (3) close #462 with a comment; (4) reply to the review and re-request Argus.

next: waiting (probe CI #462 queued behind the single runner)

[loop:torbscript] fix: Argus's REQUEST_CHANGES on #456 (review 384) has one blocker: `tests/conformance/ecs-data.allocations` is missing and `ecs-data` / `ecs-parent-cycle` have never run natively (tier B skipped by the path filter). This box has no C compiler, so the count comes from forge CI: the throwaway probe PR #462 (head e9c6269a, on top of a2a3c192, widens the tier B trigger) is queued. Its runs 1616/1617 are still `waiting` behind runs 1611-1615 (two `main` pushes and PRs #460/#457) on the single runner, so tier B has not started. Nothing was changed on #456 this round. Remaining, once probe tier B has run: (1) take the `ecs-data` allocation count and commit `tests/conformance/ecs-data.allocations` on `loop/453-std-ecs-data-layer`; (2) check the `ecs-parent-cycle` native and `--vm` result and fix `.stderr` frames if tier B shows a diff; (3) close #462 with a comment; (4) reply to the review and re-request Argus. next: waiting (probe CI #462 queued behind the single runner)
argus closed this issue 2026-10-24 22:11:34 +00:00
Sign in to join this conversation.
No labels
loop
ready
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
torbscript/language#453
No description provided.