Graph System, Projections, and Family Libraries — Design¶
Status: Proposed. Direction reviewed with Everett 2026-07-19; implementation is staged as a dependency-ordered issue chain (linked per stage below). Each stage is one atomic issue and PR sized for the review gate.
1. Motivation¶
The PreFab goal: agents build libraries of components and processors that
incrementally climb the sophistication real domain problems require. In Flecs,
prefabs are implemented through entity relationships (IsA, ChildOf), so
relationships come first.
Sander Mertens' relationship roadmap prices a fragmenting implementation at roughly two years for a cache-line archetype ECS. Most of that budget pays for cache coherence: query caches and their invalidation, the component index, archetype cleanup under id recycling, reachable caches, empty-table garbage collection. A lakehouse ECS has none of those constraints. Queries read persisted state through Daft's optimizer, ids are never recycled, and there are no live caches to invalidate. The transferable piece is small, and almost all of it is a library rather than an engine change.
2. Decisions¶
D1 — Family libraries are a layer beside core¶
A family library (archetype.missions.planning, archetype.graph,
archetype.projections)
owns reusable domain behavior at the top level. It imports archetype.core,
itself, third-party packages, and only reviewed lower family contracts. Its
logic is frame-pure — functions over DataFrames — with thin handle sugar beside
it. Scripts and application services may consume it without moving reusable
projection behavior into app; another top-level family needs an explicit
edge first.
Enforced, not conventional: the root policy in quality/architecture.toml
forbids outward imports to archetype.app, archetype.runtime,
archetype.api, and archetype.cli; fragments under
quality/architecture.d/ register each family and its reviewed lower-family
edges.
Alignment (2026-07-19, architecture-agent direction): this decision is the
special case of a repo-wide split of app/ into application authority
(stays: storage, world lifecycle, query coordination, commands, audit,
redaction, gateway, container) and reusable ECS domain families that move
top-level (missions, evaluation, artifacts — alongside graph and
projections, which are top-level from birth). Family file idiom:
components.py, processors.py, frame-pure modules, and contracts.py for
public family value contracts. Family-specific projection logic lives inside
its family (missions/projections.py); the top-level projections/ package
holds generic, cross-family read models. Sequencing per that direction: the
family dependency rule is codified first, pure types move family-by-family,
and graph/projections are introduced against the stabilized pattern.
That sequencing has since completed: HTN lives under mission planning,
dataset identity lives under evaluation, and the former production
experiments umbrella has been divided among missions, physical AI, research,
and artifact-owned transcript ingestion. This design still explains the order
in which those boundaries were established; the guide specifications describe
their current contracts.
D2 — Edges are entities; EdgeTables are archetype tables¶
A relation is a Component subclass with source and target entity-id
fields. An edge is an ordinary entity carrying one relation component. This is
the non-fragmenting representation: pairs never enter the archetype signature,
so relationship targets cannot explode the table count.
Everything an edge needs is inherited: ticks, is_active, persistence, fork
lineage. Temporal edges are a filter (tick == t), wildcards are a where on
source or target, and traversal is a join.
D3 — GraphView rides PostTick.results¶
PostTick already delivers results: dict[ArchetypeSignature, DataFrame] —
every just-persisted frame — and core/hooks.py blesses handlers that close
over state at registration. GraphView is a Resource whose hook method
stores those frames; processors at tick N read tick N−1 by construction.
Contract: strictly previous-tick visibility, read-only, lazy frames (the Daft
DAG rules hold), safe across concurrent table tasks. No core changes, no
query-service reentrancy, wired through the declarative world config
(resources=[view], hooks=[(PostTick, view.on_post_tick)]).
D4 — Cleanup is policy, propagated by ticks¶
A relation declares on_delete_target: REMOVE (default; despawn the
dangling edge, leave the source), DELETE (despawn edge and source — the
hierarchy cascade), or FLAG (mutate nothing; surface the dangling edges to
the caller — the ledger-world replacement for Flecs' Panic).
Amended 2026-07-19 (decision on issue #552): the original text specified a
CascadeDespawn processor, but processors are pure DataFrame → DataFrame
with no mutation channel — is_active is engine-owned. The policy is applied
by the driver-level graph.cascade(world, rel, view) helper instead: it
reads liveness from the GraphView as a lazy anti-join, stages despawns
through the world API, and advances one generation per invocation — calling
it once per step yields the one-generation-per-tick propagation, every step
on the ledger. A processor-native sibling requires the staged-mutation core
seam tracked in issue #604.
D5 — IsA copies at instantiation¶
instantiate(world, prefab, overrides) materializes the prefab's component
values (and its ChildOf subtree) onto fresh entities and records IsA
lineage edges. There is no runtime resolution: editing a prefab does not
mutate instances. Re-instantiation under the new prefab version is the
upgrade path, and both generations stay on the ledger, which is what makes
prefab populations gradeable.
D6 — Components are not models¶
App-level models.py files hold DTOs: plain Pydantic models or dataclasses
that cross service boundaries. Simulation state is a Component and belongs
in a components.py. Family libraries follow the htn file idiom:
components.py, processors.py, plus frame-pure modules. Existing app
modules whose models.py actually contains components should be renamed as
part of the agent-missions reorganization.
3. Shapes¶
# graph/components.py
class Relation(Component):
source: int = 0 # entity_id, matches BASE_SCHEMA / OnSpawn.entity_id
target: int = 0
class ChildOf(Relation):
exclusive = True # stage 5a
on_delete_target = Policy.DELETE # stage 5b
class IsA(Relation):
pass # stage 7
# graph/view.py
class GraphView:
def __init__(self) -> None:
self._frames: dict[ArchetypeSignature, DataFrame] = {}
self.tick: int = -1
async def on_post_tick(self, event: PostTick) -> None:
self._frames = dict(event.results) # lazy, just persisted
self.tick = event.tick - 1 # the completed tick
def frame(self, *components: type[Component]) -> DataFrame | None:
"""Concat archetype frames whose signature contains all components."""
# graph/traverse.py — frame-pure, importable by app
def targets(edges: DataFrame, rel: type[Relation], source: int) -> DataFrame:
p = rel.get_prefix() # "childof__" — prefix convention names the columns
return edges.where(col(f"{p}source") == source)
def neighborhood(
edges: DataFrame, rel: type[Relation], roots: DataFrame, depth: int
) -> DataFrame:
"""Bounded-depth iterated joins; lazy in, lazy out."""
# handle sugar beside it for scripts:
async def edges(world, rel: type[Relation], *, at: int | None = None) -> DataFrame: ...
async def link(world, rel: Relation) -> int:
return await world.spawn(rel) # exclusivity replaces in-batch from stage 5a
Projections take the same split: frame-pure read models
(overview(frames), possession(frames, entity, rels, depth)) with handle
wrappers; REST exposure later via app importing the frame layer.
4. Sander-roadmap disposition¶
| Roadmap item | Fate here | Why |
|---|---|---|
| 1. Components as entities | Dropped (v1) | Relations get identity from the component type system |
| 2. Observers | Exists | core/hooks.py lifecycle events |
| 3. Pair encoding in archetype ids | Rejected | Fragmenting pairs would mint a table per target; edges are rows instead |
| 4. Relationship components | Free | Payload fields are columns on the relation component |
| 5. Wildcard queries | Free | where filters on source/target |
| 6. Component index | Exists | Control catalog and table listing |
| 7. Cleanup | Reduced | No id recycling; dangling edge is a join-time filter (D4) |
| 8. Cleanup traits | Kept, reshaped | on_delete_target policy on the relation (D4) |
| 9. Multi-source queries | Free | Joins |
| 10. Relationship traversal | Kept | neighborhood() as bounded iterated joins |
| 11. Query cache revalidation | Not applicable | No query caches |
| 12. Breadth-first traversal | Kept, reshaped | Depth component maintained as data (stage 6) |
| 13. Uncached queries | Not applicable | Every query is uncached through Daft |
| 14. Multi-component observers | Not applicable | No cache notification problem |
| 15. Event propagation / reachable cache | Not applicable | Same |
| 16. Empty-table GC | Deferred | Table churn is bounded without fragmenting pairs |
| 17. Rule engine | Already have one | Daft is the relational engine; transitivity = bounded joins |
| 18. Exclusive relationships | Kept | Uniqueness on (relation, source) at edge-write (stage 5a) |
| 18b. Inheritance / IsA | Kept, reshaped | Copy-on-instantiate with lineage edges (D5) |
| 19. Query DSL | Not applicable | SQL and dataframes exist |
5. Stage plan¶
| Stage | Ships | Depends on | Size |
|---|---|---|---|
| 0 | projections/: world-overview read model, frame-pure + handle sugar (mission rollups arrive later via missions/projections.py, not via app) |
family rule codified | ~250 lines |
| 1 | graph/: Relation, link/unlink, edges, wildcard filters (adds the family rule only if the app-split track has not landed it) |
family rule codified | ~300 lines |
| 2 | Traversal: neighborhood, join helpers, examples/11_graph_relationships.py |
1 | ~300 lines |
| 3 | FPS projection: possession() neighborhood read model |
2 | ~200 lines |
| 4 | GraphView resource + frame() lookup |
1 | ~250 lines |
| 5a | Exclusivity: replace-in-batch on link |
1 | ~100 lines |
| 5b | ChildOf, on_delete_target policies, CascadeDespawn processor |
4, 5a | ~300 lines |
| 6 | Depth component + maintenance processor, sort("depth") ordering |
4, 5b | ~200 lines |
| 7 | Prefab marker, IsA, instantiate(), examples/12_prefabs.py |
2, 5b | ~400 lines |
| 8 | PreFab registry: manifest, versioning, eval binding | 7, design doc first | — |
Stage 8 requires its own design (docs/design/prefab-registry.md) after
stage 7 lands; its versioning story must answer #543 (schema evolution against
persisted tables).
6. Non-goals¶
- No core or app changes anywhere in stages 0–7.
- No runtime inheritance resolution for prefabs.
- No transitive-closure engine; traversal is bounded-depth.
- No REST endpoints in this chain; app may import the frame layer later.
- Core query-API traversal sugar is deliberately deferred until the library proves the shapes.