EIDOS Loader¶
Verified against the code · 4d5c9b5781eaaa62048db19cd10dc7039e402c98 · 2026-10-20
956 D1b-2 (2026-10-20): 3 finding(s) fixed — _build_ref is notation-first (stored skos:notation, else derive_slug_segment, WP2 #951), patch_add_relation has no production caller (mutation_store only uses patch_remove_relation), a missing EIDOS_FILE raises FileNotFoundError and only a corrupt archive raises BadZipFile. Re-verified against main 4d5c9b57. #935 S1 (2026-10-21): a missing EIDOS_FILE is no longer an error — current_graph() builds the domain from the overlay root node + config/domains.yaml (hierarchyPredicate, formalType); OverlayRootConflictError when the overlay has 0 or 2+ roots; tree_name_by_id now built after the merge; archive read and overlay-only tree extracted into _read_archive / _overlay_only_tree. Checked against archon/issue-935. #935 S2 (2026-10-22): documented the base-to-overlay converter (tools.migration convert: root + WORLD to overlay files, data.eidos archived with sha256 receipt, lossless-only refusals, dry-run, rollback). Checked against tools/migration/eidos_base_to_overlay.py on the #935 branch (base 3fd2448f). #1080 (2026-10-22): the overlay-only tree declaration is read from DOMAINS_FILE (the deploy-written runtime file on a deployed instance), which now carries both fields. Checked against persistence/eidos_loader.py and tools/bootstrap/bootstrap.py on archon/issue-1080 at 3dd9a7aa. #935 S7 (2026-10-22): the tree declaration is read from the runtime domains.json (generated from config/domains.yaml), not from config/domains.yaml directly. Checked against eidos_loader.py at a11d2636.¶
Layer: persistence
Purpose and Responsibilities¶
The eidos_loader module is the single source of truth for the EffectiveGraph — the fully merged, indexed, in-memory representation of a domain's overlay (the mutation store's files). Every other backend module that needs node data, relation data, or tree structure calls into this module.
Since #935 S5a the backend never reads a .eidos ZIP: the graph is built from the overlay alone (see The overlay is the only source below). The one .eidos reader left in the repository is tools/migration/eidos_archive.py, used by python -m tools.migration convert and its rollback on a domain that was not converted yet.
What this engine owns:
- Building the graph from the overlay alone: the root from the overlay's root node, the tree declaration from config/domains.yaml (see The overlay is the only source below)
- Refusing a domain that still holds only a legacy data.eidos (LegacyEidosFileNotConvertedError) and warning about a leftover one, never opening it
- Building and maintaining the module-level _cache dict (the EffectiveGraph)
- Constructing all performance indexes: ref_index, ref_by_uid, parent_of, children_of, rels_by_node, search_corpus
- Detecting cross-worker cache staleness via the sentinel counter and triggering a full rebuild when needed
- Applying surgical cache patches (patch_remove_relation, which mutation_store uses to avoid a full rebuild after a single write; patch_add_relation exists but currently has no production caller)
- Exposing the canonical read entry point current_graph() and the lifecycle operations load_instance and refresh_instance
What this engine does NOT own:
- Persisting mutations — that is mutation_store's responsibility
- Merging the overlay into the base graph — that is domain.graph_merge.merge_overlay's responsibility
- IAM / access-control filtering — that is server.py's responsibility
- The inverted search index beyond the flat search_corpus — domain.search_index._build_search_index builds the inverted index lazily; get_search_index just stores it in _cache after the first call
Cache Lifecycle¶
The EffectiveGraph lives in the module-level _cache dict. It is populated lazily on the first request after process start, and is rebuilt whenever the sentinel counter changes.
Process start
_cache = {} _cache_sentinel_mtime = 0.0
│
▼ (first request)
current_graph()
├─ _overlay_only_tree(): overlay root + tree declaration
│ (legacy data.eidos: typed error or warning, never opened)
├─ call graph_merge.merge_overlay() onto an empty base
│ (fills nodes, relations, parent_of, children_of, ref_index, …)
├─ build search_corpus
├─ populate _cache
└─ _cache_sentinel_mtime = _sentinel_mtime()
│
▼ (mutation committed by any worker)
mutation_store.invalidate()
├─ _cache.clear() ← this worker's cache cleared immediately
└─ touch _CACHE_SENTINEL ← signals all other workers
│
▼ (next request on any worker)
current_graph() warm-cache check:
_sentinel_mtime() ≠ _cache_sentinel_mtime → _cache.clear() → full rebuild
_sentinel_mtime() == _cache_sentinel_mtime → return _cache (O(1) fast path)
The sentinel value is a monotonic integer counter, not a real file modification time. The name _cache_sentinel_mtime is kept for back-compatibility with existing tests that patch it. Any increment — even two mutations within the same wall-clock second — causes a cache miss, avoiding the one-second resolution problem of filesystem st_mtime.
Key Functions¶
_sentinel_mtime¶
def _sentinel_mtime() -> float:
Returns the current cache version counter from mutation_store._read_cache_version. Returns 0.0 when the sentinel file is absent (fresh install, no mutations yet). Despite the name, this is an integer counter, not a real mtime.
Called on every request inside current_graph() to detect cross-worker staleness. The overhead is one integer read; do not try to skip it.
get_nodes¶
def get_nodes() -> dict:
Returns the full node map {uid: node_dict} from the EffectiveGraph.
from persistence import eidos_loader
nodes = eidos_loader.get_nodes()
node = nodes.get("a1b2c3d4-0000-0000-0000-000000000001")
print(node["label"]) # → "Electrical System"
get_node¶
def get_node(uid: str) -> dict | None:
Returns a single node dict by UUID, or None if not found.
node = eidos_loader.get_node("a1b2c3d4-...")
if node is None:
raise HTTPException(404, "Node not found")
get_relations¶
def get_relations() -> list:
Returns the full flat list of all relation dicts in the EffectiveGraph. Each dict has the shape described in Data Structures below.
get_trees¶
def get_trees() -> list:
Returns the list of tree structure dicts. Each dict has root, type, predicate, and children (a {parent_id: [child_ids]} map). These are the raw tree structures; for traversal, prefer get_children and get_parent.
get_children¶
def get_children(node_id: str) -> list[str]:
Returns the direct child UUIDs of node_id across all trees. If the node has no children (or does not exist), returns [].
The docstring: "Return direct children of a node across all trees."
kids = eidos_loader.get_children("parent-uuid-...")
for child_uid in kids:
child = eidos_loader.get_node(child_uid)
print(child["label"])
get_parent¶
def get_parent(node_id: str) -> str | None:
Returns the parent UUID of node_id, or None if the node is a root (domain root or WORLD). When a node appears in multiple trees, parent_of stores only the first tree's parent ("first tree wins").
get_node_relations¶
def get_node_relations(node_id: str) -> list:
Returns all relations that have node_id as either from or to. Returns [] for unknown nodes.
relations = eidos_loader.get_node_relations("some-uid-...")
outgoing = [r for r in relations if r["from"] == "some-uid-..."]
incoming = [r for r in relations if r["to"] == "some-uid-..."]
get_node_ref¶
def get_node_ref(uid: str) -> str | None:
Returns the dotted-label path (external ref) for a node, e.g. "&Disciplines.ELECT.CTP10". Returns None if the UID is not in the graph.
The docstring: "Return the external_ref path for a node (dotted label path from root)."
get_node_by_ref¶
def get_node_by_ref(ref: str) -> dict | None:
Resolves a dotted ref path (e.g. "+FFL.CTP10.TA37") to a node dict. Delegates to key_derivation.resolve_slug for prefix-tolerant matching (WP2 #951 façade; equivalent to resolve_ref in WP2). Returns None if not found.
The docstring: "Resolve an external_ref path (e.g. '+FFL.CTP10.TA37') to a node."
node = eidos_loader.get_node_by_ref("&Disciplines.ELECT")
# Works even if the stored key has a different leading prefix
get_propagation_rules¶
def get_propagation_rules() -> list:
Returns the base-graph propagation_rules list — always empty since #935 S5a, as only a .eidos snapshot filled it. User-defined rules live in the shared rulebook (BL-ARCH-003).
get_all_rules¶
def get_all_rules() -> list:
Returns the all_rules list — always empty since #935 S5a; it held the rule objects parsed from a .eidos archive's relations.eidr.
get_search_corpus¶
def get_search_corpus() -> dict:
Returns {uid: lowercased_text_blob} — a flat full-text representation of every node, used by the /search endpoint. The corpus is built after the overlay merge so that overlay-only nodes are included. Fields on the content denylist (domain.search_index._CORPUS_DENIED_FIELDS — document body, OCR, webpage/html, attachments, timestamps) are excluded, and values longer than _MAX_INDEXABLE_VALUE_LEN (500 chars) are dropped regardless of field name, so document content is never searchable (BL-FE-080 privacy invariant; hardening #511 + #530).
get_search_index¶
def get_search_index() -> dict:
Returns the inverted search index for Global Search v2 (BL-FE-080). Built lazily on the first call and stored in _cache["search_index"], so it is invalidated together with the rest of the EffectiveGraph.
The docstring: "Built once per current_graph() cache cycle and stored alongside search_corpus in the same _cache dict — so it shares the sentinel-mtime invalidation path. The first call after a cache miss pays the build cost; subsequent calls hit the cached index."
get_tree_name_by_id¶
def get_tree_name_by_id() -> dict:
Returns {tree_root_uid: display_name}. The display name is derived from the root node's label; falls back to formalType, then to the root UID, then to "Unknown".
get_domain_prefix¶
def get_domain_prefix() -> str:
Returns the leading non-alphanumeric characters from the domain root node's label. Used by remote domains to advertise their ref prefix without hardcoding it.
Examples: root label "&Disciplines" → "&", "-FFL" → "-", "FFL" → "".
patch_remove_relation¶
def patch_remove_relation(rel_uuid: str) -> None:
Surgically removes one relation from the warm cache without triggering a full rebuild. If the cache is cold, this is a no-op (the relation simply will not appear when the cache next loads).
rel_uuid is the value stored in relation["source"] — the relation's overlay id.
Called by mutation_store immediately after writing a tombstone, as a performance optimization (T10 precursor). Do not call from application code.
patch_add_relation¶
def patch_add_relation(rel: dict) -> None:
Surgically appends one relation dict to the warm cache. No-op if the cache is cold.
rel must have at least "from" and optionally "to" keys matching the structure described in Data Structures. It currently has no caller anywhere in backend/: mutation_store only calls patch_remove_relation (from _clear_caches when invalidate is given a rel_uuid).
current_graph¶
def current_graph() -> dict:
Canonical read entry point (registered in engine_registry.yaml as of Slice 2 of the loader-API-flip, issue #868). The engine's core function — all public accessors call it as their first step. Returns _cache on a warm hit (O(1)), or fully rebuilds it from the overlay otherwise. The read is sentinel-mtime-gated: it only rebuilds when the cross-worker cache-version counter has advanced (i.e., after a mutation was committed).
This is the correct function to call when reading the current effective graph. All public accessors on this module delegate to it.
Rebuild sequence:
1. _overlay_only_tree loads the overlay, selects its root and declares the one tree (see below)
2. Start from an empty base: nodes, relations, parent_of, children_of, ref_index, ref_by_uid, rels_by_node
3. Call graph_merge.merge_overlay to add every overlay node and relation and build those indexes, then build tree_name_by_id — after the merge, so the root's current label names the tree
4. Build search_corpus via _build_search_corpus (must happen after step 3). Field filtering is owned by the canonical denylist flattener domain.search_index._flatten_indexable (_CORPUS_DENIED_FIELDS) — the loader carries no flattener of its own (the legacy _corpus_flatten blocklist was removed, BL-FE-080 / hardening #511). A relation's cross-domain ref and domain are read through infra.target_ref (external_ref_of / external_domain_of)
5. Identify domain_root_uid and domain_root_path
6. Populate _cache and record _cache_sentinel_mtime
domains, propagation_rules and all_rules stay in the cache as empty lists; only a .eidos snapshot ever filled them.
Returns: dict — the _cache dict (see Data Structures below). Callers must treat this as read-only.
Exceptions: OverlayRootConflictError (HTTP 500, overlay_root_conflict) when the overlay does not hold exactly one root node; LegacyEidosFileNotConvertedError (HTTP 500, legacy_eidos_file_not_converted) when it holds no root at all and a legacy data.eidos exists; UnknownDomainError (HTTP 404, unknown_domain) when this domain's catalog entry, formalType or hierarchyPredicate is missing.
The overlay is the only source (#935 S1, S5a)¶
current_graph() builds the domain from the overlay and the runtime domains.json (_overlay_only_tree). Since #935 S5a this is the only path; the .eidos branch it once fell back from is gone.
- Root.
graph_merge.select_domain_rootpicks the one overlay node with noparent, skipping the WORLD sentinel (00000000-0000-0000-0000-000000000000) and tombstoned nodes. Its id is read from the overlay file as-is — a root id is never derived. Zero or several candidates raiseOverlayRootConflictError;candidate_idsis logged, not returned. A node whose parent is the WORLD sentinel is not a root: with no parentless root beside it the error names that case (world_parented_ids), and the chain is not repaired. A domain whose overlay is still empty therefore fails until its root node file exists — there is no synthetic root. - Legacy
data.eidos(#935 S5a). The configured legacy path (infra.config.eidos_file()—$EIDOS_FILE, default/data/data.eidos) is checked withdomain_setup.has_legacy_data_file(fail closed: an unreadable path counts as present) and never opened. When it exists and the overlay has no root candidate and no WORLD-parented node — the domain was never converted —LegacyEidosFileNotConvertedErroris raised, chained from theOverlayRootConflictError; its client message is generic, and the path and the commandpython -m tools.migration convert --domain <domain dir> --key <domain>ride on thedomain_errorlog record only (log_fieldsdomain,path,convert_command). The domain does not come up without its root. When it exists beside an overlay that has its root (converted, the file left behind),eidos_file_leftover_ignored(warning, withdomainandpath) is logged once per process per domain, not on every rebuild, and the graph is the same as without the file. Any other conflict — several roots, a WORLD-parented root — raises the originalOverlayRootConflictError; the legacy file does not change it. - Tree declaration. One tree
{"root": <root id>, "type": formalType, "predicate": hierarchyPredicate, "children": {}}, withformalTypeandhierarchyPredicatetaken from this domain's entry inDOMAINS_FILE—backend/domains.json(generated fromconfig/domains.yaml) by default, the runtimedomains.jsonthe deploy writes on a deployed instance, which carries both fields from the same source (#1080). They are never guessed: a missing entry (an unknown or emptyEIDOS_DOMAIN, an old catalog) or a missing field raisesUnknownDomainError.graph_merge.merge_overlaythen attaches every other overlay node to it. - WORLD. The WORLD node is an ordinary overlay file (location carries one); it is never a root candidate.
- Node order (#1085). The graph's
nodeslist the root first, WORLD (if present) second, then every other overlay node in file-name order — the order the retired.eidospath gave, where the base root and WORLD preceded the overlay nodes. The overlay is read in file-name order, never the file system's directory order (#1110), so the result is the same on every file system; without the root rule the root would sit wherever its file name falls, and every full-model export and itseidos:fingerprint(computed over the ordered node list) would change on conversion. A freshly bootstrapped domain lists its root first the same way. - Log.
eidos_loading_overlay_only(info, withdomain) marks each rebuild.
935 S1 proved this path equal to what the same nodes produced from a .eidos file; since S5a tests/test_overlay_root_fallback.py (acceptance A-H) pins those values for current_graph(), serialize_domain, the full-model export in view and migration mode and GET /tree (the World row as a golden, equal to the .eidos-sourced row), and the legacy-file error and warning (H). tests/test_overlay_root_node_order.py (#1085, acceptance A-D) pins the node order and that a converted domain's fingerprints equal the golden ones taken on the .eidos path — view and archive for location, every mode for a domain whose root file already existed; in migration mode a node convert wrote reports rev: 0 where a .eidos node reported null, so location's migration fingerprint differs.¶
Converting a domain to this mode (#935 S2)¶
python -m tools.migration convert --domain PATH --key KEY [--dry-run] (tools/migration/eidos_base_to_overlay.py, reading the archive through tools/migration/eidos_archive.py) produces this state from a domain data directory (data.eidos + mutations/; its backend stopped). Every base node that is the domain root or the WORLD sentinel and has no overlay file yet is written as mutations/nodes/<uid>.jsonld — same id, label, class, tree and property fields, @id from infra.iri.node_jsonld_base, written by DomainStore.write_atomic. Ids are read, never changed or derived. An overlay file that already exists is kept; one whose label, class, tree or properties differ from the base node is reported.
A kept file is changed in one case only: where it has no eidos:engineering, it is augmented with the base node's attributes.engineering — [] when the base has none — and nothing else. The loader inherits that field today (graph_merge.merge_overlay), [] included, and it is the only field the merge inherits; every other field of the overlay node replaces the base's. Without the augmentation a kept root or WORLD lacking eidos:engineering would read engineering: null once the base is gone. --dry-run reports would augment PATH; a real run first backs up the original under eidos-archive/, and --rollback restores it byte for byte. A kept root with neither rdfs:label nor eidos:label is refused as kept_root_without_label, nothing written: it already reads at runtime with a blank label (the overlay node replaces the base node whole), and adding the base label would change the graph (refs, tree name, search, FME view), so label the root through the UI first. A base node's own (not attributes.) engineering and a base productType on a node that already has an overlay file are not read at runtime; they are reported as non-runtime and stay only in the archive.
The conversion makes one named difference to the graph. A kept root whose overlay label (or skos:notation) differs from its base label loses the ref_index keys derived from the base label — the exact ref and its alt key, e.g. -System Root and system root — because the base build indexed them and the overlay merge only adds the current ref. These stale aliases all point at the root, which stays found by its current ref. They are printed as drops stale ref aliases: … by both --dry-run and a real run, and recorded as staleRefAliases in the receipt.
Every file and directory the conversion creates — the written node files, eidos-archive/ and its subdirectories, the receipt, each augmented file and its backup — is owned by the uid and gid of the domain's mutations/nodes directory (the backend's user; --owner UID:GID, numeric, overrides it), files mode 0600 like the existing overlay files and directories 0700, so a backend running as another uid than the converter can read them. When the process cannot chown to that owner (not root, and the owner is not its own uid and group) the conversion is refused as cannot_chown before any write, dry run included; without mutations/nodes and without --owner it is refused as owner_unknown. A chown that fails mid-run rolls the conversion back like any other write failure.
Every snapshot key is classified once, in the closed table SNAPSHOT_KEYS: nodes and trees are converted; relations, propagation_rules, calc_rules, rtirs and templateInstanceRecords refuse the conversion (exit 1, nothing written) when non-empty — under the key name as reason, except relations, which refuses as base_relations like relations.eidr rules; instance_properties, domains, property_store, property_template_catalog, ui_state and projectId are archived and reported as not runtime. A snapshot key outside this table refuses as unknown_snapshot_key. Conversion is also refused, nothing written, on: a node tombstone on a base node (tombstone_targets_base_node) or a relation tombstone on a base relation (tombstone_targets_base_relation); a base node other than the root and WORLD, or not exactly one base tree with a root (non_root_base_node); a base tree whose formalType/hierarchyPredicate or children don't match the KEY entry of config/domains.yaml (tree_declaration); a domains entry whose domain_id is neither the root uid nor a tree id of this base (base_domains); a node or attribute field with no place in the overlay form (unmapped_node_field); an instance_properties entry that resolves to a node (instance_property_matched); a kept root without a label (kept_root_without_label); an existing eidos-archive/ (archive_exists); or an overlay that afterwards would not give select_domain_root the base root (overlay_root_conflict). instance_properties that match no node are reported and stay in the archive.
data.eidos is then moved, never deleted, to eidos-archive/data.eidos, outside the read path, beside a receipt eidos-base-to-overlay.json holding its SHA-256, the SHA-256 of every file written, and — per augmented file — its backup path and the SHA-256 of both the backup and the augmented file (receipt fields archived, sha256, written and augmented), plus nonRuntimeFields, archivedKeys, unmatchedInstanceProperties (a count), staleRefAliases, owner (UID:GID) and convertedAt. A directory without data.eidos is a no-op, so a second run writes nothing. convert --domain PATH --rollback moves the archived data.eidos back, restores each augmented file from its backup byte for byte, and removes exactly the written files the receipt lists, printing restored PATH for each augmented file and removed PATH for each written one; it refuses, touching nothing, when there is no receipt, data.eidos exists again, or the archive, a written file or an augmented file/backup no longer matches its recorded SHA-256. tools/migration/tests/test_eidos_base_to_overlay.py (acceptance A-G) pins, for a product and a location domain, that the graph — before conversion from the retired .eidos loader path, kept as a test oracle in tests/_eidos_archive_graph.py (#935 S5a), after it from the live loader — is the same current_graph() (node fields with engineering present as a list, refs, trees, search corpus and index) and the same FME view bytes before conversion, after it and after a rollback, the stale aliases above being the only allowed difference; an end-to-end test converts DEV-shaped location and signal domains and checks every created file's owner and mode before loading them.
from persistence import eidos_loader
graph = eidos_loader.current_graph()
nodes = graph["nodes"]
print(f"{len(nodes)} nodes loaded")
load_instance¶
def load_instance(instance_path: str) -> dict:
Canonical entry point (GCF contract 02.01 / engine_registry.yaml). Clears _cache and _cache_sentinel_mtime, then calls current_graph(). Since #935 S5a instance_path is accepted and ignored — there is no .eidos file to point at; the signature stays as the registry declares it.
Returns: the newly built _cache dict.
refresh_instance¶
def refresh_instance(instance: str) -> dict:
Canonical entry point. Clears the cache and rebuilds it from the overlay (the instance parameter is accepted for interface uniformity and not used). Use this to force a rebuild after an external change.
Returns: the newly built _cache dict.
Data Structures¶
_cache — the EffectiveGraph dict¶
The module-level _cache is populated by current_graph() and returned by every public accessor. Its keys and value types:
_cache: dict = {
"nodes": dict[str, dict], # uid → node_dict
"relations": list[dict], # all relation dicts
"trees": list[dict], # tree structure dicts
"parent_of": dict[str, str], # child_uid → parent_uid
"children_of": dict[str, list[str]], # parent_uid → [child_uids]
"ref_index": dict[str, str], # dotted_path → uid
"ref_by_uid": dict[str, str], # uid → dotted_path
"rels_by_node": dict[str, list[dict]], # uid → [relation_dicts]
"search_corpus": dict[str, str], # uid → lowercased_text_blob
"tree_name_by_id": dict[str, str], # tree_root_uid → display_name
"domains": list[dict], # always [] since #935 S5a (no snapshot)
"propagation_rules": list[dict], # always [] since #935 S5a
"all_rules": list[dict], # always [] since #935 S5a
"domain_root_uid": str | None, # uid of this backend's domain root
"domain_root_path": str, # dotted path of domain root
# set lazily on first get_search_index() call:
"search_index": dict | None, # inverted index for search v2
}
Node dict (node_dict)¶
Built by graph_merge.merge_overlay from the overlay node files. Shape after the merge:
{
"id": str, # UUID (same as the key in nodes dict)
"label": str, # display label; falls back to uid if absent
"class": str, # ontological class
"treeId": str, # UUID of the tree this node was declared in
"productType": str | None,
"created": str | None, # ISO-8601 timestamp
"modified": str | None, # ISO-8601 timestamp
"properties": dict, # key→value pairs from attrs.properties
"engineering": list[dict], # engineering/instance property entries
}
_build_search_corpus still accepts a transient "_attrs" key (the shape .eidos base nodes had) and removes it; overlay nodes do not carry one. Never access "_attrs" from outside eidos_loader.py.
Relation dict¶
{
"from": str, # source node UUID
"to": str, # target node UUID (may be external placeholder)
"predicate": str, # resolved predicate label, e.g. "hasPart"
"source": str, # the relation's overlay id
# present only for cross-domain relations:
"external_domain": str, # e.g. "signal"
"external_ref": str, # dotted path in the remote domain
}
Tree dict¶
{
"root": str, # declaredRootUuid
"type": str, # formalType
"predicate": str, # hierarchyPredicate, e.g. "hasPart"
"children": dict[str, list[str]], # parent_uid → [child_uids]
}
ref_index and ref_by_uid¶
ref_index maps a dotted label path (the external ref) to a node UUID. It stores two keys per node: the canonical path (e.g. "&Disciplines.ELECT") and an alternate stripped-and-lowercased root variant (e.g. "disciplines.ELECT"), enabling cross-domain ref lookups that tolerate prefix mismatches. The alternate key is inserted by key_derivation.index_ref.
ref_by_uid is the inverse: UUID → canonical dotted path.
The dotted path is built by _build_ref, which walks parent_of from the node to the root and joins the segments with ".". Since WP2 (#951) each segment is the node's stored notation (skos:notation, surfaced as notation) used verbatim when present, else key_derivation.derive_slug_segment(label, is_root=...); ancestors missing from nodes are skipped.
parent_of and children_of¶
parent_of maps a child UUID to its parent UUID. When a node appears in multiple trees, only the first tree's parent is stored ("first tree wins"). The WORLD node ("00000000-0000-0000-0000-000000000000") has no entry in parent_of. The domain root node of each backend also has no entry in parent_of.
children_of maps a parent UUID to a deduplicated list of child UUIDs across all trees.
rels_by_node¶
Maps a UUID to every relation dict where that UUID appears as either "from" or "to". A relation therefore appears in two buckets: once for the source node and once for the target node.
Design Constraints¶
-
Always call through the public accessor functions. Never access
_cachedirectly from outside this module. The module-level variable is an implementation detail; its dict reference may be replaced by_cache.clear()+_cache.update(...)during a rebuild. -
Never mutate the return values of
get_nodes(),get_relations(), etc. All accessors return live references into_cache. Mutating them will corrupt the EffectiveGraph for all concurrent requests in the same worker process. If you need to filter or transform, copy first. -
No
.eidosfile is read (#935 S5a). The graph comes from the overlay, which must hold exactly one root node. The configured legacy path (infra.config.eidos_file(), read on each rebuild) is only checked for existence — see The overlay is the only source. In tests, pointmutation_store.MUTATIONS_DIRat a fixture overlay and setEIDOS_DOMAIN(andEIDOS_FILEat a temp path when the legacy check matters). -
Do not call
patch_add_relationorpatch_remove_relationfrom application code. These are low-level cache hooks (mutation_storeuses onlypatch_remove_relation;patch_add_relationhas no production caller). Calling them out of sequence will leave the cache in an inconsistent state until the next full rebuild. -
Build the search corpus only after the overlay merge.
_build_search_corpusis called at the very end ofcurrent_graph()because overlay-only nodes are not present innodesuntilgraph_merge.merge_overlayruns. The comment in the source notes this explicitly: "Order matters: overlay nodes only exist innodesafter merge_overlay runs (BL-FE-080 demo bug 2026-05-28 — earlier build saw only the base nodes, so 99% of overlay-only projects returned 'No results' for every query)." -
Never look up the domain root via
ref_indexusingEIDOS_DOMAIN. The environment variable name and the node's data-file path are independently defined and need not match. Always use the tree-traversal pattern (find the unique non-WORLD node absent fromparent_of), as documented ingraph-model.mdand implemented incurrent_graph().
Common Pitfalls¶
1. Accessing _cache directly¶
# WRONG — _cache may be empty (cold), or its dict may be replaced during rebuild
from persistence import eidos_loader
node = eidos_loader._cache["nodes"].get(uid)
# CORRECT
node = eidos_loader.get_node(uid)
2. Mutating the returned dict or list¶
# WRONG — modifies the live cache in place
nodes = eidos_loader.get_nodes()
nodes["fake-uid"] = {...}
# CORRECT — work on a copy
import copy
nodes_copy = dict(eidos_loader.get_nodes())
3. Expecting parent_of to contain the WORLD node¶
WORLD ("00000000-0000-0000-0000-000000000000") has no entry in parent_of. The domain root node also has no entry. Code that walks up the tree by following parent_of must explicitly stop when get_parent returns None.
uid = some_uid
path = []
while uid is not None:
node = eidos_loader.get_node(uid)
path.append(node["label"])
uid = eidos_loader.get_parent(uid)
path.reverse()
4. Using EIDOS_DOMAIN to find the domain root in ref_index¶
# WRONG — env var and data path are independently named
import os
root_uid = eidos_loader.current_graph()["ref_index"].get(os.environ["EIDOS_DOMAIN"])
# CORRECT — use the precomputed value from the cache
root_uid = eidos_loader.current_graph()["domain_root_uid"]
5. Assuming _attrs exists on a node after loading¶
_attrs is a transient key placed on base-style nodes during current_graph() and consumed (via node.pop("_attrs", None)) by _build_search_corpus before the cache is stored. After current_graph() returns, _attrs is absent from every node. Overlay-style nodes never have it at all.
6. Assuming _sentinel_mtime returns a real timestamp¶
The function name is kept for back-compatibility, but it returns a monotonic integer counter from mutation_store._read_cache_version. Code that compares this value must use equality (==), not a greater-than check.
7. Calling get_search_index before get_search_corpus is warm¶
get_search_index calls current_graph() internally, so it forces the cache warm automatically. However, the inverted index is built lazily inside get_search_index — it is not created by current_graph() itself. The "search_index" key is absent from _cache until the first get_search_index() call. Code that reads _cache.get("search_index") directly will see None on a fresh load.
8. Multi-tree nodes and parent_of¶
When a node belongs to more than one tree, only the first tree's parent is stored in parent_of. This is intentional ("first tree wins"). If your logic depends on a node's position in a specific tree, use get_trees() and traverse the "children" map of the relevant tree directly rather than relying on parent_of.