Metis · Architecture

Atlas — The Knowledge Spine (Graph, Invariants, Wonder Resolution)

A focused page within the Metis Architecture documentation. The full map and every sibling page live in the Architecture hub.

10sections16 minread1diagram1table

On this page

Atlas is V9's single largest net-new build: the unified knowledge graph that makes "start from a wonder, pull the thread" possible. A learner types a free-text question — "why is the night sky dark?", "how old is the universe?" — and Atlas turns it into a concrete, prerequisite-ordered set of concepts a grounded lesson can teach now. It is the input to Stage 0 of the Prometheus lesson pipeline, the substrate the Theia wonder-director walks to find the next, better question, and the map the consumer front door binds to. Crucially, Atlas is assembled, not authored: it unifies three knowledge graphs that already exist in the monorepo — the Metis concept graph (services/metis/src/metis/services/source_concept_graph_helpers.py), the Sophia knowledge graph (libs/sophia/knowledge-graph, 15 entity types and 27 relationship types), and the Mnemosyne prerequisite graphs (libs/mnemosyne/core) — and binds the domain kernels (Nyx, Kalika, Nisaba) as grounded leaves. The whole engine is the package @oshun/v9-atlas (libs/v9/atlas/src, ten source files), whose own module doc-comment states the discipline plainly: it "composes the real engines (Sophia BM25, Mnemosyne KnowledgeGraph) — no new graph store, retrieval, or psychometrics" (libs/v9/atlas/src/index.ts:1).

Why is it built this way — content-addressed nodes, two hard invariants, and a build-time gate? Because in V9 a lesson is a reproducible, provenance-stamped artifact, and that promise can only be as honest as the knowledge it draws from. Atlas refuses to hold an unsourced fact or a hand-typed physical constant: every fact node must carry a vetted Sophia source pin, and every STEM value node must carry a reference to the computation that yields it rather than the number itself, so the value is reproducible and current instead of a literal that silently drifts (libs/contracts/src/v9/concept-graph.ts:66). Those two rules are not lint suggestions — they are baked into the Zod schema, enforced again at store insert, and aggregated by a build gate that blocks on any violation. This page is the deep companion to the architecture set; the section hub is overview.md and the monolith is ../V9_ARCHITECTURE.md.

What ships, honestly#

Implemented and tested (real today). The @oshun/v9-atlas engine is real and green: 21 tests pass across atlas-graph.spec.ts (11), wonder-resolution.spec.ts (5), and grounded-leaves.spec.ts (5). The pieces are genuine compositions, not reimplementations. The AtlasStore enforces both invariants at insert by parsing every node through V9ConceptNodeSchema (libs/v9/atlas/src/atlas-store.ts:61) and delegates all prerequisite reasoning — closure, topological sort, gap analysis — to the real @mnemosyne/core KnowledgeGraph (libs/v9/atlas/src/atlas-store.ts:113). Wonder resolution runs real Sophia BM25 (createBM25LexicalIndex, libs/sophia/semantic-search/src/bm25/bm25.ts:522) and the real Mnemosyne identifyKnowledgeGaps (libs/mnemosyne/core/src/knowledge-graph.ts:432). The grounded-leaf binders point at real in-repo kernels — kalika:cosmology#ageTodayGyr resolves through the genuine @kalika/cosmology Friedmann solver, and the grounded-leaves test asserts the bound value really is ≈ 13.8 Gyr (13.6 < age < 14.0), i.e. it checks domain correctness, not just shape. The graph-unifier, source adapters, and the build-time grounding validator are all present with real assertions. Atlas is consumed for real by Prometheus (libs/v9/prometheus/src/ground-truth.ts:43), Theia (libs/v9/theia/src/theia.ts), and the experience view-models (libs/v9/experience/src/wonder-front-door.ts:43).

Spec-only / not yet wired to live data. The honest limit is upstream of the engine: nothing in the repo populates a production Atlas from the three live graphs. A search for callers of unifyAtlas or the from* source adapters outside libs/v9/atlas/src returns only the package's own tests and the consumer packages' test fixtures — there is no seed job, ETL, or runtime that drives the unifier over real Metis/Sophia/Mnemosyne exports and persists the result. So Atlas today is a real, tested assembly engine over an in-memory store, not a deployed, populated knowledge spine. This matches the gap analysis §2.1 exactly: "the pieces exist; unification + grounding-pin coverage is the work." The AtlasStore is in-memory only (Map-backed); no persistence/serialization layer ships.

