# Surface Realization: Anansesem, Loom & Ori Suspects

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](./clew-minos-palimpsest-symbolic-core.md); the section hub
is [../V8_ARCHITECTURE.md](../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

```mermaid
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`).

```mermaid
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](../../platform/oshun-domain-libraries.html) uses
everywhere.

## Where this connects

- **Upstream:**
  [Clew, Minos & Palimpsest — the symbolic core](./clew-minos-palimpsest-symbolic-core.md)
  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](./daedalus-compiler-and-theseus-eval.md)
  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](../../platform/oshun-domain-libraries.html) owns
  the Isis (media), Hathor (NPC dialogue), and ACE/Inworld substrates these
  three subsystems compose behind their injected seams.
