Ground NPC occurrences by canonical names
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
Extract Dungeons & Dragons NPC occurrences from the supplied
|
Extract Dungeons & Dragons NPC occurrences from the supplied
|
||||||
transcript. Include an occurrence only when the transcript establishes one
|
transcript. Include an occurrence only when the transcript establishes one
|
||||||
supplied NPC, one occurrence kind, and a coherent passage supporting both.
|
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;
|
Use the supplied canonical NPC `name`; never invent or substitute a similar
|
||||||
never invent an ID or substitute a similar name.
|
name. Cite current-transcript evidence for every occurrence.
|
||||||
|
|
||||||
Do not summarize, infer relationships, sentiment, factions, motives, aliases,
|
Do not summarize, infer relationships, sentiment, factions, motives, aliases,
|
||||||
or persistent state. Do not identify player characters, anonymous groups, or
|
or persistent state. Do not identify player characters, anonymous groups, or
|
||||||
|
|||||||
@@ -10,11 +10,8 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["npc_id", "name", "kind", "source_refs"],
|
"required": ["name", "kind", "source_refs"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"npc_id": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|||||||
350
docs/roadmap/contextual-entity-grounding.md
Normal file
350
docs/roadmap/contextual-entity-grounding.md
Normal file
@@ -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.
|
||||||
609
docs/roadmap/implementation.md
Normal file
609
docs/roadmap/implementation.md
Normal file
@@ -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.
|
||||||
@@ -227,9 +227,10 @@ func TestMaintainedCompleteExamplePublishesRegistryBackedEntityOccurrences(t *te
|
|||||||
promptID string
|
promptID string
|
||||||
slot string
|
slot string
|
||||||
name string
|
name string
|
||||||
|
requiresIDs bool
|
||||||
}{
|
}{
|
||||||
{promptID: npcoccurrences.PromptID, slot: "npc_registry", name: "Kesh"},
|
{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)
|
requests := client.requestsFor(test.promptID)
|
||||||
if len(requests) != 2 {
|
if len(requests) != 2 {
|
||||||
@@ -237,8 +238,9 @@ func TestMaintainedCompleteExamplePublishesRegistryBackedEntityOccurrences(t *te
|
|||||||
}
|
}
|
||||||
for _, request := range requests {
|
for _, request := range requests {
|
||||||
registryInput := request.Inputs[test.slot]
|
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") {
|
hasID := strings.Contains(string(registryInput.Content), `"id"`)
|
||||||
t.Fatalf("%s registry input = %q, want source-free ID grounding", test.promptID, registryInput.Content)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package npcoccurrences
|
package npcoccurrences
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"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"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,12 +52,16 @@ func canonicalizeOccurrence(occurrence *occurrenceResponse, order shared.SourceR
|
|||||||
return order.EarliestValid(refs)
|
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))
|
occurrences := make([]dnd.NPCOccurrence, len(response.Occurrences))
|
||||||
for index, occurrence := range 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{
|
occurrences[index] = dnd.NPCOccurrence{
|
||||||
NPCID: occurrence.NPCID,
|
NPCID: canonical.ID,
|
||||||
Name: occurrence.Name,
|
Name: canonical.Name,
|
||||||
Kind: dnd.NPCOccurrenceKind(occurrence.Kind),
|
Kind: dnd.NPCOccurrenceKind(occurrence.Kind),
|
||||||
SourceRefs: canonicalSourceRefs(occurrence.SourceRefs, sourceID),
|
SourceRefs: canonicalSourceRefs(occurrence.SourceRefs, sourceID),
|
||||||
}
|
}
|
||||||
@@ -63,7 +69,7 @@ func canonicalOccurrenceList(response extractionResponse, sourceID string) dnd.N
|
|||||||
if response.Occurrences == nil {
|
if response.Occurrences == nil {
|
||||||
occurrences = nil
|
occurrences = nil
|
||||||
}
|
}
|
||||||
return dnd.NPCOccurrenceList{Occurrences: occurrences}
|
return dnd.NPCOccurrenceList{Occurrences: occurrences}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func canonicalSourceRefs(refs []occurrenceSourceRefResponse, sourceID string) []source.SourceRef {
|
func canonicalSourceRefs(refs []occurrenceSourceRefResponse, sourceID string) []source.SourceRef {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
Key = "dnd/npc-occurrences"
|
Key = "dnd/npc-occurrences"
|
||||||
mappingPolicy = "dnd.npc_occurrences.extract_mapping.v2"
|
mappingPolicy = "dnd.npc_occurrences.extract_mapping.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -132,7 +132,7 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
{Name: "prompt", Value: e.promptSHA},
|
{Name: "prompt", Value: e.promptSHA},
|
||||||
{Name: "response_schema", Value: e.responseSchemaSHA},
|
{Name: "response_schema", Value: e.responseSchemaSHA},
|
||||||
{Name: "mapping_policy", Value: mappingPolicy},
|
{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
|
var response extractionResponse
|
||||||
inputs := shared.PromptInputs(sourceInput, req.References)
|
inputs := shared.PromptInputs(sourceInput, req.References)
|
||||||
inputs[NPCRegistryReferenceSlot] = npcRegistry.IdentityPromptInput()
|
inputs[NPCRegistryReferenceSlot] = npcRegistry.PromptInput()
|
||||||
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
||||||
StageName: Key,
|
StageName: Key,
|
||||||
PromptID: PromptID,
|
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)
|
return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("complete structured output: %w", err)
|
||||||
}
|
}
|
||||||
canonicalizeResponse(&response, order, req.Source.ID)
|
canonicalizeResponse(&response, order, req.Source.ID)
|
||||||
value := canonicalOccurrenceList(response, req.Source.ID)
|
value, err := canonicalOccurrenceList(response, req.Source.ID, npcRegistry)
|
||||||
if err := validateRegistryPairs(value, npcRegistry); err != nil {
|
if err != nil {
|
||||||
return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("validate NPC registry pairs: %w", err)
|
return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("resolve NPC names against registry: %w", err)
|
||||||
}
|
}
|
||||||
return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{Value: value}, nil
|
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 {
|
func ModuleSpec() pipeline.ModuleSpec {
|
||||||
return pipeline.ModuleSpec{
|
return pipeline.ModuleSpec{
|
||||||
Key: Key,
|
Key: Key,
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ import (
|
|||||||
|
|
||||||
func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
|
func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
|
||||||
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
|
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
|
||||||
{NPCID: identity.DeriveID("Other"), Name: "Other", Kind: "other", SourceRefs: occurrenceRefs(30, 30)},
|
{Name: "Other", Kind: "other", SourceRefs: occurrenceRefs(30, 30)},
|
||||||
{NPCID: identity.DeriveID("Opponent"), Name: "Opponent", Kind: "combat_opponent", SourceRefs: occurrenceRefs(20, 20)},
|
{Name: "Opponent", Kind: "combat_opponent", SourceRefs: occurrenceRefs(20, 20)},
|
||||||
{NPCID: identity.DeriveID("Ally"), Name: "Ally", Kind: "combat_ally", SourceRefs: occurrenceRefs(5, 5)},
|
{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)...)},
|
{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)},
|
{Name: "Present", Kind: "noncombat_presence", SourceRefs: occurrenceRefs(7, 7)},
|
||||||
{NPCID: identity.DeriveID("Mentioned"), Name: "Mentioned", Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)},
|
{Name: "Mentioned", Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)},
|
||||||
{NPCID: identity.DeriveID("Invalid"), Name: "Invalid", Kind: "unsupported", SourceRefs: occurrenceRefs(0, 0)},
|
{Name: "Invalid", Kind: "unsupported", SourceRefs: occurrenceRefs(0, 0)},
|
||||||
}}}
|
}}}
|
||||||
references := requiredRegistryReferences(t, "Mentioned", "Speaker", "Present", "Ally", "Opponent", "Other", "Invalid")
|
references := requiredRegistryReferences(t, "Mentioned", "Speaker", "Present", "Ally", "Opponent", "Other", "Invalid")
|
||||||
req := extractionRequest()
|
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}}) {
|
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)
|
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"}}) {
|
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)
|
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) {
|
func TestExtractUsesDocumentOrderForReferencesAndOccurrences(t *testing.T) {
|
||||||
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
|
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
|
||||||
{NPCID: identity.DeriveID("Later"), Name: "Later", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)},
|
{Name: "Later", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)},
|
||||||
{NPCID: identity.DeriveID("First"), Name: "First", Kind: "mentioned", SourceRefs: []occurrenceSourceRefResponse{
|
{Name: "First", Kind: "mentioned", SourceRefs: []occurrenceSourceRefResponse{
|
||||||
{StartUnitID: 10, EndUnitID: 10},
|
{StartUnitID: 10, EndUnitID: 10},
|
||||||
{StartUnitID: 30, EndUnitID: 30},
|
{StartUnitID: 30, EndUnitID: 30},
|
||||||
{StartUnitID: 30, EndUnitID: 30},
|
{StartUnitID: 30, EndUnitID: 30},
|
||||||
{StartUnitID: 999, EndUnitID: 0},
|
{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")
|
references := requiredRegistryReferences(t, "Later", "First", "Second")
|
||||||
req := extractionRequest()
|
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{{
|
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")
|
references := requiredRegistryReferences(t, "Mira Thorn", "Hooded Guard")
|
||||||
req := extractionRequest()
|
req := extractionRequest()
|
||||||
@@ -119,10 +122,10 @@ func TestExtractUsesRegistryIDsAndCurrentTranscriptEvidence(t *testing.T) {
|
|||||||
}
|
}
|
||||||
request := client.requests[0]
|
request := client.requests[0]
|
||||||
registry := request.Inputs[NPCRegistryReferenceSlot]
|
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"}]}` {
|
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 exact ID and name projection", registry)
|
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) {
|
if strings.Contains(string(registry.Content), forbidden) {
|
||||||
t.Fatalf("registry prompt input leaked %q: %s", forbidden, registry.Content)
|
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")
|
references := requiredRegistryReferences(t, "Mira Thorn")
|
||||||
for _, test := range []struct {
|
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
|
||||||
name string
|
{Name: "Mira Thorn", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)},
|
||||||
occurrence occurrenceResponse
|
{Name: "Unknown NPC", Kind: "mentioned", SourceRefs: occurrenceRefs(20, 20)},
|
||||||
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 := extractionRequest()
|
||||||
req.References = references
|
req.References = references
|
||||||
if _, err := newExtractor(t, client, references).Extract(context.Background(), req); err == nil || !strings.Contains(err.Error(), test.want) {
|
result, err := newExtractor(t, client, references).Extract(context.Background(), req)
|
||||||
t.Fatalf("Extract() error = %v, want %q", err, test.want)
|
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 {
|
if _, err := extractor.Extract(context.Background(), req); err != nil {
|
||||||
t.Fatalf("Extract() error = %v", err)
|
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)
|
t.Fatalf("generated registry prompt input = %#v", input)
|
||||||
}
|
}
|
||||||
metadata := extractor.ManifestMetadata()
|
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) {
|
func TestExtractRejectsInvalidRequestsAndProviderFailures(t *testing.T) {
|
||||||
references := requiredRegistryReferences(t, "Mira Thorn")
|
references := requiredRegistryReferences(t, "Mira Thorn")
|
||||||
valid := extractionRequest()
|
valid := extractionRequest()
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ type extractionResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type occurrenceResponse struct {
|
type occurrenceResponse struct {
|
||||||
NPCID string `json:"npc_id"`
|
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
SourceRefs []occurrenceSourceRefResponse `json:"source_refs"`
|
SourceRefs []occurrenceSourceRefResponse `json:"source_refs"`
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestExtractionResponsePreservesValidatorOwnedSemantics(t *testing.T) {
|
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
|
var response extractionResponse
|
||||||
if err := json.Unmarshal(content, &response); err != nil {
|
if err := json.Unmarshal(content, &response); err != nil {
|
||||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||||
}
|
}
|
||||||
occurrence := response.Occurrences[0]
|
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)
|
t.Fatalf("decoded response = %#v", occurrence)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func TestRegisterPromptAssetsAndPrepareOccurrencePrompt(t *testing.T) {
|
|||||||
"players": promptkit.Inline("occurrence-player"),
|
"players": promptkit.Inline("occurrence-player"),
|
||||||
"party": promptkit.Inline("Mira: ranger"),
|
"party": promptkit.Inline("Mira: ranger"),
|
||||||
"glossary": promptkit.Inline("Greencloak: title"),
|
"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 {
|
if err != nil {
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ func TestResponseSchemaOwnsOnlyPrivateStructuralContract(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, mutate := range []func(map[string]any){
|
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) { delete(record, "name") },
|
||||||
func(record map[string]any) { record["kind"] = 1 },
|
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["unexpected"] = true },
|
||||||
func(record map[string]any) {
|
func(record map[string]any) {
|
||||||
record["source_refs"].([]any)[0].(map[string]any)["source_id"] = "assigned later"
|
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 {
|
func validOccurrenceResponse() map[string]any {
|
||||||
return map[string]any{"occurrences": []any{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}},
|
"source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}},
|
||||||
}}}
|
}}}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
}
|
}
|
||||||
return []pipeline.CheckpointFingerprint{
|
return []pipeline.CheckpointFingerprint{
|
||||||
{Name: "normalization_policy", Value: normalizationPolicy},
|
{Name: "normalization_policy", Value: normalizationPolicy},
|
||||||
{Name: "npc_registry", Value: n.npcResolver.Seeded().IdentityPromptInput().Digest},
|
{Name: "npc_registry", Value: n.npcResolver.Seeded().IdentityDigest()},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ type Registry struct {
|
|||||||
canonical []byte
|
canonical []byte
|
||||||
digest string
|
digest string
|
||||||
projectionDigest string
|
projectionDigest string
|
||||||
|
identityDigest string
|
||||||
promptInput contracts.LLMInputMaterial
|
promptInput contracts.LLMInputMaterial
|
||||||
identityInput contracts.LLMInputMaterial
|
|
||||||
lookupByKey map[string]int
|
lookupByKey map[string]int
|
||||||
lookupByID map[string]int
|
lookupByID map[string]int
|
||||||
}
|
}
|
||||||
@@ -110,8 +110,8 @@ func emptyRegistry() *Registry {
|
|||||||
list: dnd.NPCRegistry{NPCs: []dnd.NPC{}},
|
list: dnd.NPCRegistry{NPCs: []dnd.NPC{}},
|
||||||
canonical: append([]byte(nil), content...),
|
canonical: append([]byte(nil), content...),
|
||||||
projectionDigest: projectionDigest,
|
projectionDigest: projectionDigest,
|
||||||
|
identityDigest: projectionDigest,
|
||||||
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, projectionDigest, ""),
|
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, projectionDigest, ""),
|
||||||
identityInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, projectionDigest, ""),
|
|
||||||
lookupByKey: map[string]int{},
|
lookupByKey: map[string]int{},
|
||||||
lookupByID: map[string]int{},
|
lookupByID: map[string]int{},
|
||||||
}
|
}
|
||||||
@@ -155,9 +155,9 @@ func loadRegistry(referenceContent []byte) (*Registry, error) {
|
|||||||
canonical: append([]byte(nil), content...),
|
canonical: append([]byte(nil), content...),
|
||||||
digest: digest,
|
digest: digest,
|
||||||
projectionDigest: projectionDigest,
|
projectionDigest: projectionDigest,
|
||||||
|
identityDigest: identityProjectionDigest,
|
||||||
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, projection, projectionDigest, ""),
|
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, projection, projectionDigest, ""),
|
||||||
lookupByKey: lookupByKey,
|
lookupByKey: lookupByKey,
|
||||||
identityInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, identityProjection, identityProjectionDigest, ""),
|
|
||||||
lookupByID: lookupByID,
|
lookupByID: lookupByID,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -207,6 +207,15 @@ func (r *Registry) ProjectionDigest() string {
|
|||||||
return r.projectionDigest
|
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.
|
// Count returns the number of validated NPC records.
|
||||||
func (r *Registry) Count() int {
|
func (r *Registry) Count() int {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
@@ -224,15 +233,6 @@ func (r *Registry) PromptInput() contracts.LLMInputMaterial {
|
|||||||
return r.promptInput.Clone()
|
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
|
// Lookup returns the canonical NPC for an exact canonical-name match under the
|
||||||
// NPC identity comparison policy.
|
// NPC identity comparison policy.
|
||||||
func (r *Registry) Lookup(value string) (dnd.NPC, bool) {
|
func (r *Registry) Lookup(value string) (dnd.NPC, bool) {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func TestResolveUnboundRegistryHasExactEmptyProjection(t *testing.T) {
|
|||||||
t.Fatalf("Resolve() error = %v", err)
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
}
|
}
|
||||||
input := registry.PromptInput()
|
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)
|
t.Fatalf("registry = %#v input = %#v, want unbound empty registry", registry, input)
|
||||||
}
|
}
|
||||||
if registry.ProjectionDigest() == "" || input.Digest != registry.ProjectionDigest() || input.OriginURI != "" {
|
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())
|
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"}]}`
|
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() {
|
if string(projection) != want || registry.IdentityDigest() != semanticDigest(projection) || registry.IdentityDigest() == registry.ProjectionDigest() {
|
||||||
t.Fatalf("identity projection = %#v, want %s", input, want)
|
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" {
|
if npc, ok := registry.LookupID(identity.DeriveID("Mira Thorn")); !ok || npc.Name != "Mira Thorn" {
|
||||||
t.Fatalf("LookupID() = %#v, %t", npc, ok)
|
t.Fatalf("LookupID() = %#v, %t", npc, ok)
|
||||||
@@ -213,7 +216,7 @@ func TestProjectionIsStableForEquivalentNormalizedRegistries(t *testing.T) {
|
|||||||
secondList := registryFixture()
|
secondList := registryFixture()
|
||||||
secondList.NPCs[0].SourceRefs = append(secondList.NPCs[0].SourceRefs, source.SourceRef{SourceID: "session-beta", StartUnitID: 8, EndUnitID: 8})
|
secondList.NPCs[0].SourceRefs = append(secondList.NPCs[0].SourceRefs, source.SourceRef{SourceID: "session-beta", StartUnitID: 8, EndUnitID: 8})
|
||||||
second := resolveList(t, secondList)
|
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)
|
t.Fatalf("projection/full identity mismatch: %#v %#v", first, second)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
}
|
}
|
||||||
return []pipeline.CheckpointFingerprint{
|
return []pipeline.CheckpointFingerprint{
|
||||||
{Name: "policy", Value: policy},
|
{Name: "policy", Value: policy},
|
||||||
{Name: "npc_registry", Value: v.npcResolver.Seeded().IdentityPromptInput().Digest},
|
{Name: "npc_registry", Value: v.npcResolver.Seeded().IdentityDigest()},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
}
|
}
|
||||||
return []pipeline.CheckpointFingerprint{
|
return []pipeline.CheckpointFingerprint{
|
||||||
{Name: "policy", Value: policy},
|
{Name: "policy", Value: policy},
|
||||||
{Name: "npc_registry", Value: v.npcResolver.Seeded().IdentityPromptInput().Digest},
|
{Name: "npc_registry", Value: v.npcResolver.Seeded().IdentityDigest()},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func TestNPCOccurrencePipelineUsesAcceptedRegistryAndCurrentEvidence(t *testing.
|
|||||||
}
|
}
|
||||||
|
|
||||||
request := client.requestFor(t, occurrenceextract.PromptID)
|
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 {
|
if got := string(request.Inputs["npc_registry"].Content); got != wantRegistry {
|
||||||
t.Fatalf("occurrence registry input = %s, want names-only projection %s", 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" {
|
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)
|
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, first.SourceRefs)
|
||||||
assertOccurrenceEvidence(t, second.SourceRefs)
|
assertOccurrenceEvidence(t, second.SourceRefs)
|
||||||
if first.SourceRefs[0].StartUnitID >= second.SourceRefs[0].StartUnitID {
|
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)
|
t.Fatalf("occurrence output step = %q, want ordered downstream step", occurrenceOutput.StepID)
|
||||||
}
|
}
|
||||||
registryRequest := client.requestFor(t, occurrenceextract.PromptID)
|
registryRequest := client.requestFor(t, occurrenceextract.PromptID)
|
||||||
if got := string(registryRequest.Inputs["npc_registry"].Content); got != `{"npcs":[{"id":"`+identity.DeriveID("Mira Thorn")+`","name":"Mira Thorn"}]}` {
|
if got := string(registryRequest.Inputs["npc_registry"].Content); got != `{"npcs":[{"name":"Mira Thorn"}]}` {
|
||||||
t.Fatalf("downstream registry = %s, want one canonical NPC identity", got)
|
t.Fatalf("downstream registry = %s, want one canonical NPC name", got)
|
||||||
}
|
}
|
||||||
manifestContent, err := json.Marshal(output.Manifest)
|
manifestContent, err := json.Marshal(output.Manifest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user