Two honest spec corrections. (1) The monolith's ConceptNode sketch writes kernelRefs: { nyx?, kalika?, ... } — an object keyed by kernel (V9_ARCHITECTURE.md:88). The shipped contract is an array of V9KernelRef { kernel, ref, params?, unit? } (libs/contracts/src/v9/primitives.ts:152), so a node can carry several kernel bindings of the same kernel. (2) The Sophia adapter is intentionally lossy on Sophia's rich relation vocabulary: of the 27 RelationType values, only three are special-cased (PART_OF → part_of, OPPOSITE_OF → conflicts, DERIVED_FROM → specializes); the remaining 24 collapse to a single lateral related edge (libs/v9/atlas/src/source-adapters.ts:150). Treat the "27 relationship types" in the monolith as the source's expressiveness, not what survives unification.

The node and edge contract#

The typed vocabulary lives in libs/contracts/src/v9/ and is re-exported flat and under the V9Contracts namespace (libs/contracts/src/v9/index.ts). A node is a V9ConceptNode (libs/contracts/src/v9/concept-graph.ts:66); its fields:

Field Type / enum Notes
id V9ConceptId Content-hash cn:<64-hex> or namespaced slug <ns>:<slug> (see grammar)
label, aka[] string (1–300), ≤32 aliases aka carries wonder-match synonyms ("dark night sky", "Olbers' paradox")
wonderAxis cosmos | laws | mind | meaning | deep-time | living-world | made-world The 7 axes a wonder can enter on (primitives.ts:42)
discipline core-six ∪ 12 extended (18 total) Metis taxonomy placement (primitives.ts:58)
kind topic | skill | fact | procedure | principle Drives both invariants
groundingPins[] V9SophiaPin[] (≤64) { pinId, sourceId, sourceTitle?, locator?, credibility∈[0,1], quote? }
kernelRefs[] V9KernelRef[] (≤16) { kernel: nyx|kalika|nisaba|demeter, ref, params?, unit? }
epistemicStatus established | contested | one-tradition | frontier Required on every claim (feeds the G6 gate)
safetyPolicyRef optional ref A Metis per-discipline curriculum-safety policy id

The schema is .strict(), so unknown keys are rejected. The id grammar is the load-bearing detail (libs/contracts/src/v9/primitives.ts:17): the regex ^(cn:[a-f0-9]{64}|[a-z][a-z0-9-]*:[a-z0-9][a-z0-9._-]*)$ admits exactly two forms — a content hash minted by the unifier, or a stable namespaced slug minted by an adapter or leaf binder (metis:olbers-paradox, kalika:age-of-the-universe, nisaba:the-republic). That is what lets the same concept resolve identically no matter which source graph first named it.

An edge is a V9ConceptEdge { from, to, type, weight∈[0,1] } (libs/contracts/src/v9/concept-graph.ts:102). The nine edge types unify the three sources' relations plus one V9 addition: prerequisite, related, part_of, generalizes, specializes, enables, conflicts, complements, bridges — where bridges is the cross-axis thread V9 adds (e.g. cosmology ↔ deep-time), the structural hook the Theia director uses to walk from one wonder to the next. The edge schema superRefine forbids self-loops.

The two invariants — what makes Atlas a map of reality#

The difference between Atlas and "a bag of facts" is two structural rules, both defined once on the contract and re-checked everywhere a node can enter the graph.

Invariant 1 — every fact node carries ≥1 grounding pin. A kind: 'fact' node with groundingPins.length === 0 fails to parse with the message Invariant 1 violated: fact node "<id>" has no grounding pin (concept-graph.ts:67). No ungrounded fact may exist.

Invariant 2 — every STEM value node carries a kernelRef. A node whose discipline is in the STEM set and whose kind is fact | principle | procedure must carry ≥1 kernelRef, or it fails with Invariant 2 violated: STEM <kind> node ... has no kernelRef (its value must be computed, not asserted) (concept-graph.ts:74). The STEM set is exactly five disciplines — astronomy, physics, mathematics, chemistry, earth-science (V9_STEM_DISCIPLINES, primitives.ts:93) — and the gate function v9NodeRequiresKernel(discipline, kind) (concept-graph.ts:36) is the single source of truth for "is this node subject to Invariant 2." Topics and skills are organizational and exempt; a humanities fact is subject to Invariant 1 but not 2 (its value is textual, grounded by a pin, not computed).

