From ece1bca460f9f1015ad2c42f20a84329598e7119 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 8 Aug 2026 14:37:54 +0000 Subject: [PATCH] Ground NPC occurrences by canonical names --- .../npc-occurrences/prompts/instructions.md | 4 +- .../schemas/dnd_npc_occurrences_llm.v1.json | 5 +- docs/roadmap/contextual-entity-grounding.md | 350 ++++++++++ docs/roadmap/implementation.md | 609 ++++++++++++++++++ .../cli/dnd_enemy_events_contract_test.go | 14 +- .../extract/npcoccurrences/canonicalize.go | 14 +- .../dnd/extract/npcoccurrences/extractor.go | 25 +- .../extract/npcoccurrences/extractor_test.go | 100 ++- .../dnd/extract/npcoccurrences/model.go | 1 - .../dnd/extract/npcoccurrences/model_test.go | 4 +- .../npcoccurrences/prompt_assets_test.go | 2 +- .../dnd/extract/npcoccurrences/schema_test.go | 4 +- .../normalize/npcoccurrences/normalizer.go | 2 +- .../modules/dnd/npcs/registry/registry.go | 24 +- .../dnd/npcs/registry/registry_test.go | 15 +- .../npcoccurrences/invariants/validator.go | 2 +- .../npcoccurrences/registry/validator.go | 2 +- .../dnd_npc_occurrences_runner_test.go | 9 +- 18 files changed, 1088 insertions(+), 98 deletions(-) create mode 100644 docs/roadmap/contextual-entity-grounding.md create mode 100644 docs/roadmap/implementation.md diff --git a/assets/dnd/npc-occurrences/prompts/instructions.md b/assets/dnd/npc-occurrences/prompts/instructions.md index 7fd75dd..c6e451a 100644 --- a/assets/dnd/npc-occurrences/prompts/instructions.md +++ b/assets/dnd/npc-occurrences/prompts/instructions.md @@ -1,8 +1,8 @@ Extract Dungeons & Dragons NPC occurrences from the supplied transcript. Include an occurrence only when the transcript establishes one supplied NPC, one occurrence kind, and a coherent passage supporting both. -Use the exact `npc_id` and matching `name` pair from the supplied NPC registry; -never invent an ID or substitute a similar name. +Use the supplied canonical NPC `name`; never invent or substitute a similar +name. Cite current-transcript evidence for every occurrence. Do not summarize, infer relationships, sentiment, factions, motives, aliases, or persistent state. Do not identify player characters, anonymous groups, or diff --git a/assets/dnd/npc-occurrences/schemas/dnd_npc_occurrences_llm.v1.json b/assets/dnd/npc-occurrences/schemas/dnd_npc_occurrences_llm.v1.json index 8ee5c38..fd0a230 100644 --- a/assets/dnd/npc-occurrences/schemas/dnd_npc_occurrences_llm.v1.json +++ b/assets/dnd/npc-occurrences/schemas/dnd_npc_occurrences_llm.v1.json @@ -10,11 +10,8 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["npc_id", "name", "kind", "source_refs"], + "required": ["name", "kind", "source_refs"], "properties": { - "npc_id": { - "type": "string" - }, "name": { "type": "string" }, diff --git a/docs/roadmap/contextual-entity-grounding.md b/docs/roadmap/contextual-entity-grounding.md new file mode 100644 index 0000000..70b2075 --- /dev/null +++ b/docs/roadmap/contextual-entity-grounding.md @@ -0,0 +1,350 @@ +# Contextual Entity Grounding + +## Purpose + +Notarius should use an LLM for semantic interpretation of source evidence, not +for referential-integrity work that deterministic code can perform more +reliably. D&D prompts must therefore stop requiring models to reproduce opaque +machine identifiers such as hash-derived entity IDs. Models should identify +entities through human-readable, evidence-grounded context, after which +Notarius resolves the selection and attaches the canonical durable identity. + +This roadmap defines the policy, affected D&D prompt families, and intended +end state. The ordered work needed to reach that state is maintained in +[Implementation Plan](implementation.md). + +## User Intent + +The change has two goals: + +- prevent otherwise useful model responses from failing because a long, + non-semantic string was copied incorrectly; and +- avoid spending prompt space and model effort on exact-copy work that provides + no semantic value. + +The policy is not a ban on identifiers. Durable artifacts may continue to use +application-owned IDs, and prompts may continue to request source-unit ranges +that locate evidence. The policy governs which identity work is assigned to +the model. + +## Policy + +An LLM-facing prompt input or private response schema must not require a model +to reproduce an opaque machine identifier when Notarius can establish the same +association deterministically. + +Opaque machine identifiers include cryptographic hashes, UUIDs, digests, +database keys, durable entity IDs, and other tokens whose characters do not +carry source-grounded meaning for the model. These values may remain in +application state, provenance, diagnostics, checkpoints, and durable artifact +contracts, but should be omitted from model-visible material when they do not +help the model make a semantic decision. + +The intended responsibility boundary is: + +- the model decides which contextual entity is supported by the supplied + evidence and returns the bounded semantic facts requested by the module; +- the calling module validates that the contextual selection resolves to + exactly one supplied candidate; +- deterministic code supplies the canonical display value and durable entity + ID; and +- existing validators continue to enforce referential integrity at later + artifact boundaries. + +Transcript `start_unit_id` and `end_unit_id` values are permitted. They are +contextual source coordinates and form part of the evidence contract rather +than arbitrary identity tokens. Prompt IDs, schema IDs, fingerprints, session +IDs, and digests may also remain in runtime metadata that the model is not +asked to reproduce. + +Short request-local labels are a narrowly permitted fallback only when a +contextual selector cannot uniquely represent the available choices without +unreasonable prompt cost. Such a label must be compact, scoped to one request, +validated against the supplied candidate set, and never reused as a durable +identity. Current D&D occurrence and reconciliation prompts should be designed +without this exception; adopting it later requires a concrete demonstrated +need and documented rationale. + +## Current State + +The initial NPC, item, and location registry extractors already follow the +desired pattern: the model returns contextual names and evidence, and Notarius +derives durable IDs afterward. Spells, combat turns, and enemy events use +contextual actor names rather than requiring hash-derived NPC IDs. + +Two current prompt families diverge from that pattern: + +1. `dnd/npc-occurrences`, `dnd/item-occurrences`, and + `dnd/location-occurrences` place durable registry IDs in model-visible + projections and require the private LLM response to repeat those IDs. +2. NPC-, item-, and location-registry normalization use the shared entity + reconciliation prompt, which labels candidates with opaque + `candidate-000001`-style keys and requires the model to copy those keys into + duplicate-group proposals. + +The durable occurrence artifacts correctly retain canonical entity IDs. The +problem is the private model transport contract, not the published artifact +contract. + +## Target Architecture + +### Model proposals and durable artifacts + +Private LLM response types must express contextual semantic proposals rather +than reuse the durable artifact type when that type contains an opaque entity +ID. The extractor maps a validated private response into the existing durable +artifact only after identity resolution succeeds. + +No affected durable artifact kind, media type, schema ID, schema version, or +JSON field changes as part of this work. NPC, item, and location occurrence +artifacts continue to publish their exact canonical ID/name pair. Registry +artifacts likewise retain their IDs and evidence. + +The private schemas and prompt declarations may remain at their current `v1` +identities because Notarius is pre-release and these are not external +contracts. Their content hashes, mapping-policy fingerprints, and affected +prompt fingerprints must change so incompatible checkpoints are not reused. + +### NPC occurrence grounding + +The NPC occurrence prompt receives an ordered names-only projection of the +normalized NPC registry. Its private response contains the canonical NPC name, +occurrence kind, and current-transcript source ranges, but no `npc_id`. + +The extractor resolves the returned name under the existing NPC comparison +policy. Resolution must produce exactly one registry entry. It then writes that +entry's canonical display name and durable ID into the `dnd.NPCOccurrence`. +An unknown or ambiguous selection invalidates the extraction operation; the +extractor must not guess, use fuzzy matching, silently omit the record, or +accept a partial response. + +### Item occurrence grounding + +The item occurrence prompt receives an ordered names-only projection of the +normalized item registry. Its private response contains the canonical item +name, occurrence kind, kind-specific fields, and current-transcript source +ranges, but no `item_id`. + +The extractor resolves the returned name under the existing item comparison +and identity policies. Resolution must produce exactly one registry entry, +whose canonical name and durable ID are attached deterministically. Unknown or +ambiguous selections invalidate the complete extraction operation rather than +being guessed, repaired by similarity, or dropped. + +### Location occurrence grounding + +Location identity cannot always be resolved from a display name alone: the +current registry intentionally permits same-name locations with distinct +source anchors. The location occurrence prompt must therefore receive a +contextual registry descriptor that contains the canonical display name plus +the minimum source-grounded registry evidence needed to distinguish same-name +records. It must not contain the durable `location:sha256:...` value. + +The private response uses two required selector fields: `name` and +`registry_refs`. For a comparison-unique canonical name, `registry_refs` is an +empty array and Notarius resolves the name under the location comparison +policy. For a name shared by multiple registry records, `registry_refs` +contains that record's complete canonically ordered registry ranges as +`start_unit_id` and `end_unit_id` pairs, without `source_id`. + +Every model-facing registry entry uses one fixed shape with required `name`, +`registry_refs`, and `context` fields. `context` is an array of strict objects +containing only `unit_id` and `text`. Comparison-unique entries use empty +`registry_refs` and `context` arrays. Same-name entries use the complete +registry-range selector and the bounded context described below. The model +returns only `name` and `registry_refs`; it does not reproduce `context`. + +For same-name groups, the projection also supplies bounded transcript units +covered by each record's registry ranges so the model receives meaningful +identity context rather than coordinates alone. Those ranges must resolve +against the current source document, and the resulting contextual selectors +must be unique. An invalid range or selector collision prevents the LLM call +and fails the operation. Unique-name entries do not repeat registry ranges or +context in the selector, preserving compatibility with a valid registry from +another source when the name alone is unambiguous. + +The private response separately supplies current-transcript `source_refs` that +prove the occurrence. Registry identity evidence and occurrence evidence must +remain different fields and must never be merged. The model should omit an +occurrence when the transcript does not support choosing among same-name +locations. If a returned selector does not resolve to exactly one supplied +registry record, the extractor invalidates the complete operation rather than +guessing. + +### Registry reconciliation + +The shared entity-reconciliation input replaces opaque candidate keys with +contextual candidate descriptors. At minimum, a descriptor contains the +candidate's display name and its canonically ordered source-reference ranges; +the existing transcript windows remain available for semantic judgment. + +Duplicate-group members and the canonical member in the private response use +the same contextual descriptor shape. The shared reconciliation helper maps +each descriptor back to exactly one internal candidate before assessing the +proposal. Exact deterministic duplicates should already be removed before the +LLM call; any remaining descriptor collision makes the affected candidate +ineligible for model-assisted reconciliation rather than authorizing an +arbitrary choice. + +Existing safety behavior remains in force: groups must contain at least two +supplied candidates, the canonical candidate must be a member, groups must not +overlap, and domain-specific eligibility rules remain authoritative. Invalid, +ambiguous, or unsafe groups are discarded through the existing bounded +fallback and diagnostic behavior. The model never directly mutates the +durable registry. + +The shared private reconciliation schema and helper must remain domain-neutral +within the D&D family. NPC-, item-, and location-specific duplicate policy +continues to live in the owning normalizer. + +## Prompt And Asset Changes + +The following LLM-facing assets are in scope: + +- the prompt instructions, registry input fragments, and private response + schemas for NPC, item, and location occurrences; +- the prompt manifests where input shape or selected fragments change; +- the shared D&D entity-reconciliation fragment and private response schema; + and +- the NPC-, item-, and location-registry normalization prompt inputs that use + the shared reconciliation contract. + +Affected projections must exclude durable entity IDs rather than merely stop +mentioning them in prose. Prompt instructions should describe the contextual +selection rule once at the narrowest owning asset and must preserve the current +distinction between registry grounding and transcript evidence. + +Prompt ordering and cache controls should remain unchanged unless the new +contextual input requires an intentional manifest change. Unrelated shared +prompt bytes should not be edited. Prompt and schema fingerprints should +invalidate only the operations whose selected assets or mapping semantics +changed. + +## Code And Validation Changes + +The occurrence extractors need private response types and deterministic +registry-resolution paths appropriate to their domain. Shared code is +appropriate only for demonstrated mechanics that have identical semantics; +NPC, item, and location ambiguity policies must not be forced behind a generic +resolver merely to reduce line count. + +Registry projections should expose explicit model-facing methods whose names +describe whether they are names-only or contextual identity projections. The +existing ID/name projections may remain only for deterministic consumers that +genuinely require them; they must no longer be wired to an LLM input. + +Mapping-policy and normalization-policy identifiers must be reviewed and +advanced wherever their semantics change. Checkpoint fingerprints must cover +the new projection content, private schema, prompt assets, and mapping policy, +while continuing to exclude irrelevant internal implementation details. + +Durable occurrence normalizers and registry validators remain defense in +depth. They continue to validate exact ID/name pairs on artifacts entering +through checkpoints, codecs, or other boundaries even though the LLM no longer +produces the ID directly. + +## Testing And Evaluation + +Tests should protect the behavioral boundary rather than prompt prose or +private helper structure. The completed work should demonstrate that: + +- affected model-facing registry projections do not contain durable entity + IDs; +- private occurrence schemas reject opaque ID fields and accept the intended + contextual shape; +- valid contextual selections map to the exact canonical durable ID/name pair; +- unknown, mismatched, and ambiguous selections fail without fuzzy matching, + partial acceptance, or arbitrary reassignment; +- same-name locations remain distinguishable through contextual evidence; +- reconciliation preserves equal-name candidates, resolves valid contextual + groups, and discards ambiguous or unsafe proposals; +- registry evidence never becomes occurrence evidence; +- durable codec, normalization, and validator behavior remains compatible; and +- representative assembled D&D pipelines still prepare and execute with fake + structured-LLM responses. + +Do not add repository-wide prompt-prose snapshots, exact-message-count tests, +or a change-detector test that merely scans for today's field names. Focused +projection, schema, mapping, fallback, and integration tests are the stable +owners of these risks. Model-quality evaluation with representative +transcripts remains a manual development aid rather than an offline test gate. + +## Documentation And Architectural Record + +This policy is durable and applies to future modules, so it warrants +`docs/adr/0012-resolve-opaque-entity-identifiers-deterministically.md`, which +records: + +- the semantic-proposal versus referential-integrity boundary; +- why durable opaque IDs are excluded from model response contracts; +- why contextual evidence coordinates remain permitted; +- the narrowly scoped request-local-label exception; +- alternatives including durable IDs, names-only matching, and short opaque + handles; and +- the consequences for private schemas, deterministic resolution, debugging, + and ambiguous identities. + +`docs/policy/architecture.md` states the general LLM boundary invariant and +links to the ADR. `docs/internal/dnd.md` describes the concrete occurrence +projections, contextual reconciliation selectors, resolution and failure +behavior, and the continued separation of registry grounding from occurrence +evidence. `docs/internal/llm.md` contains only a short clarification that +caller-owned modules, not PromptKit or the transport adapter, resolve +contextual model selections into application identities. + +The NPC, item, and location occurrence and registry integration documents must +continue to own their durable wire contracts, while removing current claims +that the model-facing consumer projection contains `{id,name}` or that the raw +LLM response supplies the durable ID. They should instead explain that +Notarius resolves contextual model output and publishes the same exact durable +ID/name pair. No public schema examples need to remove those IDs. + +The generic LLM-assisted deduplication entry in `docs/roadmap/future.md` must be +reconciled with this policy: stable IDs may exist inside deterministic state, +but a future model-facing proposal should use contextual selectors or a +documented request-local-label exception rather than durable IDs. + +## Compatibility And Operational Effects + +This work intentionally changes private prompt inputs, private structured +responses, and mapping semantics. It will invalidate affected checkpoints +through existing prompt, schema, projection, and policy fingerprints. No +manual checkpoint migration is required. + +Durable D&D artifacts and generated-reference compatibility remain unchanged. +Operators do not receive new configuration fields or CLI controls. The feature +does not change PromptKit, provider routing, profile selection, retries, +concurrency, or public output placement. + +## Non-Goals + +This work does not: + +- remove canonical IDs from durable registries or occurrence artifacts; +- change occurrence categories, evidence rules, or registry identity policy; +- add fuzzy, probabilistic, or embedding-based entity resolution; +- allow registry provenance to substitute for occurrence evidence; +- introduce a general entity graph or cross-artifact identity framework; +- redesign unrelated D&D prompts or their schemas; +- implement the future generic deduplication normalizer; or +- add provider-specific prompt behavior. + +## Acceptance Criteria + +The target state is complete when: + +- no maintained D&D prompt requires a model to reproduce a durable opaque + entity ID; +- current D&D reconciliation prompts no longer require opaque candidate keys; +- NPC, item, and location occurrence LLM outputs are resolved + deterministically into their unchanged durable artifacts; +- same-name location and reconciliation cases remain safe and unambiguous; +- invalid contextual selections preserve the existing extraction-failure or + normalization-fallback semantics appropriate to their stage; +- affected checkpoint identities change without altering public schema + versions; +- focused and repository-wide tests pass offline; +- the ADR, architecture invariant, D&D internal guide, LLM internal guide, + relevant integration contracts, and future roadmap accurately describe + their canonical portions of the implemented policy; and +- no unrelated code, prompt behavior, or public contract changes are included. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md new file mode 100644 index 0000000..2eb895b --- /dev/null +++ b/docs/roadmap/implementation.md @@ -0,0 +1,609 @@ +# Contextual Entity Grounding Implementation Plan + +## Objective + +Implement [Contextual Entity Grounding](contextual-entity-grounding.md) so D&D +LLM prompts return evidence-grounded contextual selectors while Notarius owns +canonical entity IDs and referential integrity. Preserve every durable D&D +artifact contract and remove opaque IDs only from model-visible inputs and +private model responses. + +This plan is written for a gpt-5.6-terra coding agent. Implement the stages in +numeric order. Each stage is intentionally scoped to one implementation prompt +and must leave the repository buildable and its focused tests passing before +the next stage begins. + +## Plan-Wide Decisions + +Apply these decisions throughout every stage: + +- Read `docs/development.md`, all files under `docs/policy/`, the feature + roadmap, and the focused implementation/tests named by the stage before + editing. +- Preserve the fixed pipeline, typed artifact boundaries, root `assets` + content-only rule, module ownership, PromptKit boundary, and evidence rules. +- Do not change the durable NPC-, item-, location-registry, or occurrence Go + types, JSON schemas, schema IDs, schema versions, media types, reference-slot + contracts, categories, or generated-reference compatibility. +- Keep the affected prompt and private response-schema identities at `v1`. + They are private pre-release transport contracts; their changed content + hashes provide the required compatibility boundary. +- Advance semantic policy identifiers exactly as directed in each stage. Do + not bump unrelated policy identifiers. +- A contextual name match uses the entity family's existing comparison policy, + never fuzzy matching. A model selection must resolve to exactly one supplied + record before a durable ID is attached. +- An invalid NPC, item, or location selection invalidates the complete + extraction operation. Do not silently drop one response record, accept a + partial artifact, or defer a known mapping failure to a later validator. +- Registry provenance remains grounding only. Only the current extraction + chunk's `source_refs` become occurrence evidence. +- Preserve prompt message order and cache controls unless a stage explicitly + directs otherwise. Edit only the selected module or shared assets; do not + rewrite unrelated shared prompt bytes. +- Preserve internal opaque IDs where deterministic code needs them. The rule + applies to material shown to the model or requested from it, not to maps, + fingerprints, checkpoints, diagnostics, or durable artifacts. +- Follow `docs/policy/testing.md`: test package-level behavior and meaningful + failure modes, not prompt prose, exact message counts, private helper + structure, or a repository-wide string-scanning change detector. All tests + remain deterministic, offline, and credential-free. +- Use `apply_patch` for edits, `gofmt` changed Go files, and preserve unrelated + worktree changes. + +## Final Private Selector Contracts + +These shapes are implementation requirements, not public artifact schemas. + +### NPC occurrence response + +Each response record contains exactly the required fields `name`, `kind`, and +`source_refs`. It does not contain `npc_id`. Notarius resolves `name` through +the normalized NPC registry and writes the matched registry record's `ID` and +canonical `Name` into the durable occurrence. + +### Item occurrence response + +Each response record contains the existing required `name`, `kind`, +`quantity`, `from`, `to`, and `source_refs` fields. It does not contain +`item_id`. Retain the current nullable representation and kind-specific +semantics. Notarius resolves `name` through the normalized item registry and +adds the matched `ID` and canonical `Name`. + +### Location occurrence response + +Each response record contains exactly the required fields `name`, +`registry_refs`, `kind`, and `source_refs`. `registry_refs` is always an array +of strict objects containing required integer `start_unit_id` and +`end_unit_id`; it may be empty. + +- When `name` has one comparison-identity match in the supplied registry, + `registry_refs` must be empty and name resolution selects that record. +- When multiple registry records share the comparison identity, + `registry_refs` must equal one record's complete canonically ordered source + ranges with `source_id` removed. +- The model-facing registry projection uses the same `name` plus + `registry_refs` selector and adds a required `context` array. Unique-name + records project empty `registry_refs` and `context` arrays. The private + response does not reproduce `context`. +- Same-name records receive bounded identity context consisting of the ordered + source units covered by their registry ranges. Each context element is a + strict object with exactly the required fields `unit_id` (integer) and + `text` (string); do not expose the durable location ID, source ID, digest, + or a replacement token. +- Building same-name grounding validates that every registry range belongs to + and resolves against the current source. If two records still produce the + same contextual selector, grounding construction fails before the LLM call. +- `registry_refs` never flow into the durable occurrence's `source_refs`. + +### Entity-reconciliation response + +The shared response remains an object with required `duplicate_groups`. +Every group has required `members` and `canonical`. A member and the canonical +selection are strict contextual objects containing: + +```json +{ + "name": "Mira Thorn", + "source_refs": [ + {"start_unit_id": 12, "end_unit_id": 12} + ] +} +``` + +The candidate prompt input uses the same descriptor and contains no `key`. +`source_refs` is required and non-empty for every eligible candidate. The +shared helper may retain its existing `candidate-000001`-style keys strictly +inside Go state to preserve input-position mapping; those keys must never be +serialized into prompt input or accepted in the private response. + +If two candidates produce an identical contextual descriptor, neither is +eligible for model-assisted reconciliation because the model cannot identify +them independently. Otherwise the helper converts returned descriptors to its +internal candidate keys before applying all existing unknown-member, +ineligible-member, duplicate-member, canonical-membership, overlap, retry, and +fallback rules. + +## Stage 1: Convert NPC Occurrences To Name-Based Resolution + +### Goal + +Remove durable NPC IDs from the NPC-occurrence prompt and private response, +then resolve the model's contextual name deterministically without weakening +checkpoint identity or downstream validation. + +### Work + +1. Inspect: + - `assets/dnd/npc-occurrences/`; + - `internal/modules/dnd/extract/npcoccurrences/`; + - `internal/modules/dnd/npcs/registry/`; + - NPC-occurrence normalizer and validator checkpoint fingerprints; and + - their focused tests. +2. Change `dnd_npc_occurrences_llm.v1.json` so every occurrence requires only + `name`, `kind`, and `source_refs`, continues to reject unknown fields, and + no longer declares `npc_id`. +3. Revise the NPC-occurrence instructions to require a supplied canonical NPC + name and current-chunk evidence, with no instruction to copy or invent an + ID. Continue using the existing shared names-only NPC registry fragment and + preserve manifest order/cache controls. +4. Remove `NPCID` from the private `occurrenceResponse`. After canonicalizing + response evidence, resolve every response name with the existing + `npcregistry.Registry.Lookup` comparison-key lookup. On the first unknown + or non-unique selection, return an extractor-scoped mapping error and no + value. For a match, construct the durable occurrence with the registry + record's exact `ID` and canonical `Name`. +5. Change `mappingPolicy` to + `dnd.npc_occurrences.extract_mapping.v3`. +6. Stop passing `IdentityPromptInput()` to the LLM; use the existing + names-only `PromptInput()`. +7. Replace the misleading exported model-input API used only for identity + fingerprints: retain the unexported ordered `{id,name}` projection and its + digest, expose that value as `IdentityDigest() string`, remove + `IdentityPromptInput()`, and update NPC-occurrence extractor, normalizer, + invariant-validator, and registry-validator fingerprints to use + `IdentityDigest()`. The digest must still distinguish ID/name identity from + the names-only prompt projection. +8. Rewrite existing focused tests around observable behavior: rendered NPC + registry input is names-only; the private schema rejects `npc_id`; valid + names acquire the registry ID; comparison-equivalent names canonicalize; + unknown names fail the whole extraction; registry identity fingerprints + remain distinct and defensive; empty registries accept only empty model + results. Remove tests whose only purpose was requiring the model to return + exact ID/name pairs. + +### Acceptance Criteria + +- No NPC-occurrence LLM input or private response contains a durable NPC ID. +- Durable NPC occurrences still contain the exact registry ID/name pair. +- Mapping failures remain extractor failures eligible for the configured + pipeline retry behavior. +- Deterministic consumers still fingerprint the ordered registry identity, + while spells, combat turns, enemy events, and NPC occurrences share the + names-only model projection. + +### Validation + +```sh +go fmt ./internal/modules/dnd/npcs/registry ./internal/modules/dnd/extract/npcoccurrences ./internal/modules/dnd/normalize/npcoccurrences ./internal/modules/dnd/validate/npcoccurrences/... +go test ./internal/modules/dnd/npcs/registry ./internal/modules/dnd/extract/npcoccurrences ./internal/modules/dnd/normalize/npcoccurrences ./internal/modules/dnd/validate/npcoccurrences/... +``` + +This stage is suitable for one gpt-5.6-terra prompt. + +## Stage 2: Convert Item Occurrences To Name-Based Resolution + +### Goal + +Give item occurrences the same contextual-name/deterministic-ID boundary while +preserving item-specific nullable fields and kind rules. + +### Work + +1. Inspect `assets/dnd/item-occurrences/`, the item occurrence extractor, the + item registry, the item occurrence normalizer and registry validator, and + their focused tests. +2. Change `dnd_item_occurrences_llm.v1.json` to remove `item_id` from required + fields and properties. Preserve required `name`, `kind`, `quantity`, `from`, + `to`, and `source_refs`, all current enums/nullability, and strict unknown + field rejection. +3. Rewrite the item registry fragment and module instructions to require the + supplied canonical name and current-chunk evidence without mentioning an + ID. Preserve prompt order and cache controls. +4. Change the item registry's model projection from ordered `{id,name}` pairs + to ordered names-only objects, add a comparison-key index, and expose a + defensive `Lookup(name) (dnd.Item, bool)` analogous to the NPC registry. + Retain exact `LookupID` for durable normalizers and validators. Because item + IDs are derived from the item comparison identity, the names-only + `ProjectionDigest` remains sufficient for model input and existing + checkpoint consumers. +5. Remove `ItemID` from the private response. During response canonicalization, + resolve every contextual name, replace it with the registry record's + canonical name, and attach its durable ID when constructing the final + `dnd.ItemOccurrence`. Unknown selections fail the complete extraction; do + not alter evidence or nullable-field validation ownership. +6. Change `mappingPolicy` to + `dnd.item_occurrences.extract_mapping.v2`. +7. Update focused tests to cover names-only projection, defensive comparison + lookup, schema rejection of `item_id`, deterministic durable mapping, + unknown-name failure after an otherwise valid record, empty registry/result + behavior, and preservation of nullable/kind-specific fields. + +### Acceptance Criteria + +- Model-visible item registry and response content contain no item hash. +- Every accepted durable occurrence has the matched registry ID and canonical + name. +- Invalid selection remains all-or-nothing, and existing normalizer/validator + defense in depth remains unchanged. + +### Validation + +```sh +go fmt ./internal/modules/dnd/items/registry ./internal/modules/dnd/extract/itemoccurrences +go test ./internal/modules/dnd/items/registry ./internal/modules/dnd/extract/itemoccurrences ./internal/modules/dnd/normalize/itemoccurrences ./internal/modules/dnd/validate/itemoccurrences/... +``` + +This stage is suitable for one gpt-5.6-terra prompt. + +## Stage 3: Add Contextual Location Grounding + +### Goal + +Replace the location registry's ID/name prompt projection with an immutable, +source-aware grounding object that can represent same-name locations safely. +Introduce the new path alongside the old occurrence input so this stage remains +buildable; Stage 4 performs the atomic extractor cutover and removes the old +path. + +### Work + +1. Inspect the location registry, location identity and source-reference + helpers, the generic source document index, occurrence checkpoint consumers, + and their focused tests. +2. In `internal/modules/dnd/locations/registry`, define the private-model + types needed by both grounding and the location occurrence extractor: + - a returned selector with exactly `name` and `registry_refs`; + - a registry projection entry with exactly `name`, `registry_refs`, and + `context`; + - a source-free range with exactly `start_unit_id` and `end_unit_id`; and + - a context unit with exactly `unit_id` and `text`. + All fields are required in their private JSON shapes, and constructors and + accessors must make defensive copies. +3. Add an operation-scoped immutable grounding type constructed from a resolved + registry and the current `*source.SourceDocument`. Its API must provide: + - a cloned `contracts.LLMInputMaterial` for the `location_registry` slot; + - deterministic resolution of a returned selector to one cloned + `dnd.Location`; and + - the digest of the exact model projection. +4. Construct the projection in registry order. Group entries by the existing + location comparison key: + - every projection entry has exactly the required fields `name`, + `registry_refs`, and `context`; + - comparison-unique entries use empty `registry_refs` and `context` arrays; + - every same-name entry uses its complete canonical source ranges stripped + of `source_id` and includes ordered context units covered by those ranges; + - each context unit contains exactly required integer `unit_id` and string + `text` fields, and units are deduplicated in source order; and + - same-name ranges must have `SourceID == doc.ID` and pass + `source.DocumentIndex.ValidateRef`. +5. Fail grounding construction with a bounded, content-safe error if the + source is nil, a same-name range is invalid or belongs to another source, + a comparison key is empty, or two records produce the same selector. Do not + expose transcript text in the error. +6. Resolution uses the existing comparison key. A unique-name selector is + accepted only with empty `registry_refs`; a same-name selector is accepted + only on an exact canonical range match. Reject unknown names, a non-empty + range list for a unique name, an empty/partial/reordered range list for an + ambiguous name, or any selector not present in the grounding. +7. Separate deterministic identity fingerprinting from LLM material. Add + `IdentityDigest()` over the registry's ordered `{id,name}` identity + projection, and update the location normalizer and registry-validator + checkpoint consumers to use it. The new operation grounding owns the model + projection digest. Retain the old ID-bearing prompt accessor only as a + documented transitional dependency of the still-unchanged location + occurrence extractor; do not add new callers. +8. Add focused tests for unique names, same-name context and selectors, + canonical range order, deterministic projection/digest, defensive copies, + exact selector resolution, nil/foreign/invalid references, selector + collisions, empty registries, and identity fingerprint stability. Do not + assert large rendered prompt strings; decode the JSON projection and assert + its semantic shape. + +### Acceptance Criteria + +- The location package can build and resolve contextual selectors without + exposing `location_id`, `source_id`, digests, or replacement labels. +- Same-name locations remain distinct and receive meaningful bounded context. +- Deterministic checkpoint consumers retain an ID-sensitive fingerprint. +- Only the existing occurrence extractor remains wired to the legacy + ID-bearing prompt path until Stage 4; the repository compiles and tests pass. + +### Validation + +```sh +go fmt ./internal/modules/dnd/locations/registry ./internal/modules/dnd/normalize/locationoccurrences ./internal/modules/dnd/validate/locationoccurrences/... +go test ./internal/modules/dnd/locations/registry ./internal/modules/dnd/normalize/locationoccurrences ./internal/modules/dnd/validate/locationoccurrences/... +``` + +This stage is suitable for one gpt-5.6-terra prompt. + +## Stage 4: Convert Location Occurrences To Contextual Resolution + +### Goal + +Wire the Stage 3 grounding object into location occurrence extraction and +remove durable location IDs from the prompt and private response. + +### Work + +1. Inspect `assets/dnd/location-occurrences/`, the location occurrence model, + schema loader, extractor, canonicalization, prompt tests, and Stage 3 + grounding tests. +2. Change `dnd_location_occurrences_llm.v1.json` so each occurrence requires + exactly `name`, `registry_refs`, `kind`, and `source_refs`; remove + `location_id`. Keep all four occurrence kinds. Make `registry_refs` a + required array, including an empty array, of strict required positive + integer ranges. Keep occurrence `source_refs` separate and unchanged. +3. Rewrite `location-registry.md` and module instructions to explain the two + selector cases, require exact supplied contextual selectors, prohibit + invented locations, and state that registry ranges/context are identity + grounding rather than occurrence evidence. Preserve manifest order and + cache controls. +4. Change the private response type to `Name`, `RegistryRefs`, `Kind`, and + `SourceRefs`. Do not reuse `source.SourceRef` for the source-free registry + range type. +5. In `Extract`, construct operation grounding from the resolved registry and + `req.Source` before calling the LLM, put its projection in the + `location_registry` input, and resolve every returned selector after + completion. Attach the selected registry record's exact ID and canonical + name to the durable occurrence while retaining only the response's + current-source `source_refs` as evidence. +6. Fail the whole extraction on grounding-construction failure or the first + unknown, malformed, mismatched, or ambiguous selector. This replaces the + current behavior that can preserve unknown ID/name pairs for later + validators. Keep later normalizer and validator checks as defense in depth + for artifacts entering other boundaries. +7. Change `mappingPolicy` to + `dnd.location_occurrences.extract_mapping.v2`. +8. Remove the legacy ID-bearing registry `PromptInput` and its model-projection + digest once the extractor uses operation grounding. Update the occurrence + checkpoint to combine `IdentityDigest()` with the new grounding projection + digest, prompt/schema fingerprint, mapping policy, and its existing inputs; + do not retain dead compatibility aliases. +9. Update focused schema, prompt, extractor, canonicalization, checkpoint, and + generated-reference tests. Cover unique-name empty selectors, successful + same-name selection, failure for an unsupported ambiguous mention, + partial/reordered ranges, no registry-to-occurrence evidence leakage, + all-or-nothing failure, empty registry/result behavior, and unchanged + durable ordering/deduplication. + +### Acceptance Criteria + +- The location prompt and private response contain no durable location ID. +- Unique and same-name records resolve according to the final selector + contract. +- Accepted durable output is unchanged in shape and still contains an exact + location ID/name pair. +- Registry context cannot become durable occurrence evidence. + +### Validation + +```sh +go fmt ./internal/modules/dnd/extract/locationoccurrences ./internal/modules/dnd/locations/registry +go test ./internal/modules/dnd/locations/registry ./internal/modules/dnd/extract/locationoccurrences ./internal/modules/dnd/normalize/locationoccurrences ./internal/modules/dnd/validate/locationoccurrences/... +``` + +This stage is suitable for one gpt-5.6-terra prompt. + +## Stage 5: Replace Reconciliation Keys With Contextual Descriptors + +### Goal + +Change the shared NPC/item/location registry-normalization proposal contract so +opaque candidate keys remain internal and the model sees and returns only +names plus evidence coordinates. + +### Work + +1. Inspect: + - `internal/modules/dnd/shared/entityreconcile/`; + - `assets/dnd/shared/prompts/common-dnd-entity-reconciliation.md`; + - `assets/dnd/entity-reconciliation/schemas/`; + - all three registry normalization manifests and prompt tests; and + - the NPC, item, and location registry normalizers and reconciliation tests. +2. Introduce one exported, defensively copied contextual selector type in + `entityreconcile` with JSON `name` and `source_refs`, plus a strict + source-free range type. Use it for candidate input views and for + `DuplicateGroup.Members` and `.Canonical`. +3. Keep deterministic candidate keys only inside `Materials`. During + `BuildContext`, validate and canonicalize candidate references as today, + serialize candidate views without `key`, derive a stable internal lookup + from canonical selector JSON to the corresponding internal candidate key, + and detect descriptor collisions before eligibility is established. + Colliding candidates must not appear in the prompt input or become + eligible; their records remain in deterministic normalization output. +4. Update `Materials.Assess` to resolve every returned selector through that + internal lookup before running the existing group assessment. Preserve + existing issue categories where their meaning still applies. Treat an + unknown or collided descriptor as an unknown/ineligible selection, discard + only the affected group, and retain existing overlap handling. `SafeGroup` + may continue returning internal candidate keys so the three domain + normalizers retain their position mapping; those keys are not model-facing. +5. Rewrite `dnd_entity_reconcile_llm.v1.json` so members and canonical are + strict selector objects. Require non-empty `name` structurally where the + current schemas do so, require `source_refs`, and make each range strict + with required positive integer endpoints. Preserve `duplicate_groups` and + the existing semantic assessment of minimum group size, membership, + duplicates, eligibility, and overlap rather than moving every semantic + failure into JSON Schema. +6. Rewrite the shared reconciliation fragment to tell the model to return + supplied contextual descriptors and never invent names or ranges. Remove + every instruction about opaque keys. Preserve all three manifests' message + ordering and cache controls. +7. Change registry normalization policy identifiers to: + - `dnd.npc_registry.normalize.v4`; + - `dnd.item_registry.normalize.v2`; and + - `dnd.location_registry.normalize.v2`. +8. Update shared and domain tests to cover candidate JSON without keys, + contextual proposal decoding, valid selector-to-internal-key mapping, + equal names with different evidence, descriptor collision exclusion, + unknown/partial/reordered descriptors, overlapping groups, canonical + membership, invalid structured-output fallback, currency safety, and + preservation of every non-applied deterministic candidate. Update prompt + asset fixtures to the new selector schema; do not snapshot prompt prose. + +### Acceptance Criteria + +- No registry normalization prompt input or private response contains a + `candidate-*` key. +- Internal keys remain inaccessible to the model but may still support safe + deterministic position mapping. +- All existing normalizer safety, retry, fallback, warning, currency, and + same-name-location policies remain intact. +- Identical contextual descriptors cannot be arbitrarily reconciled. + +### Validation + +```sh +go fmt ./internal/modules/dnd/shared/entityreconcile ./internal/modules/dnd/normalize/npcregistry ./internal/modules/dnd/normalize/itemregistry ./internal/modules/dnd/normalize/locationregistry +go test ./internal/modules/dnd/shared/entityreconcile ./internal/modules/dnd/normalize/npcregistry ./internal/modules/dnd/normalize/itemregistry ./internal/modules/dnd/normalize/locationregistry +``` + +This is the largest stage, but it is one cohesive shared-contract migration +and is suitable for one gpt-5.6-terra prompt when implemented exactly within +the listed packages. Do not combine it with occurrence or documentation work. + +## Stage 6: Record The Decision And Update Canonical Documentation + +### Goal + +Document the implemented policy in its durable architectural, internal, and +integration homes without duplicating volatile details or presenting roadmap +work as current behavior prematurely. + +### Work + +1. Re-read `docs/policy/documentation.md`, ADR-0003, ADR-0009, ADR-0011, + `docs/internal/dnd.md`, `docs/internal/llm.md`, and the six affected registry + and occurrence integration documents. Verify the code before describing it. +2. Add + `docs/adr/0012-resolve-opaque-entity-identifiers-deterministically.md` in + the repository's Nygard ADR format with status `Accepted` and the actual + implementation date. Record the model-semantic/deterministic-identity + boundary, source-coordinate allowance, request-local-label exception, + alternatives, ambiguity behavior, and consequences. Link ADR-0003 and + ADR-0009 rather than repeating their complete decisions. +3. Add a concise normative invariant under the LLM boundary in + `docs/policy/architecture.md`: callers use contextual model selections and + attach opaque application identities deterministically when possible. Link + ADR-0012 for rationale. +4. Update `docs/internal/dnd.md` to replace exact model-facing `{id,name}` + claims with the implemented NPC/item names-only and location contextual + selector behavior. Document reconciliation descriptors, internal-only keys, + all-or-nothing occurrence mapping failures, normalization fallback, and the + separation between registry and occurrence evidence. Do not duplicate the + private JSON schemas. +5. Add only a short ownership clarification to `docs/internal/llm.md`: the + calling module resolves contextual selections; PromptKit and its adapter do + not own entity identity. +6. Update these durable integration contracts while preserving their public + ID-bearing wire examples and schema statements: + - `docs/integrations/dnd-npc-registry-artifacts.md`; + - `docs/integrations/dnd-npc-occurrence-artifacts.md`; + - `docs/integrations/dnd-item-registry-artifacts.md`; + - `docs/integrations/dnd-item-occurrence-artifacts.md`; + - `docs/integrations/dnd-location-registry-artifacts.md`; and + - `docs/integrations/dnd-location-occurrence-artifacts.md`. + Remove claims that LLM consumers receive `{id,name}` or that raw model + output supplies an ID. State that Notarius maps contextual output into the + unchanged exact durable pair. +7. Revise the generic LLM-assisted deduplication entry in + `docs/roadmap/future.md`: stable unique IDs remain internal deterministic + state, while a future model proposal uses contextual descriptors or a + specifically justified request-local short label. +8. Do not change README, CLI, configuration, operations, examples, or public + schema files; this feature has no user-selectable surface or public wire + change. + +### Acceptance Criteria + +- ADR-0012 owns rationale; architecture owns the normative boundary; internal + docs own mechanics; integration docs own unchanged durable contracts; and + the future roadmap no longer proposes durable IDs as the default model + selector. +- No current-behavior document claims that a model copies hash-based entity + IDs or opaque reconciliation keys. +- Documentation does not duplicate private schemas or implementation history. + +### Validation + +```sh +git diff --check +rg -n '\{id,name\}|ID/name grounding|Candidate keys are opaque|candidate-[0-9]' docs assets/dnd +``` + +Review every search result semantically; durable wire-contract ID/name +requirements and internal test fixtures are not automatically errors. + +This stage is suitable for one gpt-5.6-terra prompt. + +## Stage 7: Integration Audit And Final Verification + +### Goal + +Verify the assembled D&D family, remove obsolete identity-copy paths, and +finish with a clean, policy-compliant implementation. + +### Work + +1. Audit every maintained D&D prompt manifest, selected fragment, private + schema, and constructed prompt projection. Confirm that no model is asked to + reproduce `npc:sha256:...`, `item:sha256:...`, + `location:sha256:...`, `candidate-*`, a UUID, a digest, or another opaque + entity handle. Do not confuse runtime metadata or durable output contracts + with model-visible material. +2. Trace all former APIs and fields, including `IdentityPromptInput`, + ID-bearing item/location prompt projections, private `NPCID`/`ItemID`/ + `LocationID` response fields, and model-visible candidate keys. Remove dead + code, obsolete comments, stale test names, and unused assets. Retain + identity-only digests and exact durable lookup APIs used by deterministic + consumers. +3. Review prompt fingerprint registration and checkpoint fingerprints. Confirm + that each affected prompt/schema/policy/projection change invalidates the + relevant operation and that unrelated D&D lanes retain their existing + fingerprints. +4. Run representative production registration and multi-step pipeline tests + using existing fakes. Update only tests whose stable behavior changed. + Confirm generated NPC/item/location registry handoffs still prepare and + that final durable occurrences encode and validate under their existing + `v1` codecs. +5. Run formatting, focused suites, full tests, vet, build, and documentation + whitespace checks. Fix only failures caused by this feature. Report any + unrelated pre-existing failure without broadening scope. +6. Review the feature roadmap acceptance criteria one by one. Do not delete + `contextual-entity-grounding.md` or this implementation plan in this stage; + roadmap retirement is a separate maintainer action after review. + +### Acceptance Criteria + +- All feature-roadmap acceptance criteria are met. +- The repository contains no obsolete model-facing opaque-identity path. +- Public artifacts and generated handoffs remain compatible. +- Tests are focused on behavior rather than prose or implementation shape. +- The worktree contains only intentional feature and documentation changes. + +### Validation + +```sh +go fmt ./internal/modules/dnd/... +go test ./internal/modules/dnd/... +go test ./internal/modules/integration/... +go test ./... +go vet ./... +go build ./cmd/notarius +git diff --check +git status --short +``` + +This stage is suitable for one gpt-5.6-terra prompt. diff --git a/internal/cli/dnd_enemy_events_contract_test.go b/internal/cli/dnd_enemy_events_contract_test.go index 4567d96..f376131 100644 --- a/internal/cli/dnd_enemy_events_contract_test.go +++ b/internal/cli/dnd_enemy_events_contract_test.go @@ -224,12 +224,13 @@ func TestMaintainedCompleteExamplePublishesRegistryBackedEntityOccurrences(t *te } } for _, test := range []struct { - promptID string - slot string - name string + promptID string + slot string + name string + requiresIDs bool }{ {promptID: npcoccurrences.PromptID, slot: "npc_registry", name: "Kesh"}, - {promptID: itemoccurrences.PromptID, slot: "item_registry", name: "Moonblade"}, + {promptID: itemoccurrences.PromptID, slot: "item_registry", name: "Moonblade", requiresIDs: true}, } { requests := client.requestsFor(test.promptID) if len(requests) != 2 { @@ -237,8 +238,9 @@ func TestMaintainedCompleteExamplePublishesRegistryBackedEntityOccurrences(t *te } for _, request := range requests { registryInput := request.Inputs[test.slot] - if !strings.Contains(string(registryInput.Content), test.name) || !strings.Contains(string(registryInput.Content), `"id"`) || strings.Contains(string(registryInput.Content), "source_refs") { - t.Fatalf("%s registry input = %q, want source-free ID grounding", test.promptID, registryInput.Content) + hasID := strings.Contains(string(registryInput.Content), `"id"`) + if !strings.Contains(string(registryInput.Content), test.name) || hasID != test.requiresIDs || strings.Contains(string(registryInput.Content), "source_refs") { + t.Fatalf("%s registry input = %q, want source-free configured grounding", test.promptID, registryInput.Content) } } } diff --git a/internal/modules/dnd/extract/npcoccurrences/canonicalize.go b/internal/modules/dnd/extract/npcoccurrences/canonicalize.go index 832d41a..7fbb94f 100644 --- a/internal/modules/dnd/extract/npcoccurrences/canonicalize.go +++ b/internal/modules/dnd/extract/npcoccurrences/canonicalize.go @@ -1,10 +1,12 @@ package npcoccurrences import ( + "fmt" "sort" "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" ) @@ -50,12 +52,16 @@ func canonicalizeOccurrence(occurrence *occurrenceResponse, order shared.SourceR return order.EarliestValid(refs) } -func canonicalOccurrenceList(response extractionResponse, sourceID string) dnd.NPCOccurrenceList { +func canonicalOccurrenceList(response extractionResponse, sourceID string, registry *npcregistry.Registry) (dnd.NPCOccurrenceList, error) { occurrences := make([]dnd.NPCOccurrence, len(response.Occurrences)) for index, occurrence := range response.Occurrences { + canonical, ok := registry.Lookup(occurrence.Name) + if !ok { + return dnd.NPCOccurrenceList{}, fmt.Errorf("occurrences[%d].name is not in the NPC registry", index) + } occurrences[index] = dnd.NPCOccurrence{ - NPCID: occurrence.NPCID, - Name: occurrence.Name, + NPCID: canonical.ID, + Name: canonical.Name, Kind: dnd.NPCOccurrenceKind(occurrence.Kind), SourceRefs: canonicalSourceRefs(occurrence.SourceRefs, sourceID), } @@ -63,7 +69,7 @@ func canonicalOccurrenceList(response extractionResponse, sourceID string) dnd.N if response.Occurrences == nil { occurrences = nil } - return dnd.NPCOccurrenceList{Occurrences: occurrences} + return dnd.NPCOccurrenceList{Occurrences: occurrences}, nil } func canonicalSourceRefs(refs []occurrenceSourceRefResponse, sourceID string) []source.SourceRef { diff --git a/internal/modules/dnd/extract/npcoccurrences/extractor.go b/internal/modules/dnd/extract/npcoccurrences/extractor.go index 620254a..2d41532 100644 --- a/internal/modules/dnd/extract/npcoccurrences/extractor.go +++ b/internal/modules/dnd/extract/npcoccurrences/extractor.go @@ -14,7 +14,7 @@ import ( const ( Key = "dnd/npc-occurrences" - mappingPolicy = "dnd.npc_occurrences.extract_mapping.v2" + mappingPolicy = "dnd.npc_occurrences.extract_mapping.v3" ) const ( @@ -132,7 +132,7 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint { {Name: "prompt", Value: e.promptSHA}, {Name: "response_schema", Value: e.responseSchemaSHA}, {Name: "mapping_policy", Value: mappingPolicy}, - {Name: "npc_registry", Value: seeded.IdentityPromptInput().Digest}, + {Name: "npc_registry", Value: seeded.IdentityDigest()}, } } @@ -158,7 +158,7 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe var response extractionResponse inputs := shared.PromptInputs(sourceInput, req.References) - inputs[NPCRegistryReferenceSlot] = npcRegistry.IdentityPromptInput() + inputs[NPCRegistryReferenceSlot] = npcRegistry.PromptInput() if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ StageName: Key, PromptID: PromptID, @@ -170,26 +170,13 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("complete structured output: %w", err) } canonicalizeResponse(&response, order, req.Source.ID) - value := canonicalOccurrenceList(response, req.Source.ID) - if err := validateRegistryPairs(value, npcRegistry); err != nil { - return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("validate NPC registry pairs: %w", err) + value, err := canonicalOccurrenceList(response, req.Source.ID, npcRegistry) + if err != nil { + return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("resolve NPC names against registry: %w", err) } return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{Value: value}, nil } -func validateRegistryPairs(value dnd.NPCOccurrenceList, registry *npcregistry.Registry) error { - for index, occurrence := range value.Occurrences { - canonical, ok := registry.LookupID(occurrence.NPCID) - if !ok { - return fmt.Errorf("occurrences[%d].npc_id is not in the NPC registry", index) - } - if occurrence.Name != canonical.Name { - return fmt.Errorf("occurrences[%d].name does not match npc_id", index) - } - } - return nil -} - func ModuleSpec() pipeline.ModuleSpec { return pipeline.ModuleSpec{ Key: Key, diff --git a/internal/modules/dnd/extract/npcoccurrences/extractor_test.go b/internal/modules/dnd/extract/npcoccurrences/extractor_test.go index cdc106a..0d745e8 100644 --- a/internal/modules/dnd/extract/npcoccurrences/extractor_test.go +++ b/internal/modules/dnd/extract/npcoccurrences/extractor_test.go @@ -18,13 +18,13 @@ import ( func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) { client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{ - {NPCID: identity.DeriveID("Other"), Name: "Other", Kind: "other", SourceRefs: occurrenceRefs(30, 30)}, - {NPCID: identity.DeriveID("Opponent"), Name: "Opponent", Kind: "combat_opponent", SourceRefs: occurrenceRefs(20, 20)}, - {NPCID: identity.DeriveID("Ally"), Name: "Ally", Kind: "combat_ally", SourceRefs: occurrenceRefs(5, 5)}, - {NPCID: identity.DeriveID("Speaker"), Name: "Speaker", Kind: "dialogue", SourceRefs: append(occurrenceRefs(2, 2), occurrenceRefs(2, 2)...)}, - {NPCID: identity.DeriveID("Present"), Name: "Present", Kind: "noncombat_presence", SourceRefs: occurrenceRefs(7, 7)}, - {NPCID: identity.DeriveID("Mentioned"), Name: "Mentioned", Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)}, - {NPCID: identity.DeriveID("Invalid"), Name: "Invalid", Kind: "unsupported", SourceRefs: occurrenceRefs(0, 0)}, + {Name: "Other", Kind: "other", SourceRefs: occurrenceRefs(30, 30)}, + {Name: "Opponent", Kind: "combat_opponent", SourceRefs: occurrenceRefs(20, 20)}, + {Name: "Ally", Kind: "combat_ally", SourceRefs: occurrenceRefs(5, 5)}, + {Name: "Speaker", Kind: "dialogue", SourceRefs: append(occurrenceRefs(2, 2), occurrenceRefs(2, 2)...)}, + {Name: "Present", Kind: "noncombat_presence", SourceRefs: occurrenceRefs(7, 7)}, + {Name: "Mentioned", Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)}, + {Name: "Invalid", Kind: "unsupported", SourceRefs: occurrenceRefs(0, 0)}, }}} references := requiredRegistryReferences(t, "Mentioned", "Speaker", "Present", "Ally", "Opponent", "Other", "Invalid") req := extractionRequest() @@ -51,6 +51,9 @@ func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) { if refs := result.Value.Occurrences[1].SourceRefs; !reflect.DeepEqual(refs, []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}) { t.Fatalf("canonical source refs = %#v", refs) } + if id := result.Value.Occurrences[0].NPCID; id != identity.DeriveID("Mentioned") { + t.Fatalf("durable NPC ID = %q, want registry identity", id) + } if invalid := result.Value.Occurrences[6]; invalid.Name != "Invalid" || invalid.Kind != "unsupported" || !reflect.DeepEqual(invalid.SourceRefs, []source.SourceRef{{SourceID: "session-alpha"}}) { t.Fatalf("invalid candidate = %#v, want preserved values with current source identity", invalid) } @@ -61,14 +64,14 @@ func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) { func TestExtractUsesDocumentOrderForReferencesAndOccurrences(t *testing.T) { client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{ - {NPCID: identity.DeriveID("Later"), Name: "Later", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)}, - {NPCID: identity.DeriveID("First"), Name: "First", Kind: "mentioned", SourceRefs: []occurrenceSourceRefResponse{ + {Name: "Later", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)}, + {Name: "First", Kind: "mentioned", SourceRefs: []occurrenceSourceRefResponse{ {StartUnitID: 10, EndUnitID: 10}, {StartUnitID: 30, EndUnitID: 30}, {StartUnitID: 30, EndUnitID: 30}, {StartUnitID: 999, EndUnitID: 0}, }}, - {NPCID: identity.DeriveID("Second"), Name: "Second", Kind: "other", SourceRefs: occurrenceRefs(30, 30)}, + {Name: "Second", Kind: "other", SourceRefs: occurrenceRefs(30, 30)}, }}} references := requiredRegistryReferences(t, "Later", "First", "Second") req := extractionRequest() @@ -107,9 +110,9 @@ func TestNewRequiresLLMAndRejectsAmbiguousReferenceSets(t *testing.T) { } } -func TestExtractUsesRegistryIDsAndCurrentTranscriptEvidence(t *testing.T) { +func TestExtractUsesNamesOnlyRegistryAndCurrentTranscriptEvidence(t *testing.T) { client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{ - NPCID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10), + Name: "Mira Thorn", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10), }}}} references := requiredRegistryReferences(t, "Mira Thorn", "Hooded Guard") req := extractionRequest() @@ -119,10 +122,10 @@ func TestExtractUsesRegistryIDsAndCurrentTranscriptEvidence(t *testing.T) { } request := client.requests[0] registry := request.Inputs[NPCRegistryReferenceSlot] - if registry.Name != NPCRegistryReferenceSlot || registry.MediaType != "application/json" || string(registry.Content) != `{"npcs":[{"id":"`+identity.DeriveID("Mira Thorn")+`","name":"Mira Thorn"},{"id":"`+identity.DeriveID("Hooded Guard")+`","name":"Hooded Guard"}]}` { - t.Fatalf("registry prompt input = %#v, want exact ID and name projection", registry) + if registry.Name != NPCRegistryReferenceSlot || registry.MediaType != "application/json" || string(registry.Content) != `{"npcs":[{"name":"Mira Thorn"},{"name":"Hooded Guard"}]}` { + t.Fatalf("registry prompt input = %#v, want names-only projection", registry) } - for _, forbidden := range []string{"other-session", "start_unit_id"} { + for _, forbidden := range []string{"npc:sha256:", "other-session", "start_unit_id"} { if strings.Contains(string(registry.Content), forbidden) { t.Fatalf("registry prompt input leaked %q: %s", forbidden, registry.Content) } @@ -162,24 +165,37 @@ func TestExtractRequiresBoundRegistryBeforeLLMCall(t *testing.T) { } } -func TestExtractRejectsUnknownIDsAndMismatchedNames(t *testing.T) { +func TestExtractRejectsUnknownNamesWithoutPartialResult(t *testing.T) { references := requiredRegistryReferences(t, "Mira Thorn") - for _, test := range []struct { - name string - occurrence occurrenceResponse - want string - }{ - {"unknown ID", occurrenceResponse{NPCID: "npc:unknown", Name: "Mira Thorn", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)}, "npc_id is not in the NPC registry"}, - {"mismatched name", occurrenceResponse{NPCID: identity.DeriveID("Mira Thorn"), Name: "Hooded Guard", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)}, "name does not match npc_id"}, - } { - t.Run(test.name, func(t *testing.T) { - client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{test.occurrence}}} - req := extractionRequest() - req.References = references - if _, err := newExtractor(t, client, references).Extract(context.Background(), req); err == nil || !strings.Contains(err.Error(), test.want) { - t.Fatalf("Extract() error = %v, want %q", err, test.want) - } - }) + client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{ + {Name: "Mira Thorn", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)}, + {Name: "Unknown NPC", Kind: "mentioned", SourceRefs: occurrenceRefs(20, 20)}, + }}} + req := extractionRequest() + req.References = references + result, err := newExtractor(t, client, references).Extract(context.Background(), req) + if err == nil || !strings.Contains(err.Error(), "name is not in the NPC registry") { + t.Fatalf("Extract() error = %v, want unknown name failure", err) + } + if len(result.Value.Occurrences) != 0 { + t.Fatalf("Extract() returned partial result = %#v", result.Value) + } +} + +func TestExtractCanonicalizesComparisonEquivalentRegistryNames(t *testing.T) { + references := requiredRegistryReferences(t, "Mira Thorn") + client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{ + Name: " mIRA\u2003thorn ", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10), + }}}} + req := extractionRequest() + req.References = references + result, err := newExtractor(t, client, references).Extract(context.Background(), req) + if err != nil { + t.Fatalf("Extract() error = %v", err) + } + occurrence := result.Value.Occurrences[0] + if occurrence.Name != "Mira Thorn" || occurrence.NPCID != identity.DeriveID("Mira Thorn") { + t.Fatalf("canonical occurrence = %#v", occurrence) } } @@ -192,7 +208,7 @@ func TestExtractResolvesGeneratedRegistryAtOperationTime(t *testing.T) { if _, err := extractor.Extract(context.Background(), req); err != nil { t.Fatalf("Extract() error = %v", err) } - if input := client.requests[0].Inputs[NPCRegistryReferenceSlot]; string(input.Content) != `{"npcs":[{"id":"`+identity.DeriveID("Mira Thorn")+`","name":"Mira Thorn"}]}` || input.OriginURI != "" { + if input := client.requests[0].Inputs[NPCRegistryReferenceSlot]; string(input.Content) != `{"npcs":[{"name":"Mira Thorn"}]}` || input.OriginURI != "" { t.Fatalf("generated registry prompt input = %#v", input) } metadata := extractor.ManifestMetadata() @@ -221,6 +237,24 @@ func TestExtractAcceptsEmptyBoundRegistryAndEmptyResponse(t *testing.T) { } } +func TestExtractRejectsNonemptyResponseForEmptyBoundRegistry(t *testing.T) { + content, err := npccodec.New().Encode(dnd.NPCRegistry{NPCs: []dnd.NPC{}}) + if err != nil { + t.Fatal(err) + } + references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{NPCRegistryReferenceSlot: { + Slot: contracts.ReferenceSlot{Name: NPCRegistryReferenceSlot}, + Items: []contracts.ReferenceItem{{SlotName: NPCRegistryReferenceSlot, MediaType: npccodec.MediaType, Content: content}}, + }}} + client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{Name: "Mira Thorn", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)}}}} + req := extractionRequest() + req.References = references + result, err := newExtractor(t, client, references).Extract(context.Background(), req) + if err == nil || !strings.Contains(err.Error(), "name is not in the NPC registry") || len(result.Value.Occurrences) != 0 { + t.Fatalf("Extract() = %#v, %v; want no accepted occurrences", result, err) + } +} + func TestExtractRejectsInvalidRequestsAndProviderFailures(t *testing.T) { references := requiredRegistryReferences(t, "Mira Thorn") valid := extractionRequest() diff --git a/internal/modules/dnd/extract/npcoccurrences/model.go b/internal/modules/dnd/extract/npcoccurrences/model.go index ff8ad36..3a73ee2 100644 --- a/internal/modules/dnd/extract/npcoccurrences/model.go +++ b/internal/modules/dnd/extract/npcoccurrences/model.go @@ -5,7 +5,6 @@ type extractionResponse struct { } type occurrenceResponse struct { - NPCID string `json:"npc_id"` Name string `json:"name"` Kind string `json:"kind"` SourceRefs []occurrenceSourceRefResponse `json:"source_refs"` diff --git a/internal/modules/dnd/extract/npcoccurrences/model_test.go b/internal/modules/dnd/extract/npcoccurrences/model_test.go index cb1cc75..379b27e 100644 --- a/internal/modules/dnd/extract/npcoccurrences/model_test.go +++ b/internal/modules/dnd/extract/npcoccurrences/model_test.go @@ -6,13 +6,13 @@ import ( ) func TestExtractionResponsePreservesValidatorOwnedSemantics(t *testing.T) { - content := []byte(`{"occurrences":[{"npc_id":"","name":"","kind":"unsupported","source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`) + content := []byte(`{"occurrences":[{"name":"","kind":"unsupported","source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`) var response extractionResponse if err := json.Unmarshal(content, &response); err != nil { t.Fatalf("json.Unmarshal() error = %v", err) } occurrence := response.Occurrences[0] - if occurrence.NPCID != "" || occurrence.Name != "" || occurrence.Kind != "unsupported" || occurrence.SourceRefs[0] != (occurrenceSourceRefResponse{StartUnitID: 0, EndUnitID: -1}) { + if occurrence.Name != "" || occurrence.Kind != "unsupported" || occurrence.SourceRefs[0] != (occurrenceSourceRefResponse{StartUnitID: 0, EndUnitID: -1}) { t.Fatalf("decoded response = %#v", occurrence) } } diff --git a/internal/modules/dnd/extract/npcoccurrences/prompt_assets_test.go b/internal/modules/dnd/extract/npcoccurrences/prompt_assets_test.go index 71a2c72..5ad5e70 100644 --- a/internal/modules/dnd/extract/npcoccurrences/prompt_assets_test.go +++ b/internal/modules/dnd/extract/npcoccurrences/prompt_assets_test.go @@ -42,7 +42,7 @@ func TestRegisterPromptAssetsAndPrepareOccurrencePrompt(t *testing.T) { "players": promptkit.Inline("occurrence-player"), "party": promptkit.Inline("Mira: ranger"), "glossary": promptkit.Inline("Greencloak: title"), - "npc_registry": promptkit.Inline(`{"npcs":[{"id":"npc:sha256:test","name":"occurrence-npc"}]}`), + "npc_registry": promptkit.Inline(`{"npcs":[{"name":"occurrence-npc"}]}`), }, }) if err != nil { diff --git a/internal/modules/dnd/extract/npcoccurrences/schema_test.go b/internal/modules/dnd/extract/npcoccurrences/schema_test.go index ab6810c..6b9e400 100644 --- a/internal/modules/dnd/extract/npcoccurrences/schema_test.go +++ b/internal/modules/dnd/extract/npcoccurrences/schema_test.go @@ -42,9 +42,9 @@ func TestResponseSchemaOwnsOnlyPrivateStructuralContract(t *testing.T) { } for _, mutate := range []func(map[string]any){ - func(record map[string]any) { delete(record, "npc_id") }, func(record map[string]any) { delete(record, "name") }, func(record map[string]any) { record["kind"] = 1 }, + func(record map[string]any) { record["npc_id"] = "npc:sha256:opaque" }, func(record map[string]any) { record["unexpected"] = true }, func(record map[string]any) { record["source_refs"].([]any)[0].(map[string]any)["source_id"] = "assigned later" @@ -81,7 +81,7 @@ func TestResponseSchemaIsDefensiveAndContentSafe(t *testing.T) { func validOccurrenceResponse() map[string]any { return map[string]any{"occurrences": []any{map[string]any{ - "npc_id": "npc:sha256:test", "name": "Mira Thorn", "kind": "dialogue", + "name": "Mira Thorn", "kind": "dialogue", "source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}}, }}} } diff --git a/internal/modules/dnd/normalize/npcoccurrences/normalizer.go b/internal/modules/dnd/normalize/npcoccurrences/normalizer.go index 76f3309..45aceee 100644 --- a/internal/modules/dnd/normalize/npcoccurrences/normalizer.go +++ b/internal/modules/dnd/normalize/npcoccurrences/normalizer.go @@ -92,7 +92,7 @@ func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint { } return []pipeline.CheckpointFingerprint{ {Name: "normalization_policy", Value: normalizationPolicy}, - {Name: "npc_registry", Value: n.npcResolver.Seeded().IdentityPromptInput().Digest}, + {Name: "npc_registry", Value: n.npcResolver.Seeded().IdentityDigest()}, } } diff --git a/internal/modules/dnd/npcs/registry/registry.go b/internal/modules/dnd/npcs/registry/registry.go index e49676f..8f32972 100644 --- a/internal/modules/dnd/npcs/registry/registry.go +++ b/internal/modules/dnd/npcs/registry/registry.go @@ -31,8 +31,8 @@ type Registry struct { canonical []byte digest string projectionDigest string + identityDigest string promptInput contracts.LLMInputMaterial - identityInput contracts.LLMInputMaterial lookupByKey map[string]int lookupByID map[string]int } @@ -110,8 +110,8 @@ func emptyRegistry() *Registry { list: dnd.NPCRegistry{NPCs: []dnd.NPC{}}, canonical: append([]byte(nil), content...), projectionDigest: projectionDigest, + identityDigest: projectionDigest, promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, projectionDigest, ""), - identityInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, projectionDigest, ""), lookupByKey: map[string]int{}, lookupByID: map[string]int{}, } @@ -155,9 +155,9 @@ func loadRegistry(referenceContent []byte) (*Registry, error) { canonical: append([]byte(nil), content...), digest: digest, projectionDigest: projectionDigest, + identityDigest: identityProjectionDigest, promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, projection, projectionDigest, ""), lookupByKey: lookupByKey, - identityInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, identityProjection, identityProjectionDigest, ""), lookupByID: lookupByID, }, nil } @@ -207,6 +207,15 @@ func (r *Registry) ProjectionDigest() string { return r.projectionDigest } +// IdentityDigest returns the SHA-256 digest of the ordered ID/name identity +// projection. It binds durable identities without exposing them to the model. +func (r *Registry) IdentityDigest() string { + if r == nil { + return "" + } + return r.identityDigest +} + // Count returns the number of validated NPC records. func (r *Registry) Count() int { if r == nil { @@ -224,15 +233,6 @@ func (r *Registry) PromptInput() contracts.LLMInputMaterial { return r.promptInput.Clone() } -// IdentityPromptInput returns the ordered ID/name projection for consumers -// that must bind output records to exact registry identities. -func (r *Registry) IdentityPromptInput() contracts.LLMInputMaterial { - if r == nil { - return contracts.LLMInputMaterial{} - } - return r.identityInput.Clone() -} - // Lookup returns the canonical NPC for an exact canonical-name match under the // NPC identity comparison policy. func (r *Registry) Lookup(value string) (dnd.NPC, bool) { diff --git a/internal/modules/dnd/npcs/registry/registry_test.go b/internal/modules/dnd/npcs/registry/registry_test.go index 23ec396..e33848e 100644 --- a/internal/modules/dnd/npcs/registry/registry_test.go +++ b/internal/modules/dnd/npcs/registry/registry_test.go @@ -19,7 +19,7 @@ func TestResolveUnboundRegistryHasExactEmptyProjection(t *testing.T) { t.Fatalf("Resolve() error = %v", err) } input := registry.PromptInput() - if registry.Bound() || registry.Digest() != "" || registry.Count() != 0 || string(input.Content) != emptyPrompt || string(registry.IdentityPromptInput().Content) != emptyPrompt { + if registry.Bound() || registry.Digest() != "" || registry.Count() != 0 || string(input.Content) != emptyPrompt || registry.IdentityDigest() != registry.ProjectionDigest() { t.Fatalf("registry = %#v input = %#v, want unbound empty registry", registry, input) } if registry.ProjectionDigest() == "" || input.Digest != registry.ProjectionDigest() || input.OriginURI != "" { @@ -46,12 +46,15 @@ func TestResolveKeepsDurableProvenanceAndProjectsOnlyOrderedNames(t *testing.T) } } -func TestIdentityPromptInputProjectsOrderedIDsAndNames(t *testing.T) { +func TestIdentityDigestTracksOrderedIDsAndNames(t *testing.T) { registry := resolveList(t, registryFixture()) - input := registry.IdentityPromptInput() + projection, err := identityProjection(registryFixture()) + if err != nil { + t.Fatal(err) + } want := `{"npcs":[{"id":"` + identity.DeriveID("Mira Thorn") + `","name":"Mira Thorn"},{"id":"` + identity.DeriveID("Captain Vale") + `","name":"Captain Vale"}]}` - if string(input.Content) != want || input.Digest == registry.ProjectionDigest() { - t.Fatalf("identity projection = %#v, want %s", input, want) + if string(projection) != want || registry.IdentityDigest() != semanticDigest(projection) || registry.IdentityDigest() == registry.ProjectionDigest() { + t.Fatalf("identity digest = %q, want digest for %s", registry.IdentityDigest(), want) } if npc, ok := registry.LookupID(identity.DeriveID("Mira Thorn")); !ok || npc.Name != "Mira Thorn" { t.Fatalf("LookupID() = %#v, %t", npc, ok) @@ -213,7 +216,7 @@ func TestProjectionIsStableForEquivalentNormalizedRegistries(t *testing.T) { secondList := registryFixture() secondList.NPCs[0].SourceRefs = append(secondList.NPCs[0].SourceRefs, source.SourceRef{SourceID: "session-beta", StartUnitID: 8, EndUnitID: 8}) second := resolveList(t, secondList) - if !reflect.DeepEqual(first.PromptInput().Content, second.PromptInput().Content) || first.ProjectionDigest() != second.ProjectionDigest() || first.Digest() == second.Digest() { + if !reflect.DeepEqual(first.PromptInput().Content, second.PromptInput().Content) || first.ProjectionDigest() != second.ProjectionDigest() || first.IdentityDigest() != second.IdentityDigest() || first.Digest() == second.Digest() { t.Fatalf("projection/full identity mismatch: %#v %#v", first, second) } } diff --git a/internal/modules/dnd/validate/npcoccurrences/invariants/validator.go b/internal/modules/dnd/validate/npcoccurrences/invariants/validator.go index 8eab5ce..565282f 100644 --- a/internal/modules/dnd/validate/npcoccurrences/invariants/validator.go +++ b/internal/modules/dnd/validate/npcoccurrences/invariants/validator.go @@ -72,7 +72,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint { } return []pipeline.CheckpointFingerprint{ {Name: "policy", Value: policy}, - {Name: "npc_registry", Value: v.npcResolver.Seeded().IdentityPromptInput().Digest}, + {Name: "npc_registry", Value: v.npcResolver.Seeded().IdentityDigest()}, } } diff --git a/internal/modules/dnd/validate/npcoccurrences/registry/validator.go b/internal/modules/dnd/validate/npcoccurrences/registry/validator.go index 50cc507..096b700 100644 --- a/internal/modules/dnd/validate/npcoccurrences/registry/validator.go +++ b/internal/modules/dnd/validate/npcoccurrences/registry/validator.go @@ -68,7 +68,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint { } return []pipeline.CheckpointFingerprint{ {Name: "policy", Value: policy}, - {Name: "npc_registry", Value: v.npcResolver.Seeded().IdentityPromptInput().Digest}, + {Name: "npc_registry", Value: v.npcResolver.Seeded().IdentityDigest()}, } } diff --git a/internal/modules/integration/dnd_npc_occurrences_runner_test.go b/internal/modules/integration/dnd_npc_occurrences_runner_test.go index c5cf206..594626d 100644 --- a/internal/modules/integration/dnd_npc_occurrences_runner_test.go +++ b/internal/modules/integration/dnd_npc_occurrences_runner_test.go @@ -36,7 +36,7 @@ func TestNPCOccurrencePipelineUsesAcceptedRegistryAndCurrentEvidence(t *testing. } request := client.requestFor(t, occurrenceextract.PromptID) - wantRegistry := `{"npcs":[{"id":"` + identity.DeriveID("Mira Thorn") + `","name":"Mira Thorn"},{"id":"` + identity.DeriveID("Hooded Guard") + `","name":"Hooded Guard"}]}` + wantRegistry := `{"npcs":[{"name":"Mira Thorn"},{"name":"Hooded Guard"}]}` if got := string(request.Inputs["npc_registry"].Content); got != wantRegistry { t.Fatalf("occurrence registry input = %s, want names-only projection %s", got, wantRegistry) } @@ -59,6 +59,9 @@ func TestNPCOccurrencePipelineUsesAcceptedRegistryAndCurrentEvidence(t *testing. if first.Name != "Mira Thorn" || string(first.Kind) != "dialogue" || second.Name != "Hooded Guard" || string(second.Kind) != "noncombat_presence" { t.Fatalf("occurrences = %#v, want canonical names, kinds, and source chronology", occurrences) } + if first.NPCID != identity.DeriveID("Mira Thorn") || second.NPCID != identity.DeriveID("Hooded Guard") { + t.Fatalf("occurrences = %#v, want durable registry IDs", occurrences) + } assertOccurrenceEvidence(t, first.SourceRefs) assertOccurrenceEvidence(t, second.SourceRefs) if first.SourceRefs[0].StartUnitID >= second.SourceRefs[0].StartUnitID { @@ -113,8 +116,8 @@ func TestSemanticNPCNormalizationCrossesOrderedRegistryHandoff(t *testing.T) { t.Fatalf("occurrence output step = %q, want ordered downstream step", occurrenceOutput.StepID) } registryRequest := client.requestFor(t, occurrenceextract.PromptID) - if got := string(registryRequest.Inputs["npc_registry"].Content); got != `{"npcs":[{"id":"`+identity.DeriveID("Mira Thorn")+`","name":"Mira Thorn"}]}` { - t.Fatalf("downstream registry = %s, want one canonical NPC identity", got) + if got := string(registryRequest.Inputs["npc_registry"].Content); got != `{"npcs":[{"name":"Mira Thorn"}]}` { + t.Fatalf("downstream registry = %s, want one canonical NPC name", got) } manifestContent, err := json.Marshal(output.Manifest) if err != nil {