Status: approved
ecs-relations¶
Summary¶
@workspace/ecs-relations v0 adds three groups of flecs relationship features on top of koota, which has none of them: (A) relation traits, symmetric (a pair R(a, b) is mirrored as R(b, a)) and transitive (isReachable(a, R, b) plus an optional materialized closure relation), (B) traversal helpers, up (find the first entity along a relation that has a trait, walking depth-first in entity-id order) and cascade (iterate matching entities ordered by relation depth), and (C) hierarchies, a ChildOf relation, a Name trait, and path lookup. Koota relations are opaque: they carry no trait flags, and koota hooks cannot veto or see the final state of a removal. So relation traits are installed per world through world.onAdd / world.onRemove hooks, not declared on the relation (concept §4, §6). The driving demo is apps/demo-ck3 (concept §8: transitive VassalOf, symmetric MarriedTo / AlliedWith, up, ChildOf with names). No docs/products/demo-ck3.md exists yet, so the requirements follow the concept mapping and the W-008 scope only.
References¶
- flecs:
- ComponentTraits.md: § Symmetric trait (adding
(R, Y)toXadds(R, X)toY, and removal is mirrored), § Transitive trait (R(A, B)andR(B, C)giveR(A, C)), § Reflexive trait (not ported), § Traversable trait (Acyclic, not ported). - Relationships.md: § The ChildOf relationship (children are deleted with the parent), § Namespacing (lookup by name, relative to a parent).
- HierarchiesManual.md: § Get parent and children, § Recursive cleanup, § Depth-first traversal, § Breadth-first traversal (
cascade), § Namespacing. - Queries.md: § Relationship Traversal (
upis depth-first: the first instance of the relationship is followed to its root before the second;Self/Upflags;Cascadeorders by hierarchy depth), § Transitive Relationships. - flecs tests
reference/flecs/test/core/src/Pairs.c:Pairs_add_symmetric_relation,Pairs_remove_symmetric_relation,Pairs_delete_entity_w_symmetric_relation,Pairs_add_symmetric_exclusive_relation(a symmetric exclusive relation moved fromBtoCdropsB(A)and givesC(A)). - flecs tests
reference/flecs/test/core/src/Hierarchies.c:Hierarchies_rel_path_from_root,Hierarchies_rel_path_from_self(path from self is the empty string);reference/flecs/test/core/src/Lookup.c(path lookup). - bitECS / becsy / miniplex: no relation traits to port. bitECS
Cascadeis the prior art for depth-ordered iteration (concept §3). - koota (public API only; verified in
reference/koota/packages/core/src): relation({ exclusive, autoDestroy, store }),Rel(target),Rel('*'),entity.add(Rel(t)),entity.remove(Rel(t)),entity.remove(Rel('*')),entity.has(Rel(t)),entity.targetsFor(Rel),entity.targetFor(Rel),world.query(Rel(target)),world.query(Rel('*')),world.onAdd(Rel, (source, target) => …),world.onRemove(Rel, (source, target) => …),world.has(entity),createQuery,Not,trait,entity.id().- Prior art for the hierarchy:
.github/skills/koota/references/relations.md(§ Hierarchies, § Finding ancestors, § Ordered Relations). - concept §4, §6, §8, package-rules.
Koota limits the package works within¶
The requirements below are written so that none of them needs $internal or a patch. Each limit is cited as KLn in the requirements.
- KL1 Relations are opaque.
relation()returns a callable that carries only internal context ($internal). There is no public way to attach a flag (symmetric, transitive, traversable) or to read its options (exclusive,autoDestroy,store). So the package cannot mark a relation, cannot tell whether it is exclusive, and cannot copy or check its pair data. Traits are installed per world by hooks (REL-001, REL-040) and callers must pass compatible relations (documented, REL-155). - KL2 Hook timing.
world.onAdd(Rel, cb)runs after the pair is added, with(source, target), for specific targets only (addingRel('*')is a no-op).world.onRemove(Rel, cb)runs before the pair is removed: inside the callbacksource.has(Rel(target))is stilltrue. It runs once per pair forremove(Rel(t)),remove(Rel('*')),remove(RelTrait), replacement of an exclusive pair, and the cleanup done when either end is destroyed or the world is reset (reference/koota/packages/core/src/trait/trait.ts:addRelationPair,removeRelationPair,cleanupRelationTarget,removeTrait;entity/entity.tsdestroyEntity). Consequences: a naive removal mirror recurses without end (a.removeruns the hook, which removesb → a, which runs the hook forb → a, which callsa.remove(Rel(b))whilea → bstill exists), so mirroring needs a re-entrancy guard (REL-010). A closure recomputed inside the hook must ignore the edge being removed (REL-043). The hook is also called when nothing is removed:removeRelationPairreturns early only if the entity has noRelpair at all, then calls the remove subscriptions for(entity, t)before checking that the entity hasRel(t). So ifentityhas someRelpair but notRel(t),entity.remove(Rel(t))fires the hook for(entity, t)withentity.has(Rel(t)) === false, and nothing is removed. User hooks see that spurious call and the package cannot suppress it. The hooks of this package MUST ignore a removal when!source.has(rel(target))(REL-015, REL-054). - KL3 Hooks cannot veto. An add cannot be rejected, so cycles in
ChildOfor any other relation, and duplicate sibling names, cannot be prevented by the package. Helpers must terminate on cycles instead (REL-064, REL-085, REL-108, REL-124). - KL4 Hooks are per world and do not survive
world.reset().reset()destroys all entities (hooks fire during that) and then clears the trait instances and their subscriptions (world/world.tsreset,clearTraitInstance). A package cannot re-install itself without the internalresetSubscriptions, so callers re-install after a reset (REL-152). - KL5 Re-adding an existing pair is a silent no-op (no hook). A mirror add therefore ends by itself, and a pair never exists twice.
- KL6 Reverse lookup and order. The only public reverse lookup is
world.query(Rel(target))(a snapshot of the sources).entity.targetsFor(Rel)is a snapshot in insertion order, except that a removal moves the last target into the freed slot (relation.tsremoveRelationTarget). Neither order is a contract, so the package sorts byentity.id()wherever order is observable (REL-063, REL-082, REL-105, REL-128).entity.id()is reused after a destroy, so an order by id is creation order only until ids are recycled. - KL7
Not()takes traits only, not pairs. There is no query for "has noChildOf". Roots are found withentity.targetFor(ChildOf) === undefined. There is no name index: path lookup scans the children of each parent, so it is linear in the number of children per segment, and linear in the number of named entities for the root segment (REL-135). - KL8 Entities have no public world accessor.
entity.targetsFor,get,has,addwork through koota'sNumber.prototypepatch. All helpers therefore takeworldfirst, which also lets them validate that an entity is alive (world.has(entity)) and run queries (REL-028, REL-067, REL-109, REL-134). - KL9
autoDestroyandexclusiveare koota's cleanup model.autoDestroy: 'orphan'(alias'source') destroys the sources of a pair when its target is destroyed, recursively, through a LIFO queue. The order of destruction inside a subtree is not specified (flecs deletes children before parents).exclusive: truereplaces the old target on add (hooks fire: remove, then add). - KL10 The wildcard is the string
'*'. There is noWildcardexport.Rel('*')works in queries andremove, not inadd(KL2). - KL11 Names and traversability do not exist. Traits and entities have no names (concept §4), and koota has no
Acyclic/Traversablechecks, soNameis an ordinary trait (REL-120) and traversal safety comes from visited sets. - KL12
ordered()(ordered children) is experimental and not used here.
Requirements¶
All requirements are [ecs] (model behaviour of the package) unless tagged [ux] (what a developer reading the docs or using the API sees). All are [test] (headless Vitest, fresh koota world per test) unless tagged [manual]. The groups below are sized so that Triage can create one feature item per group: F01 S, F02 S, F03 M, F04 S, F05 S, F06 S, F07 M, F08 S. Areas: A = relation traits (F01 to F03), B = traversal (F04, F05), C = hierarchies (F06, F07), plus F08 for package-wide rules. New IDs are numbered inside the group's range. The ranges do not follow creation order once edge cases are added.
Every group is buildable on its own: a group's requirements only use functions of its own group or of the groups it depends on. Dependencies: F03 depends on F02 (isReachable in REL-046), F07 depends on F06 (ChildOf, children), and F08 depends on F01 to F07 and is implemented last. Every error a function throws names that function (symmetric, transitive, up, parent, children, getPath, lookup) and leaves the world unchanged, and each requirement that throws says so in its own group. isReachable and cascade do not throw for dead entities or unused relations.
Terms:
- A pair R(a, b) is a relation R with source a and target b.
- Alive means world.has(entity).
- The targets of e under R are e.targetsFor(R).
- e reaches y under R if there is a path e → t1 → … → y of one or more R pairs. An entity reaches itself only through a cycle.
- The sources* of a under R are the entities that reach a (the transitive reverse), found with world.query(R(x)) (KL6).
Area A: Relation traits¶
REL-F01 Symmetric (S)¶
- REL-001 [ecs][test]:
symmetric(world, rel)MUST return an unsubscribe function and install hooks onworldforrel. After it,a.add(rel(b))MUST leave botha.has(rel(b))andb.has(rel(a))true(flecsPairs_add_symmetric_relation). - REL-002 [ecs][test]:
a.remove(rel(b))MUST leave botha.has(rel(b))andb.has(rel(a))false(flecsPairs_remove_symmetric_relation). Other pairs ofaandbMUST be unchanged. - REL-003 [ecs][test]:
a.remove(rel('*'))MUST remove everyrelpair ofaand every mirrored pairrel(a)on the former targets. Entities that were never related toaMUST be unchanged. - REL-004 [ecs][test]: Destroying
aMUST leave no entity withrel(a)and MUST NOT throw. The other side being destroyed first, or in the same cascade (autoDestroy), MUST NOT throw either (flecsPairs_delete_entity_w_symmetric_relation; KL2, KL9). - REL-005 [ecs][test]: Pairs that exist when
symmetric(world, rel)is called MUST be mirrored at install time, so the final state is symmetric for every existing pair. - REL-006 [ecs][test]: For an
exclusive: truerelation,a.add(rel(b))thena.add(rel(c))MUST leavea → candc → a, MUST removea → bandb → a. Ifcalready had a targetx, the mirrored add replaces it (exclusive), soxMUST loserel(c)(flecsPairs_add_symmetric_exclusive_relation). - REL-007 [ecs][test]: Self pair.
a.add(rel(a))MUST leave exactly one paira → aand MUST NOT recurse without end.a.remove(rel(a))MUST remove it. - REL-008 [ecs][test]: Adding a pair that already exists, in either direction, MUST be a no-op (KL5): no extra pair, no throw, and no second hook call for the existing pair.
- REL-009 [ecs][test]: User hooks (
world.onAdd(rel, cb),world.onRemove(rel, cb)) MUST fire exactly once per direction: once for(a, b)and once for(b, a)for one add, and likewise for one real remove (the pair exists). The order between the two calls is not specified. Whena.remove(rel(b))is called whileahas somerelpair but notrel(b), koota calls useronRemovehooks for(a, b)once (KL2) and the package MUST NOT add a call for(b, a)or any other call. - REL-010 [ecs][test]: Removal mirroring MUST terminate for every removal route:
remove(rel(b)),remove(rel('*')),remove(RelTrait), exclusive replacement (REL-006), destroy of either side, andworld.reset()(KL2). A re-entrancy guard keyed by the unordered pair is part of the install (state lives in the install closure, per world). The guard MUST be cleared when a removal finishes, also when it was ignored (REL-015). - REL-011 [ecs][test]: Pair data is not mirrored. If
relhas a store, the mirrored pair MUST get the store defaults, and a laterseton one side MUST NOT change the other (OQ4). - REL-012 [ecs][test]: The unsubscribe function MUST remove the hooks. After it, adds and removes MUST NOT be mirrored, and existing pairs MUST stay as they are. Calling it twice MUST be harmless, and calling it after
world.reset()orworld.destroy()MUST NOT throw (moved from REL-153). - REL-013 [ecs][test]: Calling
symmetric(world, rel)twice for the samerelMUST give the same observable state as one call (adds and removes are mirrored once, no throw, no recursion). - REL-014 [ecs][test]:
symmetric(world, rel)for one relation MUST NOT affect another relation, andsymmetriconworld1MUST NOT affectworld2, even with the samerelobject. - REL-015 [ecs][test]: The remove hook MUST ignore a call for
(source, target)when!source.has(rel(target))(KL2). Example:a → candc → aexist,ahas no pair withb, anda.remove(rel(b))is called. Nothing changes:a → c,c → astay, no pair ofbis removed, and the guard of REL-010 holds no stale key, so a later reala.add(rel(b))followed bya.remove(rel(b))is mirrored as in REL-002. - REL-016 [ecs][test]:
symmetricMUST throw, namingsymmetric, ifworldis not initialized (world.isInitializedisfalse, e.g. afterworld.destroy()), and MUST install no hook: the world is unchanged.
REL-F02 Transitive reachability (S)¶
- REL-020 [ecs][test]:
isReachable(world, a, rel, b)MUST betrueifareachesbunderrel, elsefalse. Fora → b → c:isReachable(a, c)istrue,isReachable(c, a)isfalse. The result is computed from the pairs in the world at call time. No registration (transitive) is needed (OQ2). - REL-021 [ecs][test]: A direct pair counts:
a → bgivesisReachable(a, b)true. - REL-022 [ecs][test]: Not reflexive.
isReachable(a, a)MUST befalseunlessareaches itself through a cycle (flecsReflexiveis out of scope). - REL-023 [ecs][test]: Diamonds and several targets. With
a → b,a → c,b → d,c → d:isReachable(a, d)istrue. Each entity is visited at most once, so the cost is linear in the reachable subgraph. - REL-024 [ecs][test]: Cycle safety. With
a → b → a(anda → a),isReachableMUST terminate and return the correct answer:isReachable(a, a)istrue, andisReachable(a, x)for unrelatedxisfalse. - REL-025 [ecs][test]: The answer MUST follow later changes: after
b.remove(rel(c)),isReachable(a, c)isfalse. There is no cache. - REL-026 [ecs][test]: Works for exclusive and non-exclusive relations, and for relations with a store.
- REL-027 [ecs][test]: Entities destroyed along a path break it: after destroying
bina → b → c,isReachable(a, c)isfalse. - REL-028 [ecs][test]:
isReachableMUST returnfalse, and MUST NOT throw, whenaorbis not alive inworld, including an entity of another world (a dead entity reaches and is reached by nothing; moved from REL-156).
REL-F03 Materialized closure (M)¶
The closure is an extra relation C chosen by the caller (OQ2). C(y) on x means x reaches y under rel. It makes "all x that reach y" a normal koota query: world.query(C(y)).
- REL-040 [ecs][test]:
transitive(world, rel, { closure })MUST return an unsubscribe function and install hooks onworldforrel. It MUST throw, namingtransitive, ifclosure === relor ifworldis not initialized, and MUST install no hook in that case.closureMUST be non-exclusive and have no store (KL1: not checked, documented in REL-053). - REL-041 [ecs][test]: Pairs that exist when
transitiveis called MUST be reflected in the closure at install time. - REL-042 [ecs][test]: Add. After
a → b,b → c,c → din any order of adds: the closure hasa → {b, c, d},b → {c, d},c → {d}and nothing else. Adding the middle link last (a → b,c → d, thenb → c) MUST give the same closure. - REL-043 [ecs][test]: Remove. After removing
b → cfrom the chain in REL-042, the closure hasa → {b},b → {},c → {d}. Removing a pair MUST be correct although koota runs the hook before the pair is gone (KL2): the recomputation ignores the edge being removed. - REL-044 [ecs][test]: Alternative paths keep closure pairs. With
a → b,a → c,b → d,c → d: removingb → dMUST keepa → d(still reachable throughc) and dropb → d. - REL-045 [ecs][test]: Destroying an entity in the middle of a chain MUST remove every closure pair whose only path went through it, and MUST leave closure pairs that have another path. It MUST NOT throw (KL2, KL9).
- REL-046 [ecs][test]: Invariant. After each operation in REL-042 to REL-045, for every pair of alive entities
(x, y):x.has(closure(y))istrueif and only ifisReachable(world, x, rel, y). A randomized test over adds, removes, and destroys SHOULD check this. - REL-047 [ecs][test]: Cycle. With
a → b → a, the closure hasa → {a, b}andb → {a, b}(a path of length ≥ 1 from an entity to itself exists). Breaking the cycle withb.remove(rel(a))leavesa → {b}. - REL-048 [ecs][test]:
world.query(closure(y))MUST return exactly the entities that reachy.world.query(closure(y), Trait)composes with other terms as in koota. - REL-049 [ecs][test]: Unsubscribe MUST remove the hooks. Later changes MUST NOT update the closure, and existing closure pairs MUST stay (stale). Calling it twice MUST be harmless, and calling it after
world.reset()orworld.destroy()MUST NOT throw (moved from REL-153). Callingtransitivetwice with the samerelandclosureMUST leave the closure correct (as REL-013). - REL-050 [ecs][test]:
closurepairs written by the package MUST NOT be mirrored or re-processed bysymmetricortransitivehooks ofrel. Hooks MUST only react torel, never toclosure. - REL-051 [ecs][test]:
relMAY have a store.seton pair data ofrelMUST NOT change the closure. - REL-052 [ecs][test]:
world.reset()MUST NOT throw while hooks are installed. After it, hooks are gone (KL4): a newrelpair does not update the closure, and callingtransitiveagain works on the new empty state. - REL-053 [ux][manual]: The README MUST have a section for
transitive(written with this feature) that states that:closuremust be a dedicated, non-exclusive, store-less relation that only the package writes; atransitivecall costs work proportional to the affected sources and their reach per change; the closure is not maintained after unsubscribe orworld.reset(). Verify by reading it. - REL-054 [ecs][test]: The remove hook MUST ignore a call for
(source, target)when!source.has(rel(target))(KL2). Example:a → cexists with the closurea → {c},ahas no pair withb, anda.remove(rel(b))is called. The closure is unchanged, nothing is recomputed forb, and the call does not throw.
Area B: Traversal¶
REL-F04 up (S)¶
- REL-060 [ecs][test]:
up(world, entity, rel, trait)MUST return the first entity found alongrelthat hastrait, orundefined. "First" is by the depth-first walk of REL-063, not the smallest distance fromentity. Example:ship ← cockpit ← pilotasChildOf,Massonship:up(world, pilot, ChildOf, Mass)returnsship(flecsUpwithChildOf). - REL-061 [ecs][test]: By default
entityitself is not considered (flecsUpwithoutSelf). With{ self: true },entityMUST be checked first, then its targets (flecsSelf | Up). - REL-062 [ecs][test]: If no entity on the walk has
trait,upMUST returnundefined. For an entity with no targets, that is the result (orentityitself with{ self: true }if it hastrait). - REL-063 [ecs][test]: The walk is depth-first. At every entity, the targets under
relare visited in ascendingentity.id()order, not inentity.targetsFor(rel)order (KL6). The first target is followed to its root before the next target is tried, and the first entity withtraitwins (flecs Relationship Traversal). The result MUST be the same for equal world state, whatever the order of earlier adds and removes. Example:x → p1 → g1andx → p2, withMassong1and onp2, andp1.id() < p2.id(): result isg1, althoughp2is nearer (distance 1,g1is at 2). Ifp2.id() < p1.id(), the result isp2. Addingx → p2beforex → p1, or removingx → p2and adding it again (which changes koota's target order), does not change either result. - REL-064 [ecs][test]: Cycles. If the walk reaches an entity a second time it MUST NOT follow it again.
upMUST terminate, and return a match if one is reachable, elseundefined(KL3). - REL-065 [ecs][test]: The result MUST follow later changes (reparenting, adding or removing
trait). There is no cache.upMUST NOT change the world. - REL-066 [ecs][test]:
upworks for exclusive and non-exclusive relations and for any plaintrait(tag, SoA, AoS). The caller reads the value withup(...)?.get(trait). - REL-067 [ecs][test]:
upMUST throw, namingup, ifentityis not alive inworld, including an entity of another world. The world is unchanged (moved from REL-156).
REL-F05 cascade (S)¶
- REL-080 [ecs][test]:
cascade(world, query, rel)MUST return an array of the entities that matchquery(acreateQuery(...)result or aQueryParameter[]), ordered by their depth underrel, lowest depth first. It MUST return each matching entity exactly once. - REL-081 [ecs][test]: Depth is
0for an entity with noreltargets, else1 + max(depth(t))over its targets. So every entity comes after all of itsreltargets that are in the result. WithChildOf: roots first, then their children, then grandchildren (flecs Breadth-first traversal,cascade). - REL-082 [ecs][test]: Entities of the same depth MUST be ordered by ascending
entity.id()(KL6). The result MUST be deterministic for equal world state. - REL-083 [ecs][test]: Entities that do not match
queryare not returned, but they MUST still count for depth. Withroot ← mid ← leafwheremiddoes not match,leafhas depth 2. - REL-084 [ecs][test]: The result is a snapshot. Changing the world while iterating the array MUST NOT change the array.
- REL-085 [ecs][test]: Cycles. Back edges on the current path are ignored when computing depth, so
cascadeMUST terminate and MUST return each matching entity once, including entities that are part of a cycle (KL3). - REL-086 [ecs][test]: Depth of an entity with several targets is the longest path (REL-081), so
a → b → canda → cgiveadepth 2. - REL-087 [ecs][test]:
cascadereturns[]for a query with no matches and does not throw for a relation that no entity uses. - REL-088 [ecs][test]:
cascadeMUST NOT change the world, and each call MUST reflect the currentrelpairs (no cache). - REL-089 [ecs][test]: Query semantics are koota's: entities with
IsExcludedMUST NOT be returned (asworld.query). Their pairs still count for the depth of other entities.
Area C: Hierarchies¶
REL-F06 ChildOf (S)¶
- REL-100 [ecs][test]:
ChildOfMUST be a koota relation created withexclusive: trueandautoDestroy: 'orphan'(KL1, KL9). An entity has at most one parent, and a parent's destruction destroys its children. - REL-101 [ecs][test]:
parent(world, child)MUST return the entitypfor whichchild.has(ChildOf(p)), orundefinedfor a root.children(world, p)MUST return every entitycwithc.has(ChildOf(p)). - REL-102 [ecs][test]: Reparenting.
child.add(ChildOf(p2))whilechildis a child ofp1MUST makep2the parent, removechildfromchildren(world, p1), and add it tochildren(world, p2). The entity MUST keep all other traits (flecs:ChildOfis exclusive). - REL-103 [ecs][test]: Destroying a parent MUST destroy all descendants, at any depth, in the same call (flecs
Recursive cleanup, kootaautoDestroy). The order of destruction is not specified (KL9). The parent's own other relations MUST be cleaned as in koota. - REL-104 [ecs][test]: Destroying a child MUST leave the parent and its other children alive, and
children(world, parent)MUST no longer contain it. - REL-105 [ecs][test]:
children(world, p)MUST be ordered by ascendingentity.id()(KL6). It returns[]for a leaf and for an unused entity, and a fresh array each call (a snapshot). - REL-106 [ecs][test]: Order by id is deterministic and independent of the order in which
ChildOfwas added or removed in between (the swap-and-pop order of koota does not show). - REL-107 [ecs][test]: Canary for koota behaviour the package relies on:
world.query(ChildOf('*'))returns exactly the non-root entities, andworld.query(ChildOf(p))returns exactly the children ofp. - REL-108 [ecs][test]: Cycles are not prevented (KL3). With
a.add(ChildOf(b))andb.add(ChildOf(a))ora.add(ChildOf(a)),parentandchildrenMUST still return normally. Destroying a member of the cycle MUST NOT recurse without end (koota'sprocessedEntitiesset). The behaviour ofgetPath,up, andcascadeon cycles is specified and tested in their own groups (REL-124, REL-064, REL-085). - REL-109 [ecs][test]:
parentandchildrenMUST throw, naming the function, for an entity that is not alive inworld, including an entity of another world. The world is unchanged (moved from REL-156). - REL-110 [ux][manual]: The README MUST state that
ChildOfis plain koota (queries withChildOf(parent)work as documented), that cycles are the caller's responsibility, and that child order is by entity id (use kootaordered()if user order matters; out of scope). Verify by reading it.
REL-F07 Name, paths, and lookup (M)¶
- REL-120 [ecs][test]:
NameMUST be a koota SoA trait with one string field,value, default''. A named entity is an entity withNameand a non-emptyvalue. Entities are named, renamed, and read with plain koota calls (add(Name({ value })),set(Name, { value }),get(Name)). No other API is needed to name an entity. - REL-121 [ecs][test]:
getPath(world, entity)MUST return the names from the root toentityjoined by/. A named rootSpaceshipgivesSpaceship. Its childCockpitgivesSpaceship/Cockpit(flecsHierarchies_rel_path_from_root; separator: OQ5). - REL-122 [ecs][test]: An entity that is unnamed (no
Nameor an emptyvalue) MUST appear in a path as#<entity.id()>. Such segments are not resolved bylookup(REL-129). - REL-123 [ecs][test]:
getPath(world, entity, { from })MUST return the path ofentityrelative to the ancestorfrom, without the segment offrom(flecsHierarchies_rel_path_from_self: path from self is''). It MUST throw, naminggetPath, iffromis notentityor an ancestor of it. - REL-124 [ecs][test]:
getPathMUST throw, naminggetPath, if the parent chain ofentitycontains a cycle (REL-108). - REL-125 [ecs][test]:
getPathMUST throw, naminggetPath, if a name on the chain contains/, because the path would be ambiguous (OQ5). - REL-126 [ecs][test]:
lookup(world, path)MUST resolvepathsegment by segment. The first segment is matched against the roots, and each next segment against the children of the previous match. It MUST return the entity, orundefinedif any segment has no match. Matching is exact and case-sensitive. A root is an entity with a non-emptyNameand noChildOftarget (KL7). - REL-127 [ecs][test]:
lookup(world, path, { from })MUST resolvepathrelative to the children offrom(flecsecs_lookup_from, "Parent::Child" relative toParent).fromMUST be alive, elselookupthrows naminglookup. - REL-128 [ecs][test]: Sibling names are not unique (KL3, OQ6). If several siblings (or roots) share a name,
lookupMUST return the one with the lowestentity.id(). - REL-129 [ecs][test]: Unnamed entities MUST NOT match any segment, including a segment like
#12(no lookup by id in v0). An emptypath, or a path with an empty segment (a//b,/a,a/) MUST returnundefined. - REL-130 [ecs][test]: Renaming (
entity.set(Name, { value })) MUST take effect immediately forlookupandgetPath. There is no cache or index (KL7). - REL-131 [ecs][test]: Reparenting changes the path. After
child.add(ChildOf(other)),getPath(child)starts withother's path, andlookupwith the old path returnsundefined. - REL-132 [ecs][test]: Round trip. For every entity whose chain has only named entities with names that are unique among their siblings:
lookup(world, getPath(world, e)) === e. - REL-133 [ecs][test]: A named entity with a parent MUST NOT be found by a root-level segment. A named root MUST NOT be found as a child of any entity.
- REL-134 [ecs][test]:
getPathandlookupMUST throw, naming the function, for an entity argument (entityorfrom) that is not alive inworld, including an entity of another world. The world is unchanged (moved from REL-156). Destroyed entities are never returned bylookup. - REL-135 [ux][manual]: The README MUST state that lookup scans names with no index (cost is linear in the number of siblings per segment and in the number of named entities for the first segment), that names are not unique unless the caller makes them so, that
/cannot appear in names used with paths, and that#idsegments are for display only. Verify by reading it.
REL-F08 Package rules (S)¶
Depends on F01 to F07 and is the last feature item: its tests use the install functions of F01 and F03 and the exports of all groups, and its README text links the sections written by REL-053, REL-110, and REL-135. Retired IDs: REL-153 moved into REL-012 and REL-049, REL-154 moved into the per-function requirements that throw (REL-016, REL-040, REL-067, REL-109, REL-123 to REL-127, REL-134), REL-156 moved into REL-028, REL-067, REL-109, and REL-134.
- REL-150 [ecs][test]: There MUST be no module-level mutable state. Hooks, guards, and recompute state live in the closure of one
symmetricortransitivecall, which is one world. Installingsymmetricandtransitiveinworld1MUST NOT change behaviour inworld2, even with the samerelandclosureobjects (a relation definition is shared and holds no per-world state). It extends REL-014 totransitiveand to both installs together. - REL-151 [ecs][test]: Importing the package MUST have no side effects: it creates no world, no entity, no hook.
ChildOfandNameare definitions (constants) only. - REL-152 [ecs][test]: Canary for KL4: after
world.reset(), hooks installed bysymmetricandtransitiveare gone (a new pair is not mirrored). Calling the install functions again MUST work. The test documents koota's behaviour and fails if koota changes it, so the README can be updated. - REL-155 [ux][manual]: The README MUST have a "Preconditions" section that lists what the package cannot check (KL1) and links the sections written by REL-053 (
transitive), REL-110 (ChildOf), and REL-135 (names):relforsymmetricdoes not mirror pair data (REL-011);closurefortransitiveis dedicated, non-exclusive, store-less; hooks are per world and lost onworld.reset()(REL-152); kootaonRemovehooks run before the removal and can fire for a pair the entity does not have (KL2), which user code that mixes its own hooks with these must expect. Verify by reading it.
API sketch¶
import type { Entity, Query, QueryParameter, Relation, TagTrait, Trait, World } from 'koota';
export type Unsubscribe = () => void;
// Area A: relation traits. Installed per world through hooks (KL1, KL2, KL4).
// Mirrors every pair of `rel` now and after. Throws (message names `symmetric`) if `world` is not initialized. (REL-001, REL-016)
export function symmetric(world: World, rel: Relation<Trait>): Unsubscribe;
export interface TransitiveOptions {
// Dedicated, non-exclusive, store-less relation written only by the package. (REL-040)
closure: Relation<Trait>;
}
// Keeps `closure(y)` on every x that reaches y under `rel`. Throws (names `transitive`) if closure === rel. (REL-040)
export function transitive(world: World, rel: Relation<Trait>, options: TransitiveOptions): Unsubscribe;
// Pure traversal, needs no registration. (REL-020)
export function isReachable(world: World, from: Entity, rel: Relation<Trait>, to: Entity): boolean;
// Area B: traversal.
export interface UpOptions {
self?: boolean; // check `entity` first (default false) (REL-061)
}
export function up(
world: World,
entity: Entity,
rel: Relation<Trait>,
trait: Trait,
options?: UpOptions,
): Entity | undefined;
// Entities matching `query`, ordered by depth under `rel`, then by entity id. (REL-080..REL-082)
export function cascade(world: World, query: Query<QueryParameter[]> | QueryParameter[], rel: Relation<Trait>): Entity[];
// Area C: hierarchies.
export const ChildOf: Relation<TagTrait>; // relation({ exclusive: true, autoDestroy: 'orphan' }) (REL-100)
export const Name: Trait<{ value: string }>; // trait({ value: '' }) (REL-120)
export function parent(world: World, entity: Entity): Entity | undefined;
export function children(world: World, parent: Entity): Entity[]; // ascending entity.id() (REL-105)
export interface PathOptions {
from?: Entity; // an ancestor of `entity` (REL-123)
}
export function getPath(world: World, entity: Entity, options?: PathOptions): string;
export interface LookupOptions {
from?: Entity; // resolve relative to the children of `from` (REL-127)
}
export function lookup(world: World, path: string, options?: LookupOptions): Entity | undefined;
Usage for the demo (docs only):
const MarriedTo = relation();
const VassalOf = relation({ exclusive: true });
const Reaches = relation(); // closure of VassalOf
symmetric(world, MarriedTo);
transitive(world, VassalOf, { closure: Reaches });
const emperor = world.spawn(Name({ value: 'Emperor' }));
const duke = world.spawn(VassalOf(emperor));
const count = world.spawn(VassalOf(duke));
isReachable(world, count, VassalOf, emperor); // true
world.query(Reaches(emperor)); // [duke, count]
up(world, count, VassalOf, Title); // first liege up the chain that has `Title` (`VassalOf` is exclusive, so this is the nearest)
Notes for implementation:
- Shared helpers: one walk over
entity.targetsFor(rel)sorted byentity.id()with avisitedset (forup,isReachable, depth);sources*throughworld.query(rel(x)). - Mirroring: guard set of unordered pair keys in the
symmetricclosure. Skip the mirror if the other side is dead (world.has) (KL2, REL-004). - Closure: on every
reladd or remove, recompute the closure for the affected sources, which are the source of the changed pair and itssources*. On remove, the traversal skips the edge in the callback (REL-043). Add and remove closure pairs by diffing against the current closure targets. Install runs the same recompute for every source found throughworld.query(rel('*')). cascade: compute depth for every matching entity with memoization and the longest-path rule (REL-081, REL-086), then sort by[depth, entity.id()].- Roots for
lookup:world.query(Name), filtered byentity.targetFor(ChildOf) === undefinedand a non-empty name (KL7). Children bychildren(world, parent).
Differences from flecs¶
- Per-world hooks instead of relation properties. flecs adds
Symmetric/Transitiveto the relation entity once for the world. koota relations are opaque (KL1), so the traits are installed per world with a call that returns an unsubscribe, and they are lost onworld.reset()(KL4). The concept sketch (symmetric(rel),up(entity, rel, trait)) is extended with a leadingworldargument (KL8, OQ1). - Transitivity is not applied in queries. flecs makes
(R, X)queries followR. Hereworld.query(R(x))stays one-hop. Reachability isisReachable, and a queryable materialized closure is opt-in (REL-040). Cost: closure maintenance work per change, which flecs avoids by traversing at query time. - Not reflexive, and no
Acyclic/Traversablechecks. koota cannot veto an add (KL3), so every helper guards against cycles instead of asserting. flecs refuses to traverse a relation that is notTraversable. upandcascadeare functions, not query terms.upreturns the entity that holds the trait (flecs resolves it into the query's term source).cascadeis an eager function that returns an array. It has nodesc, no caching, and no group iterator. It orders by depth like flecsCascade, and by entity id inside one depth (OQ3).upwalks depth-first like flecs, but visits the targets of an entity in entity-id order, where flecs uses table order (REL-063).ChildOfis plain koota.exclusiveandautoDestroy: 'orphan'replace flecs's builtin cleanup. Children are destroyed, but in no specified order (flecs deletes children before parents) (KL9). flecs hasParentstorage,OrderedChildren, and scoping (set_scope). None of those are ported.- Names are a trait. flecs entities carry a name in a builtin component and enforce unique names among siblings. Here
Nameis an ordinary trait, uniqueness is not enforced (KL3), and lookup is a scan (KL7). Path separator is/(flecs:.in C,::in C++). Unnamed entities print as#<id>in paths but cannot be looked up by it (flecs resolves#id). - Symmetric pair data. flecs pairs carry no data in a symmetric relation. Here a relation with a store is allowed, its mirrored pair gets defaults, and the data is not kept in sync (REL-011).
Out of scope¶
Reflexive,Acyclic,Traversable,Final,Exclusiveas package traits, and any query-level transitivity or traversal term ($this,$x,self|upin the query DSL).cascadewithdesc(bottom-up), cached or grouped cascade queries, breadth-first / depth-first iterators other than the ordering in REL-081.- Cycle prevention and checked mutators (
setParentthat throws on a cycle), unique-name enforcement, and a name index. OrderedChildren/ kootaordered(),Parentstorage, scoping, name lookup by#id,..and relative path segments, case-insensitive lookup, and escaping of the separator.- Mirroring or merging pair data for
symmetric. Per-relation configuration of what the mirror carries. - A
reachable(world, a, rel)list function (the closure answers it), and incremental reachability for large graphs. - Re-installing hooks after
world.reset()automatically (KL4). - React hooks over these helpers, serialization of
Nameand closures (ecs-serialize), prefab child copying (ecs-prefab), and devtools display of paths. - A
demo-ck3scenario group (asPREFAB-F06): add it whendocs/products/demo-ck3.mdanddocs/specs/demo-ck3.mdexist. - Upstream proposals: relation properties (
symmetric,transitive,traversable) and aNot(pair)in koota, anonRemovehook that runs after the removal, hooks that surviveworld.reset(), and a public world accessor on an entity.
Open questions¶
Each has a recommended default. The requirements above already use the defaults. If the user decides otherwise, the named requirements change.
- API shape: world first. Default:
symmetric(world, rel),transitive(world, rel, { closure }),isReachable(world, a, rel, b),up(world, entity, rel, trait),cascade(world, query, rel), because hooks are per world (KL4), entities have no world accessor (KL8), and the world lets every function reject dead entities (REL-028, REL-067, REL-109, REL-134). The concept sketch (symmetric(rel),up(entity, rel, trait)) would need a module-level registry or$internal, both against the package rules. Affects: all signatures. - Who owns the closure relation, and is
transitiveneeded forisReachable? Default: the caller passesclosure(a dedicated relation), andisReachableis a pure traversal that needs no registration. Alternative: the package creates the closure relation per call and returns it (needs a package-held handle perrel, which is module state or a world trait), orisReachablethrows unlesstransitivewas installed (needs a per-world registry that goes stale afterworld.reset()). Affects: REL-020, REL-040, REL-053. cascadeorder. Default: depth-ordered like flecsCascade(all of depth 0, then depth 1, and so on; ties by id). W-008 and concept §6 say "depth-first, depth-ordered", which can mean either breadth by level (flecs) or parent-before-child pre-order (each subtree before its next sibling). If the demo needs pre-order (subtree contiguous), REL-080 to REL-082 change, and the ordering key changes to the path of ids. Decide before implementing F05.- Pair data of symmetric relations. Default: not mirrored, mirror gets defaults (REL-011). Alternative: copy data on add and sync on
set(needsonChangehooks, which turn on change tracking for the relation), or reject relations with a store (not detectable, KL1). - Path format. Default:
/separator, no leading separator, a name containing/makesgetPaththrow (REL-125). Alternatives:.(as flecs C),::, a configurable separator, or escaping. Affects: REL-121, REL-125, REL-129. - Unique sibling names. Default: not enforced, lowest id wins (REL-128). Alternative: a checked
spawnChildorsetNamehelper that throws on a duplicate sibling name. koota cannot reject a plainentity.add(Name(...))anyway (KL3). - Cycles in
ChildOf. Default: tolerated and not prevented, helpers terminate or throw (REL-108, REL-124). Alternative: a checkedsetParent(world, child, parent)that throws on a cycle. - Closure on cycles includes self pairs (REL-047):
a → aappears whenais on a cycle. Alternative: never write a self closure pair. Then the invariant in REL-046 would needx !== y. - No
demo-ck3brief yet. The requirements follow the concept §8 mapping. Which of the three areas does the first ck3 backlog feature need first? Default order of work items: F01, F02, F03 (traits), F04, F05 (traversal), F06, F07 (hierarchy), then F08 last. F03 needs F02 and F07 needs F06. F08 depends on F01 to F07 and is the last item (REL-150 to REL-152 and REL-155 test or document all groups).