ecs: std/ecs schedule: System, Stage, Schedule and the ecs-schedule gate (step 2 of #89) #763

Closed
opened 2026-11-13 19:08:24 +00:00 by orion · 5 comments
Collaborator

Context

Step 2 of 3 of #89 (ECS slice 1). The specification is ECS.md section 6 ("The access set is declared", "Stages and the fixed step") and the owner's decisions in section 15 (q3: no World in std/ecs; q8: the frame loop lives in std/scene, std/ecs knows only Schedule.run). Step 1 (#453, merged) shipped the data layer in std/ecs including ComponentKey as a hand-written string. This step adds the systems, stages and schedule to the same package. std/transform (step 3) is not part of it.

Goal

System<World>, Stage, Schedule<World> in std/ecs with a sequential runner, a fixed-step accumulator over Duration and a conflict diagnostic, and the gate of slice 1: a conformance program that steps a world through a schedule for several frames and prints the same bytes in the VM and natively.

Depends on

#453 (merged). Nothing unpublished; std/time (Duration) exists on main. std/ecs gains a dependency on std/time in its project.trb. No seed impact: no syntax, no native, no driver flag, no std name the compiler looks up by string.

Requirements

  • R1. The package shall provide public type System<World> with name: String, reads: Set<ComponentKey> = [], writes: Set<ComponentKey> = [] and run: (var world: World, elapsed: Duration) => Void, as in ECS.md section 6. The access sets are a documented promise; the checker does not verify them.
  • R2. The package shall provide Stage as a value naming a stage: the four built-in Stage.input, Stage.fixed, Stage.update, Stage.late, in that run order, and Stage(name:) for a user stage.
  • R3. Schedule<World> shall hold the stages in run order and the systems of each stage in declaration order; var fn add(system: System<World>, to stage: Stage) appends a system, and var fn addStage(stage: Stage, after other: Stage) inserts a user stage (an unknown other panics naming it). Declaration order is the only order; no sorting, no hashing of iteration order.
  • R4. When Schedule.run(var world: World, elapsed: Duration) is called, the schedule shall run Input, then add elapsed to the accumulator and run Fixed once per whole step contained in it (zero or more times, each with elapsed = step, subtracting step each time), then Update, then Late, and user stages at their position. Input, Update, Late and user stages receive the frame's elapsed.
  • R5. The accumulator shall be a Duration (exact count, no Float); step defaults to 16 ms and is a field; step of zero or less panics at run (it would not terminate). The remainder after a frame stays in the accumulator and carries into the next frame. A cap of maxStepsPerFrame: Int = 8 bounds the catch-up: more whole steps than the cap drop the excess from the accumulator (the spiral-of-death guard of Bevy and Unity) and the dropped count is readable as droppedSteps.
  • R6. When two systems of one stage declare the same key in writes, or one writes a key the other reads, Schedule.conflicts(): List<Conflict> shall list the pair (Conflict with stage, first, second, key, Equals, Show); the runner never refuses to run and never reorders because of it (ECS.md: until the fork-join barrier exists the access sets are checked for a conflict and otherwise only documented). Systems in different stages never conflict.
  • R7. The schedule shall be deterministic: the same operations and the same elapsed sequence give the same system calls in the same order on every machine and in both back ends.
  • R8. Every public item shall carry a doc comment with an # Examples block; no World type, resource or frame loop is shipped (ECS.md q3, q8).

Decisions (by the loop, refinement)

  • Stage is a struct value, not an enum, because a program adds its own stages (ECS.md: "a program may add its own"); the four built-ins are public const members, equality by name. A stage added twice or a system added to an unknown stage panics naming it.
  • Where the conflict check runs: on demand through conflicts(), not at add, so a program can add systems in any order and a test can pin the exact pair list. A Schedule.check() that panics on a non-empty list is deliberately not added now; slice 4 (key from componentKey<>(), ECS.md section 13) owns turning the report into a diagnostic.
  • Why the cap (R5): without it an elapsed of an hour runs the fixed stage 225 000 times; the cap is the standard answer and costs one field. maxStepsPerFrame is the loop's technical choice, not a product question; it is a field a program may raise.
  • Fixed systems get elapsed = step (not the frame's), so a fixed system is a pure function of the step count, which is what the lockstep rule of ECS.md section 3 "Determinism" needs.
  • Layout: std/ecs/src/system.trb (System, Conflict), stage.trb (Stage), schedule.trb (Schedule), re-exported from lib.trb; tests in std/ecs/tests/schedule.test.trb.
  • The access-set key type is the hand-written ComponentKey from #453 (ComponentKey("acme/game/Position")); componentKey<T>() is #90.

Acceptance criteria

  • std/ecs has System, Stage, Schedule, Conflict as above; std/ecs/project.trb depends on std/time; torb check std/ecs and torb format --check are clean; tools/check-commits.sh clean.
  • Run order: a test with systems appending their name to a log across Input, Fixed, Update, Late and one user stage added after a built-in shows the exact order; two systems in one stage run in declaration order.
  • Fixed step: with step = 10 ms, frames of 4, 4, 4, 25 and 0 ms give fixed-run counts 0, 0, 1, 2, 0 (accumulator after each frame 4, 8, 2, 7, 7 ms, pinned in the test), fixed systems see elapsed == step, the others the frame's elapsed; the accumulator after each frame equals the expected Duration exactly (a test of 3 600 frames of equal elapsed shows no drift: total fixed time equals step * count + remainder).
  • Cap: an elapsed of 1 s with step = 10 ms and maxStepsPerFrame = 8 runs Fixed 8 times, sets droppedSteps to 92 and leaves the accumulator below step; step <= 0 panics (a .trb test or a conformance program with .exit/.stderr).
  • conflicts(): write/write and write/read pairs of one stage are reported with stage, both system names and the key, in declaration order; read/read, different stages and disjoint keys report nothing; running a conflicting schedule still runs every system.
  • A user stage added after an unknown stage, a system added to an unknown stage, and a stage added twice each panic naming the stage.
  • Gate, step 2: tests/conformance/ecs-schedule.trb declares its own World (two Columns groups as in ecs-data), three to four systems across Input, Fixed, Update and Late (one writing, one reading the same key in a later stage), a fixed step, and steps the world for at least eight frames with uneven elapsed values, printing the world and the system log after each frame plus conflicts() of a deliberately conflicting second schedule. Tier B runs it in the VM and natively and both must give ecs-schedule.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 gains the schedule section (the frame-loop pattern of ten lines, labelled as the program's own, q8); skills/ regenerated (torb docs skill docs skills, --check is a tier A gate); ECS.md section 13 slice 1 marked "data layer and schedule landed" with #453 and this issue; #89 step 2 ticked by the PR text (Refs #89).
  • Tier A and B green in forge CI.

Seed impact

None (see "Depends on").

Size and model

One PR, two Conventional Commits: feat(std/ecs): systems, stages and the sequential schedule (package, tests, conformance program) and docs(std/ecs): schedule page, skills and the design record. Implementer: sonnet, medium (std/ package, no compiler change).

Part of #89 (Refs #89, not Closes).

## Context Step 2 of 3 of #89 (ECS slice 1). The specification is [ECS.md section 6](https://git.torb.dev/torbscript/language/src/branch/main/docs/design/ECS.md) ("The access set is declared", "Stages and the fixed step") and the owner's decisions in section 15 (q3: no `World` in `std/ecs`; q8: the frame loop lives in `std/scene`, `std/ecs` knows only `Schedule.run`). Step 1 (#453, merged) shipped the data layer in `std/ecs` including `ComponentKey` as a hand-written string. This step adds the systems, stages and schedule to the same package. `std/transform` (step 3) is not part of it. ## Goal `System<World>`, `Stage`, `Schedule<World>` in `std/ecs` with a sequential runner, a fixed-step accumulator over `Duration` and a conflict diagnostic, and the **gate of slice 1**: a conformance program that steps a world through a schedule for several frames and prints the same bytes in the VM and natively. ## Depends on #453 (merged). Nothing unpublished; `std/time` (`Duration`) exists on `main`. `std/ecs` gains a dependency on `std/time` in its `project.trb`. **No seed impact**: no syntax, no native, no driver flag, no std name the compiler looks up by string. ## Requirements - R1. The package shall provide `public type System<World>` with `name: String`, `reads: Set<ComponentKey> = []`, `writes: Set<ComponentKey> = []` and `run: (var world: World, elapsed: Duration) => Void`, as in ECS.md section 6. The access sets are a documented promise; the checker does not verify them. - R2. The package shall provide `Stage` as a value naming a stage: the four built-in `Stage.input`, `Stage.fixed`, `Stage.update`, `Stage.late`, in that run order, and `Stage(name:)` for a user stage. - R3. `Schedule<World>` shall hold the stages in run order and the systems of each stage in declaration order; `var fn add(system: System<World>, to stage: Stage)` appends a system, and `var fn addStage(stage: Stage, after other: Stage)` inserts a user stage (an unknown `other` panics naming it). Declaration order is the only order; no sorting, no hashing of iteration order. - R4. When `Schedule.run(var world: World, elapsed: Duration)` is called, the schedule shall run `Input`, then add `elapsed` to the accumulator and run `Fixed` once per whole `step` contained in it (zero or more times, each with `elapsed = step`, subtracting `step` each time), then `Update`, then `Late`, and user stages at their position. `Input`, `Update`, `Late` and user stages receive the frame's `elapsed`. - R5. The accumulator shall be a `Duration` (exact count, no `Float`); `step` defaults to 16 ms and is a field; `step` of zero or less panics at `run` (it would not terminate). The remainder after a frame stays in the accumulator and carries into the next frame. A cap of `maxStepsPerFrame: Int = 8` bounds the catch-up: more whole steps than the cap drop the excess from the accumulator (the spiral-of-death guard of Bevy and Unity) and the dropped count is readable as `droppedSteps`. - R6. When two systems of one stage declare the same key in `writes`, or one writes a key the other reads, `Schedule.conflicts(): List<Conflict>` shall list the pair (`Conflict` with `stage`, `first`, `second`, `key`, `Equals, Show`); the runner never refuses to run and never reorders because of it (ECS.md: until the fork-join barrier exists the access sets are checked for a conflict and otherwise only documented). Systems in different stages never conflict. - R7. The schedule shall be deterministic: the same operations and the same `elapsed` sequence give the same system calls in the same order on every machine and in both back ends. - R8. Every public item shall carry a doc comment with an `# Examples` block; no `World` type, resource or frame loop is shipped (ECS.md q3, q8). ## Decisions (by the loop, refinement) - **`Stage` is a struct value, not an enum**, because a program adds its own stages (ECS.md: "a program may add its own"); the four built-ins are `public const` members, equality by name. A stage added twice or a system added to an unknown stage panics naming it. - **Where the conflict check runs**: on demand through `conflicts()`, not at `add`, so a program can add systems in any order and a test can pin the exact pair list. A `Schedule.check()` that panics on a non-empty list is deliberately not added now; slice 4 (key from `componentKey<>()`, ECS.md section 13) owns turning the report into a diagnostic. - **Why the cap** (R5): without it an `elapsed` of an hour runs the fixed stage 225 000 times; the cap is the standard answer and costs one field. `maxStepsPerFrame` is the loop's technical choice, not a product question; it is a field a program may raise. - **`Fixed` systems get `elapsed = step`** (not the frame's), so a fixed system is a pure function of the step count, which is what the lockstep rule of ECS.md section 3 "Determinism" needs. - **Layout**: `std/ecs/src/system.trb` (`System`, `Conflict`), `stage.trb` (`Stage`), `schedule.trb` (`Schedule`), re-exported from `lib.trb`; tests in `std/ecs/tests/schedule.test.trb`. - **The access-set key type** is the hand-written `ComponentKey` from #453 (`ComponentKey("acme/game/Position")`); `componentKey<T>()` is #90. ## Acceptance criteria - [ ] `std/ecs` has `System`, `Stage`, `Schedule`, `Conflict` as above; `std/ecs/project.trb` depends on `std/time`; `torb check std/ecs` and `torb format --check` are clean; `tools/check-commits.sh` clean. - [ ] Run order: a test with systems appending their name to a log across `Input`, `Fixed`, `Update`, `Late` and one user stage added `after` a built-in shows the exact order; two systems in one stage run in declaration order. - [ ] Fixed step: with `step = 10 ms`, frames of 4, 4, 4, 25 and 0 ms give fixed-run counts 0, 0, 1, 2, 0 (accumulator after each frame 4, 8, 2, 7, 7 ms, pinned in the test), fixed systems see `elapsed == step`, the others the frame's `elapsed`; the accumulator after each frame equals the expected `Duration` exactly (a test of 3 600 frames of equal `elapsed` shows no drift: total fixed time equals `step * count + remainder`). - [ ] Cap: an `elapsed` of 1 s with `step = 10 ms` and `maxStepsPerFrame = 8` runs `Fixed` 8 times, sets `droppedSteps` to 92 and leaves the accumulator below `step`; `step <= 0` panics (a `.trb` test or a conformance program with `.exit`/`.stderr`). - [ ] `conflicts()`: write/write and write/read pairs of one stage are reported with stage, both system names and the key, in declaration order; read/read, different stages and disjoint keys report nothing; running a conflicting schedule still runs every system. - [ ] A user stage added after an unknown stage, a system added to an unknown stage, and a stage added twice each panic naming the stage. - [ ] **Gate, step 2**: `tests/conformance/ecs-schedule.trb` declares its own `World` (two `Columns` groups as in `ecs-data`), three to four systems across `Input`, `Fixed`, `Update` and `Late` (one writing, one reading the same key in a later stage), a fixed step, and steps the world for at least eight frames with uneven `elapsed` values, printing the world and the system log after each frame plus `conflicts()` of a deliberately conflicting second schedule. Tier B runs it in the VM and natively and both must give `ecs-schedule.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` gains the schedule section (the frame-loop pattern of ten lines, labelled as the program's own, q8); `skills/` regenerated (`torb docs skill docs skills`, `--check` is a tier A gate); ECS.md section 13 slice 1 marked "data layer and schedule landed" with #453 and this issue; #89 step 2 ticked by the PR text (`Refs #89`). - [ ] Tier A and B green in forge CI. ## Seed impact None (see "Depends on"). ## Size and model One PR, two Conventional Commits: `feat(std/ecs): systems, stages and the sequential schedule` (package, tests, conformance program) and `docs(std/ecs): schedule page, skills and the design record`. Implementer: sonnet, medium (`std/` package, no compiler change). Part of #89 (`Refs #89`, not `Closes`).
Author
Collaborator

[loop:torbscript] claim implement by session:loop-torbscript-20261113 until 2026-11-13T21:09Z

[loop:torbscript] claim implement by session:loop-torbscript-20261113 until 2026-11-13T21:09Z
Author
Collaborator

[loop:torbscript] implement: #769, head ca829a915c, CI pending (gates, tier A, tier B still queued after 28 min), next: waiting (CI and review)

[loop:torbscript] implement: https://git.torb.dev/torbscript/language/pulls/769, head ca829a915cacc90158b17da9adb5ad539316f66c, CI pending (gates, tier A, tier B still queued after 28 min), next: waiting (CI and review)
Author
Collaborator

[loop:torbscript] release implement: CI still queued after the 30 minute wait, nothing more to do until it runs; tier B may need tests/conformance/ecs-schedule.allocations (no native build locally)

[loop:torbscript] release implement: CI still queued after the 30 minute wait, nothing more to do until it runs; tier B may need tests/conformance/ecs-schedule.allocations (no native build locally)
Author
Collaborator

[loop:torbscript] claim review by session:loop-torbscript-20261113 until 2026-11-14T01:10:00Z

[loop:torbscript] claim review by session:loop-torbscript-20261113 until 2026-11-14T01:10:00Z
argus closed this issue 2026-11-14 02:46:28 +00:00
Author
Collaborator

[post-merge] PR #769 merged (c4e91a10c, fixes on 3e3c127c3). Nothing live to verify: std/ecs is library code, the nightly-20261113 prerelease already carries it. Release step not done: no x.y.z tag exists yet (tags are nightly-YYYYMMDD only) and project.trb is still version 0.1.0, so release.yml (tag-triggered) has nothing to publish. Next step: version-bump PR on main (project.trb version, compiler/project.trb, compiler/src/package/version.trb languageVersion, editors/vscode/package.json), reviewer argus requested; after it is merged, tag the chosen x.y.z (a feature: minor bump) and check the release and images jobs. Owner: orion.

[post-merge] PR #769 merged (c4e91a10c, fixes on 3e3c127c3). Nothing live to verify: std/ecs is library code, the nightly-20261113 prerelease already carries it. Release step not done: no x.y.z tag exists yet (tags are nightly-YYYYMMDD only) and project.trb is still version 0.1.0, so release.yml (tag-triggered) has nothing to publish. Next step: version-bump PR on main (project.trb version, compiler/project.trb, compiler/src/package/version.trb languageVersion, editors/vscode/package.json), reviewer argus requested; after it is merged, tag the chosen x.y.z (a feature: minor bump) and check the release and images jobs. Owner: orion.
Sign in to join this conversation.
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#763
No description provided.