Ariadne · Architecture

Surface Realization: Anansesem, Loom & Ori Suspects

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

6sections11 minread2diagrams

On this page

V8's architectural thesis splits a mystery into two layers: a symbolic skeleton (ground truth, clue logic, a uniqueness proof) that must be machine-verifiable, and an experienced surface (prose, dialogue, art, voice, living suspects) that agents realize from that skeleton. The symbolic core — Clew, Minos, Palimpsest — hands this layer a case it has already solved: a MysterySession IR whose culprit Minos proved is the unique model of the player-available clues, plus the hidden CaseGroundTruth it was derived from. This page is about the second layer — turning that proven skeleton into a playable, interrogatable mystery without ever letting the surface contradict the proof.

Three subsystems do the realization, and all three obey one inherited rule: the LLM may speak the case but never invent it — no new suspect, no new location, no number, and no lie the proof did not author. Anansesem (libs/yemaya/case-writers-room) is the writers' room that realizes scenes, briefings, character sheets, partner banter, and the static interrogation trees. Loom (libs/yemaya/case-assets) is the asset fabric that binds the case to generated environments, portraits, evidence props, music, and voice-over. Ori (libs/yemaya/case-suspects) is the runtime layer that turns each suspect into a memory-bearing, interrogatable mind. The orchestration and contracts are real and deterministic; the generation — LLM prose polish, Isis media, ACE/Inworld avatars — is provider-gated behind honest fail-loud / fail-closed seams. This is the surface companion to the symbolic-core page; the section hub is ../V8_ARCHITECTURE.md.

What ships, honestly#

The realization layer is real and exercised end-to-end, with the generative pieces sitting behind typed seams. Three tiers:

