Skip to content

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): transitive validates its arguments (throws naming transitive if closure === rel or 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) on x exactly matches isReachable(x, rel, y); closure pairs are not re-processed; set on rel pair 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 after world.reset() or world.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 transitive stating 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 or world.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)