These are enforced at three layers, deliberately redundant:

  1. At parseV9ConceptNodeSchema.superRefine (concept-graph.ts:66) throws.
  2. At insertAtlasStore.addNode re-parses through the same schema before storing (atlas-store.ts:61), so a node can only exist in a store if it is already valid. The unifier-test "rejects an ungrounded fact node at insert" pins this.
  3. At build — a non-throwing aggregator, checkV9ConceptNodeInvariants(node) (concept-graph.ts:133), returns every violation across a whole candidate set so the build gate can report and block (see the grounding gate below).

The reason for the redundancy is the unifier: when several source drafts collapse into one node, the merged node might still violate an invariant (e.g. an ungrounded fact that no source supplied a citation for). Rather than silently inventing a pin, the merged node is re-validated at insert and, if it fails, is reported as skipped, not coerced — a logged truncation, never a fabricated grounding (graph-unifier.ts:9).

Assembling the spine: source adapters and the unifier#

Each source graph "speaks its own dialect," so an adapter maps it into the shared V9ConceptNode/V9ConceptEdge vocabulary (libs/v9/atlas/src/source-adapters.ts):

  • Metis (fromMetisConceptGraph, source-adapters.ts:87) mirrors the ConceptGraphNode dataclass shape from the Python helper (source_concept_graph_helpers.py): topic/skill/fact/procedure/principle kinds already match V9, and fact nodes are grounded from their evidence_citation_ids — each citation becomes a V9SophiaPin at a caller-supplied default credibility (0.7 unless overridden). This is the seed of Invariant-1 coverage. The test "Metis adapter grounds fact nodes from evidence citations" verifies two citations become two pins at the configured credibility.
  • Sophia (fromSophiaKnowledgeGraph, source-adapters.ts:163) consumes a duck-typed SophiaGraphStoreLike (getAllNodes/getAllEdges), not the concrete @sophia/knowledge-graph GraphStore class — a structural seam that keeps Atlas from hard-coupling to Sophia's internals. Sophia entities (figures, texts, traditions) are organizational topic nodes with no grounding pin (they are not atomic facts), and the relation mapping is the lossy one noted above.
  • Mnemosyne (fromMnemosynePrereqGraph, source-adapters.ts:212) maps real @mnemosyne/core KGNode/KGEdge: it supplies the prerequisite skeleton, mapping prerequisite/part_of through and skills to skill kind.