Implemented, deterministic, and tested (real today). The Loom service runs the whole mint→publish pipeline with zero external credentials: runPipeline (apps/v8/loom-service/src/pipeline.ts:127) threads Clew → verify → Anansesem → assets → compile → eval → safety → release, and the §12.4 acceptance suite mints a case, passes all eight ReleaseDecision gates, compiles a real V5 pack through the validate-cold-cases.py build gate, and reproduces bit-for-bit from the seed (apps/v8/loom-service/src/pipeline.test.ts, 5/5 green). With no LLM injected, Anansesem emits a complete, grounded, deterministic surface; Loom's planner, retrieval ranker, budget-degrader, voice caster, and MetaHuman mapper are real algorithms with their own unit tests (libs/yemaya/case-assets/src/*.test.ts); and Ori's memory stream, fair-lying guard, and offline voice are real and reproducible (libs/yemaya/case-suspects/src/*.test.ts).

Provider-gated injected seams (fail-loud / fail-closed, not faked). The actual generation is injected, never fabricated. Anansesem's prose enricher is a CompletionFn (types.ts:30) — absent, the deterministic base text stands; present, a provider error propagates. Loom's minter wants an Isis JobRunner (orchestrate.ts:52) — absent, with real gaps to fill, realizeCase throws rather than ship a half-empty pack. Ori's live avatar broker reads OSHUN_INWORLD_* / OSHUN_NVIDIA_ACE_* creds (runtime.ts:49) — absent, it reports { configured: false } and fails closed to the deterministic offline interrogation, which is itself fully playable and fair-play-correct.

Honest scope notes. The shipped §12.4 acceptance pack is text-only: the offline deps (deps.ts) wire no Isis runner, so no media is minted — Loom's fabric is real and unit-tested but not exercised by that run. And Ori is the runtime layer (Stage 9–10): it is driven by the relay/host, not by the offline mint, which bakes the static 12-beat trees Anansesem authored.

The realization boundary#

flowchart TB subgraph core["Symbolic core (proven)"] SK["MysterySession IR + CaseGroundTruth<br/>(culprit proven unique by Minos)"] end subgraph offline["Offline mint — loom-service/pipeline.ts"] AN["Anansesem<br/>realizeCase (DAG)"] LO["Loom<br/>realizeCase (fabric)"] CO["compile → V5 pack"] SK --> AN SK --> LO AN -->|"RealizedCase: scenes, sheets,<br/>static 12-beat trees, banter"| CO LO -->|"AssetManifest: bound urls + C2PA"| CO end subgraph runtime["Runtime — Stage 9/10"] ORI["Ori suspects<br/>FailClosedBroker"] SK -.->|"projects ground truth"| ORI end CMP(["CompletionFn — LLM polish"]) -.->|injected; err propagates| AN ISIS(["Isis JobRunner — media"]) -.->|injected; absent + gaps ⇒ throw| LO ACE(["ACE / Inworld + Hathor"]) -.->|injected; absent ⇒ fail-closed offline| ORI CO --> PACK[/"cold_cases pack → UE5 client"/]

Anansesem and Loom both read the same verified skeleton and write disjoint slices of the pack; Ori re-projects the skeleton at runtime so a live interrogation can never drift out of solvability.

Anansesem — the writers' room#

Anansesɛm (an Akan word for the Anansi storytelling tradition) realizes the narrative surface. It "only speaks the case" — solvability is owned by Clew and proven by Minos, and nothing here re-derives mystery logic (case-writers-room/src/types.ts:8). The public entry is realizeCase (dag.ts:244): it takes a WritersRoomInput (the session, the ground truth, the canon slice) and an optional CompletionFn, and returns a RealizedCase.

The realization DAG#

The room is a genuine directed graph built on an in-house StateGraph (V8 ships its own rather than importing the broken agents-orchestration barrel — dag.ts:2). A Showrunner sets the brief (title, era voice, grounding vocabulary) and fans the work out to five specialist writers — StoryDirector (the briefing), CharacterWriter (a sheet per cast member), DialogueWriter (an interrogation tree per suspect), Cinematographer (scene descriptions + shot notes), SoundDesigner (partner banter + audio cues) — whose disjoint slices an Integrator assembles (dag.ts:96). Because the five slices never read one another, order is immaterial; the graph is compiled and actually invoked (graph.compile().invoke(state)), not narrated. The graph id is literally anansesem-writers-room (dag.ts:97).

Grounding: the closed vocabulary#

The hard guarantee is in grounding.ts. From the ground truth + canon, the Showrunner builds a GroundedVocabulary (grounding.ts:58): the closed set of people (victim + suspects + canon characters), location ids and display names, and the weapon. assertGrounded (grounding.ts:241) is a real proper-noun extractor, not a shape check: it walks every realized string, lifts runs of two or more consecutive capitalized, name-shaped words (extractProperNouns, grounding.ts:182) — the shape that could smuggle in an invented "Cornelius Blackwood" — strips genitives, excludes function words and sentence-initial verbs, and reports any token resolving to no grounded entity as a GroundingViolation. The room cannot name a suspect or place the skeleton didn't author.

Interrogation: twelve tells, one fair lie#

buildInterrogation (interrogation.ts:123) aligns each suspect to V5's twelve facial-tell beats (TELL_BEATS, interrogation.ts:26) and the Truth / Doubt / Lie mechanic, with invariants that tie the surface back to the proof:

  • Every tree has exactly 12 beats; beat 7 is the alibi beat, beat 9 the pressure beat.
  • The culprit lies exactly once — their alibi (beat 7) is a Lie whose refutedByClueId is the alibi-flaw clue (alibiFlawClue, interrogation.ts:67); the builder throws if the culprit has no flaw clue in the session (interrogation.ts:146).
  • An innocent's alibi is Truth (verified, with grounded corroborator names) or Doubt (unverifiable) — never a Lie about the alibi. A red-herring innocent may tell one deflatable Lie tied to a fair herring clue (herringClueFor), so the "every Lie carries a refuting clue" invariant (InterrogationLine.refutedByClueId, types.ts:139) always holds.

Stress tells escalate with veracity (a Lie under pressure plays gaze-aversion / swallow / jaw-clench; Truth plays calm — tellFor, interrogation.ts:98), and the prose for non-decisive beats is deterministic and grounded in the real victim name, weapon, and place.

The enrichment seam (fail-loud)#

Every node's prose passes through enrichLine (enrich.ts:32). With no CompletionFn it returns the deterministic base unchanged. With one, it asks the model to rewrite the line in register — and then re-grounds the result: withinBoundary (enrich.ts:69) rejects any rewrite that introduces a name-shaped token outside the allowed proper-noun set, falling back to the deterministic base (fail-safe on content). A provider error, by contrast, propagates uncaught (enrich.ts:39) — the room never swallows an outage into silently-degraded prose. The model can polish; it cannot invent a suspect, and it cannot fake success.

Loom — the asset-realization fabric#

Loom binds a case to multimedia through the existing Isis client. Its entry, realizeCase (case-assets/src/realize.ts:62), runs the fabric end to end: plan → retrieve → degrade-to-budget → mint the surviving gaps → bind + assemble, returning a contract-valid AssetManifest.

Planning: counts derived, never literal#

planAssets (plan.ts:166) computes exactly which assets a case needs from its structure — every count derived, none a fixed literal: one environment per distinct story location (de-duped by slug), one portrait per suspect plus the victim, one 3D prop per physical/forensic clue and one document/photo per documentary/testimony clue (classifyClueModality, plan.ts:138), case music plus one sting per revelation/climax plot point, one voice-over line per realized dialogue line (or a VO-minute-budget-derived set, deriveDialogueBudget), and optional flashback/CCTV video per timeline beat at the crime scene. Each PlannedAsset carries semantic tags (cell, era, subject, clue type) and a boundTo target so the binder can wire the produced url back into the exact V5 evidence node.

Retrieve-then-generate#

Before minting anything, retrieveThenGenerate (retrieve.ts:67) asks an injected AssetLibrary whether an existing asset already satisfies a plan entry — reuse beats mint when the world already has the right thing. The default InMemoryTagAssetLibrary is a real ranker (retrieve.ts:123): a specificity-weighted tag overlap (identity tags like location:/subject:/ clue: weighted 4×, shared context tags like cell:/era:/theme: weighted 1×, retrieve.ts:175) blended 0.8/0.2 with a description-token Jaccard. The weighting is the point: a wrong-location environment that shares all the case boilerplate still falls below the 0.62 reuse threshold (DEFAULT_REUSE_THRESHOLD) and gets minted bespoke. It is computed retrieval, never always-reuse and never always-mint; the same surface can be backed by Isis's CLIP semantic index through the isis-asset-library.ts adapter.

Budget, voice, and MetaHuman#

degradeToBudget (budget-degrade.ts:87) reserves asset-jobs from the CaseBudget and, when the ceiling is hit, degrades rather than fails in a real priority order — environments and portraits first, evidence next, optional flashback video dropped first (mintPriority, budget-degrade.ts:46) — logging every drop so the plan is never silently truncated. castVoice (voices.ts:85) assigns each character one stable ElevenLabs-style voice id by reducing a SHA-256 of the key over a register-partitioned pool (DEFAULT_VOICE_POOL of 12 distinct voices), so the same character sounds the same across every case they appear in. assembleMetaHuman (metahuman.ts:87) maps a portrait + persona tags to MetaHuman Creator's real axes (skin-tone ramp, age 18–80, hair, facial hair, body type, age-scaled detail overlays) deterministically from the character id.

Minting through Isis (fail-loud)#

The mint step is where Loom refuses to fabricate. mintGaps (orchestrate.ts:208) builds a real JobRequest per gap — the right generation type, a prompt assembled from the case with period-correct dimensions and an anachronism negative-prompt, and an idempotency key derived from a per-asset seed — and submits it through a JobRunner structurally satisfied by @isis/client. Any runner error, a job-level error, or a job that returned no output file is raised as a MintingError (orchestrate.ts:67); it never invents a result url. And realizeCase throws if real gaps remain with no runner wired (realize.ts:80) — an absent backend is surfaced, not papered over.

Ori — the living suspects#

Ori (Yoruba for the "inner head," the seat of identity) is the runtime that makes a suspect interrogatable rather than a fixed dialogue tree. Each suspect carries a SuspectOri (ori.ts:106) seeded deterministically from caseId + suspectId: a total projection of the hidden ground truth — what they KNOW (their movements, who they saw, their alibi), what they HIDE (the culprit conceals the deed behind a flawed alibi; innocents may hide an unrelated, harmless secret), and the set of fair lies they may tell. Because the Ori invents nothing the symbolic layer didn't author, the runtime can never drift out of solvability. Persona axes are derived, not random: the culprit is measurably more evasive and more stressed under questioning than a cleared innocent (ori.ts:216).

sequenceDiagram participant D as Detective (player) participant B as FailClosedBroker participant M as MemoryStream participant G as Fair-lying guard participant V as Offline voice / Hathor D->>B: send(utterance [present:clueId]?) B->>M: tick + remember(observation, importance) alt clue presented B->>G: crackUnderEvidence(ori, clueId, session) G-->>B: cracks iff clue establishes the alibi-flaw fact end B->>M: maybeReflect (≥2.0 poignancy) + plan(stance) B->>G: canLie(candidateLie, session)? G-->>B: true iff a reachable clue refutes it B->>V: generate(stance, context, allowLie) V-->>D: grounded line (or grounded confession)

The memory stream (Generative Agents)#

MemoryStream (memory.ts:89) implements the Park et al. Generative Agents retrieval: every memory carries an importance/poignancy, and retrieval scores candidates by recency · importance · relevance (an 8-turn recency half-life, a Jaccard relevance term, memory.ts:149). This is what keeps re-interrogation consistent across V5's multiple passes — the suspect cannot say "I was home" then "I was at the club," because the prior statement is retrieved before the next answer. Once accumulated poignancy crosses a threshold (2.0, tuned so two firm accusations plus a presented clue trip it but idle small-talk does not), maybeReflect (memory.ts:174) synthesizes a higher-level belief ("the detective has hard evidence and is closing in"), and plan (memory.ts:207) chooses the stance to hold next turn — cooperative → guarded → deflecting → rattled → cracking — from accumulated pressure.

The fair-lying guard#

fair-lying.ts is the runtime half of the fair-play covenant Minos enforces at authoring time. canLie (fair-lying.ts:105) returns false for any lie that no player-reachable clue can refute: it indexes the session's clues by the ground-truth fact each establishes (indexClues, via each clue's metadata.groundTruthFactId), and a lie is permitted only if a reachable clue proves its refutingFactId. An Ori may never assert an "authorial cheat" delivered through dialogue. crackUnderEvidence (fair-lying.ts:149) is its mirror: the culprit cracks only when the player presents the specific clue that establishes the fact refuting their load-bearing alibi lie — an irrelevant clue raises pressure but does not break them, and an innocent (who told the truth) never cracks. The confession is grounded in the suspect's own ground truth, not improvised.

The fail-closed broker#

FailClosedBroker (runtime.ts:174) is the §6.5 seam to a managed avatar service. It reads ACE/Inworld credentials once (credentialsFromEnv, runtime.ts:49) and reports an honest status() of { configured, mode }; it is "live" only when creds and a real live broker implementation both exist (runtime.ts:197). With neither, it fails closed to an OfflineInterrogation — the memory stream, the guard, and the deterministic OfflineDialogueBackend (dialogue-backend.ts:104), whose persona-driven voice is a pure function of the Ori, the question, the stance, and whether a lie was authorized. A live LLM is adapted in through hathorBackendFrom (hathor-adapter.ts:70), which builds a grounded persona and propagates any provider error (fail-loud). The host treats both modes through one send surface, so the game stays playable and fair-play-correct even with no cloud avatar — the same fail-loud-when-unconfigured discipline the platform's domain-orchestration layer uses everywhere.

Where this connects#

  • Upstream: Clew, Minos & Palimpsest — the symbolic core produces and proves the verified skeleton this layer realizes; the fair-lying guard is the runtime mirror of Minos's authoring-time fair-play proof.
  • Downstream: Daedalus & Theseus — compile and eval compiles Anansesem's trees + Loom's manifest into the V5 pack and play-tests it end to end; the Loom service (apps/v8/loom-service) is the relay the UE5 client mints and fetches cases through.
  • Platform: the domain-orchestration layer owns the Isis (media), Hathor (NPC dialogue), and ACE/Inworld substrates these three subsystems compose behind their injected seams.