W-063: ecs-relations materialized closure (REL-F03)¶
Type: feature · Size: M · Epic: E-relations-v0 · Target: packages/ecs-relations · Breaking: no Depends: W-060, W-062 Refs: spec REL-040..REL-054 (group REL-F03; KL1, KL2, KL4, KL9); REL-053 is the README section
Goal¶
Implement transitive(world, rel, { closure }) in packages/ecs-relations: per-world hooks keep the store-less, non-exclusive closure relation equal to reachability under rel, so world.query(closure(y)) lists every entity that reaches y. Uses isReachable from W-062 for the invariant check.
Steps¶
- S1 (REL-040, REL-041):
transitivevalidates its arguments (throws namingtransitiveifclosure === relor the world is not initialized, and installs no hook), returns an unsubscribe function, and reflects existing pairs in the closure at install; files: packages/ecs-relations/src/transitive.ts, packages/ecs-relations/src/index.ts, packages/ecs-relations/tests/transitive-install.test.ts - S2 (REL-042, REL-047): add hook recomputes the closure for the affected sources (the source of the new pair and its sources, found with
world.query(rel(x))), diffing against current closure targets; the chain adds in any order give the same closure; a cycle gives self closure pairs; files: packages/ecs-relations/src/transitive.ts, packages/ecs-relations/tests/transitive-add.test.ts - S3 (REL-043, REL-044, REL-045): remove hook recomputes while ignoring the edge being removed; alternative paths keep their closure pairs; destroying a middle entity removes only the pairs with no other path and does not throw; files: packages/ecs-relations/src/transitive.ts, packages/ecs-relations/tests/transitive-remove.test.ts
- S4 (REL-046, REL-050, REL-051, REL-054): randomized check over adds, removes, and destroys that
closure(y)onxexactly matchesisReachable(x, rel, y); closure pairs are not re-processed;setonrelpair data does not change the closure; removing an absent pair leaves the closure unchanged; files: packages/ecs-relations/tests/transitive-invariant.test.ts, packages/ecs-relations/src/transitive.ts - S5 (REL-048, REL-049, REL-052):
world.query(closure(y))returns exactly the reaching entities; unsubscribe removes the hooks, leaves the closure stale, is idempotent, and is safe afterworld.reset()orworld.destroy();world.reset()with hooks installed does not throw and a re-install starts from the empty state; files: packages/ecs-relations/tests/transitive-query.test.ts, packages/ecs-relations/src/transitive.ts - S6 (REL-053 [manual]; changeset): README section for
transitivestating the closure contract (dedicated, non-exclusive, store-less, written only by the package), the per-change cost, and that the closure is not maintained after unsubscribe orworld.reset(); files: packages/ecs-relations/README.md, .changeset/w-063-ecs-relations-closure.md (patch,@workspace/ecs-relations)
Log¶
- 2026-11-01 created (Triage), split from the approved spec W-008
- 2026-11-01 placed in Later (Depends W-060 and W-062 not done)