All three adapters are pure transforms — they build draft nodes; they never enforce invariants themselves (that is the store's job), and mapEdges drops dangling/self-loop edges at the source.

The unifier (unifyAtlas, graph-unifier.ts:96) is the largest single piece of net-new logic. It works in three passes:

  1. Group by content-addressed label key. Every draft's label is slugged (atlasSlug: lowercased, NFKD-normalized, non-alphanumerics → hyphens, ≤100 chars, source-adapters.ts:49) and the slug is hashed to the canonical id cn:<sha256(slug)> (graph-unifier.ts:68). Two drafts that name the same thing — "Photosynthesis" from Metis and from Mnemosyne — slug identically and therefore land in the same group, independent of namespace.
  2. Merge each group into one node. Grounding pins and kernel refs are unioned and de-duplicated (by pinId / ref); aliases absorb the other drafts' labels so cross-source naming becomes searchable aka; and the kind is resolved to the most specific present via the priority principle > procedure > fact > skill > topic (KIND_PRIORITY, graph-unifier.ts:30) — so a topic from one source and a fact from another collapse to a grounded fact. The merged node is added to the store, which re-validates it; a failure goes to skipped with its reason.
  3. Remap, dedupe, and prune edges. Every per-source id is remapped to its canonical id; edges whose endpoints were skipped/unknown, became self-loops after the merge, or duplicate an existing (from,to,type) are dropped and counted in droppedEdgeCount.

The result is an AtlasUnificationResult reporting rawNodeCount, mergedNodeCount, the skipped list, and droppedEdgeCount — so a build can see exactly how much collapsed and what was honestly left out. The unifier tests pin each behaviour: same-concept collapse to one cn: node, an ungrounded fact surfacing as skipped (not coerced), and correct edge remapping.

Grounded leaves — references, not values#

The fourth input is domain data bound as leaves (grounded-leaves.ts). The key idea, and the reason Invariant 2 exists, is that a STEM node holds a reference to the computation, never the number. kalikaKernelLeaf mints a node carrying kernelRef.ref = 'kalika:cosmology#ageTodayGyr' (grounded-leaves.ts:70); nyxEphemerisLeaf binds nyx:ephemeris#sun.position (:106); nisabaSourceLeaf binds a humanities fact to a vetted source pin with no kernel (:139). The leaf holds the ref string; Atlas itself does not execute kernels.

The actual recomputation happens later, at G2, in Aletheia. Its kernel registry (libs/v9/aletheia/src/kernel-evaluators.ts:33) is the binding from a ref to a real in-repo computation — kalika:cosmology#ageTodayGyr → ageTodayGyr(PLANCK_2018_COSMOLOGY), nyx:constants#speedOfLight → SPEED_OF_LIGHT (299 792 458 m/s, libs/nyx/constants/src/physical.ts:18). The registry currently knows six refs. This is a deliberate fail-loud seam: when Prometheus builds ground truth it calls the evaluator (ground-truth.ts:65), and if a node references a ref the registry can't compute, the Aletheia accuracy gate emits cannot recompute ...: unknown kernel ref "<ref>" and blocks — it never passes an unverifiable number. So the leaf binders can name refs ahead of the evaluator supporting them; the cost is an honest G2 failure, not a silently-skipped check.

Wonder resolution#

Resolution (resolveWonder, libs/v9/atlas/src/wonder-resolution.ts:86) turns a free-text string into the minimal, prerequisite-ordered concept set to teach now.

flowchart TD W["free-text wonder<br/>'why is the night sky dark?'"] --> BM["BM25 lexical index<br/>over node label+aka+axis"] ATL[("AtlasStore<br/>ConceptNodes")] --> BM BM --> CAND["ranked candidates (top-K)"] EMB["optional dense reranker<br/>(Nous WonderEmbedder)"] -. "linear blend" .-> CAND CAND -->|"top-1"| TGT["target ConceptNode"] CAND -->|"none score > 0"| ERR[["WonderUnresolvedError"]] TGT --> PROJ["project Atlas → Mnemosyne<br/>KnowledgeGraph"] ATL --> PROJ MAST["learner mastery map"] --> PROJ PROJ --> GAP["identifyKnowledgeGaps()<br/>unmastered prerequisites"] GAP --> TOPO["topologicalSort()<br/>prerequisite order"] TOPO --> SET["conceptSet:<br/>gaps first, target last"]

Step by step:

  1. Lexical recall (always). A fresh BM25 index is built over each node's text — label + aka + discipline + wonderAxis (nodeText, wonder-resolution.ts:63) — and searched for the wonder. BM25 is the Sophia engine, not a reimplementation.
  2. Optional dense rerank (the Nous boundary). WonderEmbedder is an injected interface embed(texts) → vectors (wonder-resolution.ts:21). When present, every node is scored (1 − denseWeight)·bm25 + denseWeight·cosine(wonder, node) over all nodes, so a wonder with no lexical overlap ("the beginning of everything") can still reach the right concept. Absent ⇒ BM25-only. Honest characterization: the fusion is a weighted linear blend of the raw BM25 score and the dense cosine — not a rank-normalized fusion (RRF); the shipped tests exercise the pure-lexical end (no embedder) and the pure-dense end (denseWeight: 1), where the toy embedder steers "the beginning of everything" to the finite-age node.
  3. Disambiguation. The top-ranked candidate is the target; all ranked candidates are returned so the front door can show alternatives. If nothing scores above zero (or the Atlas is empty), it fails loud with WonderUnresolvedError rather than inventing a concept (wonder-resolution.ts:56).
  4. Mastery-scoped prerequisite frontier. Atlas is projected onto a Mnemosyne KnowledgeGraph annotated with the learner's mastery (toMnemosyneGraph, atlas-store.ts:113), and identifyKnowledgeGaps(mastery, target, masteryFloor) returns the unmastered prerequisites (default floor intermediate). Only prerequisite/part_of Atlas edges become prerequisite/structural KG edges; everything else is a lateral related_to, so the closure follows true dependencies, not mere relatedness (toKgRelation, atlas-store.ts:31).
  5. Minimal teaching set. The gaps are ordered by the graph's topological sort and the target is appended, giving conceptSet = [...orderedGaps, target] — prerequisite-first, target last. A cyclic prerequisite graph degrades gracefully: topologicalSort throws, and resolution falls back to the given order rather than failing the whole query (orderByPrerequisite, wonder-resolution.ts:153).

Worked example (from the test suite). Build an Atlas with How light travels, The universe has a finite age, and Olbers' paradox, with the first two as prerequisites of the third. resolveWonder('why is the night sky dark?') resolves to a:olbers (BM25 over the aliases), and with a learner who knows nothing the conceptSet is the full chain [finite-age, light, olbers], target last (wonder-resolution.spec.ts:55). Give the learner advanced mastery of a:light and the frontier drops to [finite-age]; the teaching set becomes [finite-age, olbers] (wonder-resolution.spec.ts:64). This is the cognitive-load discipline the product calls for, computed — not guessed.

The build-time grounding gate#

The validator (libs/v9/atlas/src/grounding-validator.ts) turns the per-node invariant checks into a graph-wide verdict. validateConceptNodes(nodes) walks a candidate set and returns an AtlasGroundingReport { ok, totalNodes, factNodes, stemNodes, groundedFactNodes, violations[] } (:28); validateAtlasGrounding runs it over a built store; and assertAtlasGrounded(atlas) throws on any violation, listing each offending node id and message (:57). The intent is a CI gate — Atlas grounding invariant violated by N node(s): ... — so an ungrounded node can never reach a shipped lesson. Because the store already rejects bad nodes at insert, a built store should always pass; the validator's real value is over a candidate set before it enters the store (e.g. an offline coverage audit that counts groundedFactNodes / factNodes).

How Atlas connects to its neighbours#

Atlas is a spine precisely because so much hangs off it:

  • Prometheus, Stage 0 → Stage 1. Wonder resolution feeds the concept set into buildGroundTruth (libs/v9/prometheus/src/ground-truth.ts:43), which reads each node back out of the store: grounded fact nodes become V9GroundedClaims carrying their pins; STEM kernel nodes become recomputed V9KernelResults via the Aletheia evaluator. If the concept set has no grounded fact node, Prometheus refuses to forge an ungrounded lesson (ground-truth.ts:86). See the Prometheus pipeline.
  • Theia, the wonder-director. nextWonders walks the outgoing enables → bridges → specializes → related edges to surface "the next, better question," and surpriseMe ranks unlearned concepts by the fraction of their prerequisites the learner has already mastered — both read straight off the Atlas store and its Mnemosyne projection (libs/v9/theia/src/theia.ts:31,92).
  • The experience layer. buildWonderFrontDoor wraps resolveWonder into the tested view-model the "ask a wonder" screen binds to, including the scaffolding depth (prerequisiteCount) and frontier "surprise me" suggestions (libs/v9/experience/src/wonder-front-door.ts:43); the personal Atlas star-map reads the same graph. See delivery and cross-cutting.

The reuse ledger for all of this — which @sophia, @mnemosyne, @kalika, and @nyx surfaces Atlas leans on, and where the seams are — is catalogued in the subsystem map and reuse ledger and the six-layer reuse stack.

Failure modes and edge cases#

  • Empty Atlas / no matchWonderUnresolvedError (fail loud, never invent).
  • Ungrounded merged node → reported in skipped with its invariant reason and left out of the store; the edges touching it are dropped and counted.
  • Cyclic prerequisite graphtopologicalSort throws inside orderByPrerequisite, which catches and falls back to the input order so resolution still returns a set.
  • Unknown kernel ref → Atlas accepts the leaf (the ref is a valid string), but the downstream Aletheia G2 gate fails loud at recompute time. The binding is honest about absence rather than fabricating a value.
  • Edge to a missing endpointAtlasStore.addEdge throws edge endpoint "<id>" is not a node in the Atlas (atlas-store.ts:70).
  • Performance shape (documented, not a bug). resolveWonder rebuilds the BM25 index and the Mnemosyne projection from allNodes() on every call (wonder-resolution.ts:96, atlas-store.ts:113). That is O(N) per query and fine at the current in-memory scale; it is a known cost to revisit when a persisted, large Atlas ships (see the spec-only note above).