Compare commits

..

7 Commits

73 changed files with 2342 additions and 506 deletions

View File

@@ -14,9 +14,32 @@
"properties": { "properties": {
"members": { "members": {
"type": "array", "type": "array",
"items": {"type": "string"} "items": {"$ref": "#/$defs/selector"}
}, },
"canonical": {"type": "string"} "canonical": {"$ref": "#/$defs/selector"}
}
}
}
},
"$defs": {
"selector": {
"type": "object",
"additionalProperties": false,
"required": ["name", "source_refs"],
"properties": {
"name": {"type": "string", "minLength": 1},
"source_refs": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["start_unit_id", "end_unit_id"],
"properties": {
"start_unit_id": {"type": "integer", "minimum": 1},
"end_unit_id": {"type": "integer", "minimum": 1}
}
}
} }
} }
} }

View File

@@ -3,10 +3,10 @@ in party possession established by the transcript. This is an occurrence history
not an inventory or ledger: do not calculate balances, resolve item identity not an inventory or ledger: do not calculate balances, resolve item identity
across records, or infer ownership that the transcript does not establish. across records, or infer ownership that the transcript does not establish.
For every occurrence, copy the exact `item_id` and `name` pair from the supplied For every occurrence, use the supplied canonical item `name`. Record a stated
item registry. Record a stated quantity as an integer and leave it null when the transcript quantity as an integer and leave it null when the transcript does not state
does not state one. Use a concise observed item name and preserve the stated one. Preserve the stated currency denomination through the selected canonical
currency denomination. registry name.
Use `discovered` when the party learns of or encounters an item without Use `discovered` when the party learns of or encounters an item without
establishing possession. Use `acquired` when the party or a party member gains establishing possession. Use `acquired` when the party or a party member gains

View File

@@ -1,6 +1,6 @@
Use the supplied item registry only to ground each occurrence. Every record Use the supplied item registry only to ground each occurrence. Every record
must copy one registry item's exact `id` and exact `name`; do not invent, must use one registry item's canonical `name`; do not invent, rename, merge,
rename, merge, or infer registry items. The registry is not transcript or infer registry items. The registry is not transcript evidence: cite only the
evidence: cite only the current transcript chunk in `source_refs`. current transcript chunk in `source_refs`.
{{ input "item_registry" }} {{ input "item_registry" }}

View File

@@ -10,9 +10,8 @@
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["item_id", "name", "kind", "quantity", "from", "to", "source_refs"], "required": ["name", "kind", "quantity", "from", "to", "source_refs"],
"properties": { "properties": {
"item_id": {"type": "string"},
"name": {"type": "string"}, "name": {"type": "string"},
"kind": {"type": "string"}, "kind": {"type": "string"},
"quantity": {"type": ["integer", "null"]}, "quantity": {"type": ["integer", "null"]},

View File

@@ -20,6 +20,11 @@ location only when the chunk's context supports that coreference. It must not
create a registry location, and registry content or provenance must never create a registry location, and registry content or provenance must never
replace current-chunk evidence. replace current-chunk evidence.
For every occurrence, return the exact selector from the location registry:
the canonical `name`, plus an empty `registry_refs` array for a unique name or
the complete ordered `registry_refs` array for a repeated name. Registry ranges
and context identify the location only; they are not occurrence evidence.
For overlapping support, visited outranks planned, recalled, and mentioned; For overlapping support, visited outranks planned, recalled, and mentioned;
planned outranks recalled and mentioned; recalled outranks mentioned. A passage planned outranks recalled and mentioned; recalled outranks mentioned. A passage
may produce multiple records when it independently establishes separate facts, may produce multiple records when it independently establishes separate facts,

View File

@@ -1,9 +1,11 @@
A normalized location registry is provided below for identity grounding. It may A contextual location registry is provided below for identity grounding. It may
be empty. Each record contains the exact location ID and canonical display name be empty. Every record supplies a canonical display name. A name that appears
to copy when the transcript establishes an occurrence of that place. once is selected with that name and an empty `registry_refs` array. A repeated
name is selected only by copying both its name and its complete, ordered
`registry_refs` array exactly as supplied.
Registry content is context, not occurrence evidence. Do not derive an Registry content is context, not occurrence evidence. Do not derive an
occurrence or a source range from the registry, and do not infer a location occurrence or `source_refs` range from the registry. Do not invent a location
that is absent from it. or selector that is absent from it.
{{ input "location_registry" }} {{ input "location_registry" }}

View File

@@ -10,10 +10,21 @@
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["location_id", "name", "kind", "source_refs"], "required": ["name", "registry_refs", "kind", "source_refs"],
"properties": { "properties": {
"location_id": {"type": "string"},
"name": {"type": "string"}, "name": {"type": "string"},
"registry_refs": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["start_unit_id", "end_unit_id"],
"properties": {
"start_unit_id": {"type": "integer", "minimum": 1},
"end_unit_id": {"type": "integer", "minimum": 1}
}
}
},
"kind": {"enum": ["visited", "planned", "recalled", "mentioned"]}, "kind": {"enum": ["visited", "planned", "recalled", "mentioned"]},
"source_refs": { "source_refs": {
"type": "array", "type": "array",

View File

@@ -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

View File

@@ -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"
}, },

View File

@@ -1,6 +1,7 @@
Identify only well-supported duplicate groups among the supplied candidates. Identify only well-supported duplicate groups among the supplied candidates.
Candidate keys are opaque identifiers. Copy each selected key exactly. A group Return each selected candidate's supplied contextual descriptor exactly: its
must contain at least two supplied keys, and its `canonical` key must be one of `name` and complete ordered `source_refs`. A group must contain at least two
its members. Do not create keys, records, names, source references, evidence, supplied descriptors, and its `canonical` descriptor must be one of its
or replacement values. Omit any uncertain or unsafe group. members. Do not invent names, ranges, records, evidence, or replacement values.
Omit any uncertain or unsafe group.

View File

@@ -0,0 +1,64 @@
# ADR-0012: Resolve opaque entity identifiers deterministically
**Status:** Accepted
**Date:** 2026-08-08
## Context
Entity IDs in durable Notarius artifacts are application-owned, deterministic
identifiers. They are useful to artifact consumers, but their hash-based form
does not help a model distinguish entities and would make the model reproduce
an opaque implementation detail. A plain name is likewise insufficient where
multiple supplied records share that name.
The LLM boundary must preserve the typed artifact and durable-schema ownership
of [ADR-0003](0003-strongly-typed-stage-interfaces.md) and the distinction
between disambiguating references and source evidence in
[ADR-0009](0009-prefer-minimal-evidence-grounded-extraction-artifacts.md).
## Decision
Callers present a model with semantic selections: a canonical name when it is
unique in the request, or a contextual descriptor containing the name and
source coordinates when that context is needed to distinguish supplied
records. The model returns only those supplied selections. The caller resolves
each accepted selection against the request-local supplied records and attaches
the opaque application ID deterministically.
Source coordinates are permitted in a selection solely as identity context.
They neither establish an occurrence fact nor replace that occurrence's
current-transcript evidence. A selector must resolve exactly; unknown,
ambiguous, partial, reordered, or otherwise unsafe selections are not mapped.
Where an operation requires a complete grounded artifact, that failure rejects
the complete artifact rather than accepting a partially mapped result.
An explicitly scoped request-local short label is permitted only when a
contextual descriptor would be impractical and the caller can deterministically
map the label within that one request. Such a label is not a durable ID, must
not escape the request boundary, and requires a concrete justification in its
own module contract.
## Alternatives considered
- Ask the model to return durable IDs. This exposes opaque implementation
state, does not improve semantic disambiguation, and makes model output
depend on hash formatting.
- Select by name alone. This cannot safely distinguish same-name records.
- Make request-local labels durable identifiers. This would turn prompt
presentation into a public identity contract and create avoidable migration
pressure.
- Let the model invent identifiers or resolve ambiguity. This makes identity
assignment non-deterministic and weakens validation.
## Consequences
Durable integration contracts retain their exact ID/name pairs while models
operate on readable contextual selections. Calling modules must own selector
construction, exact resolution, ambiguity handling, and conversion into their
durable artifact type; PromptKit and its adapter remain transport-only.
Some ambiguous or invalid proposals are deliberately omitted, retried, or
rejected according to the caller's existing failure policy. Internal candidate
keys may support deterministic request-local mapping, but they are not
model-visible selectors or durable data. This adds local validation work while
keeping identity assignment auditable and stable.

View File

@@ -24,12 +24,13 @@ An incompatible shape change requires a new schema version.
Both extraction and normalization require an `item_registry` reference bound to Both extraction and normalization require an `item_registry` reference bound to
an earlier normalized `dnd/item-registry` artifact. The registry is immutable an earlier normalized `dnd/item-registry` artifact. The registry is immutable
for an operation and contributes only its ordered `{id,name}` projection after for an operation and contributes names-only grounding after the shared evidence
the shared evidence message. It is never occurrence evidence. message. Notarius resolves the model's selected name into the unchanged exact
durable ID/name pair. It is never occurrence evidence.
Each occurrence must use one exact registry ID/name pair. An extraction response Each occurrence must use one exact registry ID/name pair. An extraction response
with an unknown ID or mismatched name is rejected as invalid model output; the with an unknown or ambiguous selected name is rejected as invalid model output;
configured pipeline may retry it and never accepts a partial artifact. the configured pipeline may retry it and never accepts a partial artifact.
Normalization and validation remain defense in depth for artifacts entering Normalization and validation remain defense in depth for artifacts entering
through other boundaries: normalization canonicalizes a recognized name by ID, through other boundaries: normalization canonicalizes a recognized name by ID,
preserves unknown values for the registry validator, and the registry validator preserves unknown values for the registry validator, and the registry validator

View File

@@ -85,9 +85,10 @@ for later artifacts.
`dnd/item-occurrences` requires one approved item registry through its `dnd/item-occurrences` requires one approved item registry through its
`item_registry` reference slot for both extraction and normalization. Its `item_registry` reference slot for both extraction and normalization. Its
consumer receives only an ordered, source-free `{id,name}` projection; the consumer receives names-only grounding; Notarius resolves the selected name
registrys source references are never occurrence evidence. Unknown IDs and into the unchanged exact durable ID/name pair. The registrys source references
mismatched pairs are rejected by the occurrence contract. See the are never occurrence evidence. Unknown or ambiguous selections are rejected by
the occurrence contract. See the
[item-occurrence artifact](dnd-item-occurrence-artifacts.md) for that strict [item-occurrence artifact](dnd-item-occurrence-artifacts.md) for that strict
wire contract, [Configuration](../config.md#d-d-reference-slots) for binding wire contract, [Configuration](../config.md#d-d-reference-slots) for binding
rules and validator selection, and the [JSON output contract](json-output.md) rules and validator selection, and the [JSON output contract](json-output.md)

View File

@@ -73,10 +73,12 @@ complete canonical evidence sequence.
Both extraction and normalization require exactly one `location_registry` reference of Both extraction and normalization require exactly one `location_registry` reference of
kind `dnd/location-registry`, media type `application/json`, and at most 1 MiB. The kind `dnd/location-registry`, media type `application/json`, and at most 1 MiB. The
registry provides identity grounding only: unknown IDs and mismatched ID/name registry provides identity grounding only. The model selects a supplied
pairs are rejected rather than guessed or reassigned. The current transcript is contextual name-and-registry-reference descriptor, and Notarius resolves it
the only evidence source for an occurrence; registry evidence and provenance into the exact durable ID/name pair. Unknown, partial, or ambiguous selections
never become occurrence evidence. are rejected rather than guessed or reassigned. The current transcript is the
only evidence source for an occurrence; registry evidence and provenance never
become occurrence evidence.
See [Configuration](../config.md#d-d-reference-slots) for the selectable slot See [Configuration](../config.md#d-d-reference-slots) for the selectable slot
and generated-handoff compatibility, [D&D module internals](../internal/dnd.md) and generated-handoff compatibility, [D&D module internals](../internal/dnd.md)

View File

@@ -83,9 +83,11 @@ not evidence for later artifacts.
## Consumers and publication ## Consumers and publication
`dnd/location-occurrences` requires one approved location registry through its `dnd/location-occurrences` requires one approved location registry through its
`location_registry` reference slot. Its prompt receives an ordered source-free `{id, `location_registry` reference slot. Its prompt receives contextual selectors
name}` projection and must not treat registry references as occurrence containing a canonical name and registry references; Notarius resolves a
evidence. See the [location-occurrence artifact](dnd-location-occurrence-artifacts.md) selection into the unchanged exact durable ID/name pair. Registry references
must not be treated as occurrence evidence. See the
[location-occurrence artifact](dnd-location-occurrence-artifacts.md)
for that contract, [Configuration](../config.md#references-and-ordered-handoffs) for that contract, [Configuration](../config.md#references-and-ordered-handoffs)
for binding rules, and the [JSON output contract](json-output.md) for for binding rules, and the [JSON output contract](json-output.md) for
publication. publication.

View File

@@ -67,10 +67,12 @@ for uncertain classification.
## Identity, evidence, and order ## Identity, evidence, and order
The required normalized [NPC registry artifact](dnd-npc-registry-artifacts.md) resolves The required normalized [NPC registry artifact](dnd-npc-registry-artifacts.md)
the exact `{npc_id, name}` pair. Unknown IDs and names that do not match their supplies names-only contextual grounding to the model. Notarius resolves the
ID are rejected; normalization does not repair names by similarity. Registry selected name and writes the exact `{npc_id, name}` pair. An unknown or
references are provenance only and never replace an occurrence's own evidence. ambiguous selection rejects the complete model result; normalization does not
repair names by similarity. Registry references are provenance only and never
replace an occurrence's own evidence.
The registry may include an identity established by a factual third-party The registry may include an identity established by a factual third-party
mention; that provenance alone does not create a `mentioned` occurrence. Each mention; that provenance alone does not create a `mentioned` occurrence. Each
occurrence remains a separately cited fact in the current transcript. occurrence remains a separately cited fact in the current transcript.

View File

@@ -82,9 +82,10 @@ with its own cited evidence and category.
This registry can ground actor or caster names in the [spell](dnd-spell-artifacts.md) This registry can ground actor or caster names in the [spell](dnd-spell-artifacts.md)
and [combat-turn](dnd-combat-turn-artifacts.md) artifacts. It is required to and [combat-turn](dnd-combat-turn-artifacts.md) artifacts. It is required to
resolve the canonical `name` in an [NPC occurrence](dnd-npc-occurrence-artifacts.md). resolve the canonical `name` in an [NPC occurrence](dnd-npc-occurrence-artifacts.md).
Occurrence consumers receive an ordered source-free `{id,name}` projection; Occurrence consumers receive names-only grounding; Notarius resolves the
spells, combat turns, and the [enemy-event artifact](dnd-enemy-event-artifacts.md) selected canonical name and writes the unchanged exact durable ID/name pair.
receive names-only grounding for actor or subject display. None of these Spells, combat turns, and the [enemy-event artifact](dnd-enemy-event-artifacts.md)
also receive names-only grounding for actor or subject display. None of these
projections supply later-artifact evidence. [Configuration](../config.md#d-d-reference-slots) projections supply later-artifact evidence. [Configuration](../config.md#d-d-reference-slots)
owns the `npc_registry` binding rules. owns the `npc_registry` binding rules.
The [JSON output contract](json-output.md) defines publication, and The [JSON output contract](json-output.md) defines publication, and

View File

@@ -127,14 +127,17 @@ combine results from distinct scenes, so it intentionally does not apply that
rule. Configuration owns the exact validator key and chain position. rule. Configuration owns the exact validator key and chain position.
Normalizers are deterministic for spells, combat turns, item occurrences, NPC Normalizers are deterministic for spells, combat turns, item occurrences, NPC
occurrences, scene descriptions, enemy events, and location occurrences. They canonicalize display occurrences, scene descriptions, enemy events, and location occurrences. They
values and evidence, use source-document order for stable output, and issue canonicalize display values and evidence, use source-document order for stable
bounded warnings for changes or collapsed duplicates. The NPC and location output, and issue bounded warnings for changes or collapsed duplicates. NPC,
normalizers are intentional exceptions: each first produces a deterministic item, and location registry normalizers are intentional exceptions: each first
candidate set, then may use a bounded structured-LLM proposal to reconcile produces a deterministic candidate set, then may use a bounded structured-LLM
identity groups. Invalid or unusable proposals retain the deterministic result proposal to reconcile identity groups. The proposal selects supplied
and surface retry or fallback diagnostics; the model does not directly replace descriptors—names with their candidate source referencesnot durable IDs.
durable records. Request-local candidate keys may support resolution internally, but are never
included in model input or output. Colliding descriptors are ineligible, and
invalid or unusable proposals retain the deterministic result with retry or
fallback diagnostics; the model does not directly replace durable records.
## Generated References And Grounding ## Generated References And Grounding
@@ -144,11 +147,19 @@ producer provenance; consumers resolve the handed-off artifact into an
immutable, validated projection for each operation. External files are checked immutable, validated projection for each operation. External files are checked
during preparation, while generated artifacts are resolved at the handoff. during preparation, while generated artifacts are resolved at the handoff.
NPC, location, and item registries project ordered, source-free `{id, name}` NPC and item registry consumers receive names-only grounding. Location
pairs to their respective occurrence extractors and normalizers. Exact ID/name consumers receive a contextual selector containing the canonical name and the
matching preserves every identity the registry recognizes, including same-name registry references needed to distinguish same-name places. The calling module
locations with distinct source anchors. The NPC registry additionally supplies resolves those supplied selections locally and maps them into the unchanged
names-only actor grounding to spells, combat turns, and enemy events. durable ID/name pair; an unknown or ambiguous selection rejects the complete
occurrence result rather than accepting a partial mapping. The NPC registry
additionally supplies names-only actor grounding to spells, combat turns, and
enemy events.
Registry references establish a registry identity and may disambiguate a
selection, but never become occurrence evidence. Each occurrence keeps its own
current-transcript source references, even when it was grounded through the
same registry record.
Scene descriptions are eligibility-only projections: they retain current-chunk Scene descriptions are eligibility-only projections: they retain current-chunk
classification data, not scene prose or evidence, and exist to route combat classification data, not scene prose or evidence, and exist to route combat
extraction. Enemy-event extraction also projects combat turns to `actor` and extraction. Enemy-event extraction also projects combat turns to `actor` and
@@ -170,7 +181,7 @@ checkpoint fingerprint.
| Spells | May use a spell-catalog overlay and optional NPC grounding; the catalog validator supplies domain-specific semantic checks. | | Spells | May use a spell-catalog overlay and optional NPC grounding; the catalog validator supplies domain-specific semantic checks. |
| NPC registry | Establishes transcript-grounded NPC identities, including factual third-party mentions, without assigning occurrence categories. It does not consume an NPC registry, and its normalizer is the LLM-assisted reconciliation exception described above. | | NPC registry | Establishes transcript-grounded NPC identities, including factual third-party mentions, without assigning occurrence categories. It does not consume an NPC registry, and its normalizer is the LLM-assisted reconciliation exception described above. |
| Combat turns | Requires a scene-description artifact. It calls the LLM only for an exact `combat` classification; exact non-combat classifications return an accepted empty result, while missing or mismatched classifications return an empty result with a bounded warning. Optional NPC grounding never becomes evidence. | | Combat turns | Requires a scene-description artifact. It calls the LLM only for an exact `combat` classification; exact non-combat classifications return an accepted empty result, while missing or mismatched classifications return an empty result with a bounded warning. Optional NPC grounding never becomes evidence. |
| Item occurrences | Requires the normalized item registry for exact ID/name grounding at extraction and normalization. Campaign context may disambiguate, but the registry never becomes occurrence evidence. | | Item occurrences | Requires the normalized item registry for exact deterministic grounding at extraction and normalization. Campaign context may disambiguate, but the registry never becomes occurrence evidence. |
| Item registry | Produces source-grounded item types and unique designations. Its LLM-assisted reconciliation is proposal-only, preserves distinct currency denominations and item types, and does not create per-instance identities. | | Item registry | Produces source-grounded item types and unique designations. Its LLM-assisted reconciliation is proposal-only, preserves distinct currency denominations and item types, and does not create per-instance identities. |
| NPC occurrences | Requires the normalized NPC registry at extraction and normalization, using it for canonical actor grounding only. It separately emits cited current-transcript occurrence facts, including `mentioned`, rather than deriving them from registry provenance. | | NPC occurrences | Requires the normalized NPC registry at extraction and normalization, using it for canonical actor grounding only. It separately emits cited current-transcript occurrence facts, including `mentioned`, rather than deriving them from registry provenance. |
| Scene descriptions | Produces the classifications consumed by combat routing; it does not consume an NPC registry or provide evidence for combat artifacts. | | Scene descriptions | Produces the classifications consumed by combat routing; it does not consume an NPC registry or provide evidence for combat artifacts. |

View File

@@ -24,6 +24,9 @@ adapter does not own source evidence, artifact conversion, normalization, or
durable schemas. Those responsibilities remain with the module and its durable schemas. Those responsibilities remain with the module and its
[integration contract](../integrations/). [integration contract](../integrations/).
The calling module also resolves contextual entity selections and attaches any
application identity; PromptKit and this adapter do not own entity identity.
`PromptKitClient` validates the request target and prompt identity, maps each `PromptKitClient` validates the request target and prompt identity, maps each
named material to a PromptKit inline artifact while preserving its origin URI, named material to a PromptKit inline artifact while preserving its origin URI,
passes the supplied request session through to PromptKit's direct per-run passes the supplied request session through to PromptKit's direct per-run

View File

@@ -183,6 +183,12 @@ The caller of the LLM owns prompt selection, prompt inputs, response schema,
and interpretation of structured output. Provider adapters do not own source- and interpretation of structured output. Provider adapters do not own source-
or domain-specific prompt logic. or domain-specific prompt logic.
When a model selects an application entity, callers must supply a contextual
selection and deterministically attach the opaque application identity whenever
the selection resolves exactly. Models do not receive or reproduce opaque
application identifiers; [ADR-0012](../adr/0012-resolve-opaque-entity-identifiers-deterministically.md)
records the rationale and limited request-local-label exception.
LLM calls and other external operations accept cancellation and respect LLM calls and other external operations accept cancellation and respect
timeouts. Concurrency control belongs in shared runtime plumbing rather than in timeouts. Concurrency control belongs in shared runtime plumbing rather than in
individual modules. individual modules.

View 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.

View File

@@ -29,13 +29,15 @@ not as committed release dates.
- Add a reusable normalizer that asks an LLM to identify duplicate sets in a - Add a reusable normalizer that asks an LLM to identify duplicate sets in a
list and propose one replacement element for each set. list and propose one replacement element for each set.
- Define the minimum domain-neutral input contract, initially an ordered list - Define the minimum domain-neutral input contract, initially an ordered list
whose elements have stable unique IDs. Artifact-kind registrations or whose elements retain stable unique IDs as internal deterministic state.
adapters may expose that structure without moving domain rules into the Model proposals use contextual descriptors, or a specifically justified
generic package. request-local short label, rather than durable IDs. Artifact-kind
registrations or adapters may expose that structure without moving domain
rules into the generic package.
- Keep mutation deterministic: parse and validate the model's duplicate groups, - Keep mutation deterministic: parse and validate the model's duplicate groups,
require every referenced ID to exist, reject overlapping or malformed groups, resolve every supplied descriptor or local label exactly, reject overlapping
prevent unrelated insertion or deletion, and apply only approved replacement or malformed groups, prevent unrelated insertion or deletion, and apply only
operations in code. approved replacement operations in code.
- Preserve provenance needed for audit and downstream validation, and emit - Preserve provenance needed for audit and downstream validation, and emit
warnings describing every collapsed group. warnings describing every collapsed group.
- Evaluate batching and context-window limits before applying the normalizer to - Evaluate batching and context-window limits before applying the normalizer to

View File

@@ -0,0 +1,612 @@
# 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`.
The prompt material's existing `Digest` field owns the digest of the exact
model projection; do not expose a second grounding-specific digest API.
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 carries the
model projection digest in its `LLMInputMaterial`. 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. Keep the occurrence's
static module fingerprint based on `IdentityDigest()`, prompt/schema
fingerprints, and mapping policy. The operation-scoped projection is already
covered by source/chunk identity and configured or generated reference
dependencies, while its `LLMInputMaterial.Digest` identifies the exact model
input; do not add a second digest API or operation-aware static fingerprint.
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.

View File

@@ -219,14 +219,15 @@ func TestMaintainedCompleteExamplePublishesRegistryBackedEntityOccurrences(t *te
} }
for _, request := range locationRequests { for _, request := range locationRequests {
registryInput := request.Inputs["location_registry"] registryInput := request.Inputs["location_registry"]
if !strings.Contains(string(registryInput.Content), "Moon Gate") || !strings.Contains(string(registryInput.Content), `"id"`) || strings.Contains(string(registryInput.Content), "source_refs") { if !strings.Contains(string(registryInput.Content), "Moon Gate") || !strings.Contains(string(registryInput.Content), "registry_refs") || strings.Contains(string(registryInput.Content), `"id"`) || strings.Contains(string(registryInput.Content), "source_refs") {
t.Fatalf("location occurrence registry input = %q, want source-free ID grounding", registryInput.Content) t.Fatalf("location occurrence registry input = %q, want contextual selector grounding", registryInput.Content)
} }
} }
for _, test := range []struct { for _, test := range []struct {
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"},
@@ -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)
} }
} }
} }
@@ -319,16 +321,16 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque
} else { } else {
var registry struct { var registry struct {
Items []struct { Items []struct {
ID string `json:"id"` Name string `json:"name"`
} `json:"items"` } `json:"items"`
} }
if err := json.Unmarshal(request.Inputs["item_registry"].Content, &registry); err != nil { if err := json.Unmarshal(request.Inputs["item_registry"].Content, &registry); err != nil {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("decode generated item registry: %w", err) return contracts.StructuredCompletionResponse{}, fmt.Errorf("decode generated item registry: %w", err)
} }
if len(registry.Items) != 1 { if len(registry.Items) != 1 || registry.Items[0].Name != "Moonblade" {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated item registry has %d items, want 1", len(registry.Items)) return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated item registry has %d items, want 1", len(registry.Items))
} }
content = []byte(fmt.Sprintf(`{"occurrences":[{"item_id":%q,"name":"Moonblade","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_segment":5,"end_segment":5}]}]}`, registry.Items[0].ID)) content = []byte(`{"occurrences":[{"name":"Moonblade","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_segment":5,"end_segment":5}]}]}`)
} }
case combat.PromptID: case combat.PromptID:
content = []byte(`{"combat_turns":[{"actor":"Kesh","turn_kind":"turn","source_refs":[{"start_unit_id":8,"end_unit_id":8}]}]}`) content = []byte(`{"combat_turns":[{"actor":"Kesh","turn_kind":"turn","source_refs":[{"start_unit_id":8,"end_unit_id":8}]}]}`)
@@ -336,23 +338,27 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque
if combatScene { if combatScene {
var registry struct { var registry struct {
NPCs []struct { NPCs []struct {
ID string `json:"id"` Name string `json:"name"`
} `json:"npcs"` } `json:"npcs"`
} }
if err := json.Unmarshal(request.Inputs["npc_registry"].Content, &registry); err != nil { if err := json.Unmarshal(request.Inputs["npc_registry"].Content, &registry); err != nil {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("decode generated NPC registry: %w", err) return contracts.StructuredCompletionResponse{}, fmt.Errorf("decode generated NPC registry: %w", err)
} }
if len(registry.NPCs) == 0 { if len(registry.NPCs) == 0 || registry.NPCs[0].Name != "Kesh" {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated NPC registry has no NPCs") return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated NPC registry has no NPCs")
} }
content = []byte(fmt.Sprintf(`{"occurrences":[{"npc_id":%q,"name":"Kesh","kind":"combat_opponent","source_refs":[{"start_unit_id":7,"end_unit_id":7}]}]}`, registry.NPCs[0].ID)) content = []byte(`{"occurrences":[{"name":"Kesh","kind":"combat_opponent","source_refs":[{"start_unit_id":7,"end_unit_id":7}]}]}`)
} else { } else {
content = []byte(`{"occurrences":[]}`) content = []byte(`{"occurrences":[]}`)
} }
case locationoccurrences.PromptID: case locationoccurrences.PromptID:
var registry struct { var registry struct {
Locations []struct { Locations []struct {
ID string `json:"id"` Name string `json:"name"`
RegistryRefs []struct {
StartUnitID int `json:"start_unit_id"`
EndUnitID int `json:"end_unit_id"`
} `json:"registry_refs"`
} `json:"locations"` } `json:"locations"`
} }
if err := json.Unmarshal(request.Inputs["location_registry"].Content, &registry); err != nil { if err := json.Unmarshal(request.Inputs["location_registry"].Content, &registry); err != nil {
@@ -362,14 +368,18 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque
return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated location registry has no locations") return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated location registry has no locations")
} }
unitID := 1 unitID := 1
locationID := registry.Locations[0].ID location := registry.Locations[0]
if combatScene { if combatScene {
unitID = 7 unitID = 7
if len(registry.Locations) > 1 { if len(registry.Locations) > 1 {
locationID = registry.Locations[1].ID location = registry.Locations[1]
} }
} }
content = []byte(fmt.Sprintf(`{"occurrences":[{"location_id":%q,"name":"Moon Gate","kind":"visited","source_refs":[{"start_unit_id":%d,"end_unit_id":%d}]}]}`, locationID, unitID, unitID)) registryRefs, err := json.Marshal(location.RegistryRefs)
if err != nil {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("encode location selector: %w", err)
}
content = []byte(fmt.Sprintf(`{"occurrences":[{"name":%q,"registry_refs":%s,"kind":"visited","source_refs":[{"start_unit_id":%d,"end_unit_id":%d}]}]}`, location.Name, registryRefs, unitID, unitID))
case enemyevents.PromptID: case enemyevents.PromptID:
content = []byte(`{"events":[{"name":"Kesh","kind":"fled","source_refs":[{"start_unit_id":10,"end_unit_id":10}]}]}`) content = []byte(`{"events":[{"name":"Kesh","kind":"fled","source_refs":[{"start_unit_id":10,"end_unit_id":10}]}]}`)
default: default:

View File

@@ -10,24 +10,34 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
) )
type orderedItemOccurrenceResponse struct { type orderedItemOccurrence struct {
value itemOccurrenceResponse value dnd.ItemOccurrence
earliest int earliest int
hasEvidence bool hasEvidence bool
} }
func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOrder, sourceID string, registry *itemregistry.Registry) error { func canonicalItemOccurrenceList(response extractionResponse, order shared.SourceRefOrder, sourceID string, registry *itemregistry.Registry) (dnd.ItemOccurrenceList, error) {
if response == nil { if response.Occurrences == nil {
return nil return dnd.ItemOccurrenceList{}, nil
} }
ordered := make([]orderedItemOccurrenceResponse, len(response.Occurrences)) ordered := make([]orderedItemOccurrence, len(response.Occurrences))
for index := range response.Occurrences { for index, occurrence := range response.Occurrences {
if err := validateRegistryPair(index, response.Occurrences[index], registry); err != nil { item, found := registry.Lookup(occurrence.Name)
return err if !found {
return dnd.ItemOccurrenceList{}, fmt.Errorf("occurrences[%d].name is not in the item registry", index)
} }
earliest, hasEvidence := canonicalizeItemOccurrence(&response.Occurrences[index], order, sourceID) refs := order.Canonicalize(itemOccurrenceSourceRefs(occurrence.SourceRefs, sourceID))
ordered[index] = orderedItemOccurrenceResponse{ earliest, hasEvidence := order.EarliestValid(refs)
value: response.Occurrences[index], ordered[index] = orderedItemOccurrence{
value: dnd.ItemOccurrence{
ItemID: item.ID,
Name: item.Name,
Kind: dnd.ItemOccurrenceKind(occurrence.Kind),
Quantity: cloneQuantity(occurrence.Quantity),
From: occurrence.From,
To: occurrence.To,
SourceRefs: refs,
},
earliest: earliest, earliest: earliest,
hasEvidence: hasEvidence, hasEvidence: hasEvidence,
} }
@@ -41,49 +51,11 @@ func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOr
} }
return ordered[left].earliest < ordered[right].earliest return ordered[left].earliest < ordered[right].earliest
}) })
for index := range ordered { occurrences := make([]dnd.ItemOccurrence, len(ordered))
response.Occurrences[index] = ordered[index].value for index, occurrence := range ordered {
occurrences[index] = occurrence.value
} }
return nil return dnd.ItemOccurrenceList{Occurrences: occurrences}, nil
}
func canonicalizeItemOccurrence(occurrence *itemOccurrenceResponse, order shared.SourceRefOrder, sourceID string) (int, bool) {
if occurrence == nil {
return 0, false
}
refs := order.Canonicalize(itemOccurrenceSourceRefs(occurrence.SourceRefs, sourceID))
occurrence.SourceRefs = itemOccurrenceResponseRefs(refs)
return order.EarliestValid(refs)
}
func validateRegistryPair(index int, occurrence itemOccurrenceResponse, registry *itemregistry.Registry) error {
item, found := registry.LookupID(occurrence.ItemID)
if !found {
return fmt.Errorf("occurrences[%d].item_id is not in the item registry", index)
}
if item.Name != occurrence.Name {
return fmt.Errorf("occurrences[%d].name does not match item_id", index)
}
return nil
}
func canonicalItemOccurrenceList(response extractionResponse, sourceID string) dnd.ItemOccurrenceList {
if response.Occurrences == nil {
return dnd.ItemOccurrenceList{}
}
occurrences := make([]dnd.ItemOccurrence, 0, len(response.Occurrences))
for _, occurrence := range response.Occurrences {
occurrences = append(occurrences, dnd.ItemOccurrence{
ItemID: occurrence.ItemID,
Name: occurrence.Name,
Kind: dnd.ItemOccurrenceKind(occurrence.Kind),
Quantity: cloneQuantity(occurrence.Quantity),
From: occurrence.From,
To: occurrence.To,
SourceRefs: itemOccurrenceSourceRefs(occurrence.SourceRefs, sourceID),
})
}
return dnd.ItemOccurrenceList{Occurrences: occurrences}
} }
func itemOccurrenceSourceRefs(refs []itemOccurrenceSourceRefResponse, sourceID string) []source.SourceRef { func itemOccurrenceSourceRefs(refs []itemOccurrenceSourceRefResponse, sourceID string) []source.SourceRef {
@@ -97,17 +69,6 @@ func itemOccurrenceSourceRefs(refs []itemOccurrenceSourceRefResponse, sourceID s
return values return values
} }
func itemOccurrenceResponseRefs(refs []source.SourceRef) []itemOccurrenceSourceRefResponse {
if refs == nil {
return nil
}
values := make([]itemOccurrenceSourceRefResponse, len(refs))
for index, ref := range refs {
values[index] = itemOccurrenceSourceRefResponse{StartSegment: ref.StartUnitID, EndSegment: ref.EndUnitID}
}
return values
}
func cloneQuantity(value *int) *int { func cloneQuantity(value *int) *int {
if value == nil { if value == nil {
return nil return nil

View File

@@ -20,7 +20,7 @@ const (
ItemRegistryMaxBytes = itemregistry.MaxBytes ItemRegistryMaxBytes = itemregistry.MaxBytes
) )
const mappingPolicy = "dnd.item_occurrences.extract_mapping.v1" const mappingPolicy = "dnd.item_occurrences.extract_mapping.v2"
var requiredCapabilities = []string{ var requiredCapabilities = []string{
"chunks", "chunks",
@@ -161,10 +161,11 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
}, &response); err != nil { }, &response); err != nil {
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("complete structured output: %w", err) return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("complete structured output: %w", err)
} }
if err := canonicalizeResponse(&response, order, req.Source.ID, registry); err != nil { value, err := canonicalItemOccurrenceList(response, order, req.Source.ID, registry)
if err != nil {
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("map item occurrence response: %w", err) return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("map item occurrence response: %w", err)
} }
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{Value: canonicalItemOccurrenceList(response, req.Source.ID)}, nil return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{Value: value}, nil
} }
func ModuleSpec() pipeline.ModuleSpec { func ModuleSpec() pipeline.ModuleSpec {

View File

@@ -12,7 +12,7 @@ import (
func TestExtractGroundsOccurrencesInRequiredRegistry(t *testing.T) { func TestExtractGroundsOccurrencesInRequiredRegistry(t *testing.T) {
id := itemidentity.DeriveID("Torch") id := itemidentity.DeriveID("Torch")
client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{ client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{
{ItemID: id, Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)}, {Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)},
}}} }}}
req := extractionRequest() req := extractionRequest()
req.References = itemRegistryReferences(t) req.References = itemRegistryReferences(t)
@@ -24,55 +24,38 @@ func TestExtractGroundsOccurrencesInRequiredRegistry(t *testing.T) {
t.Fatalf("occurrences = %#v", result.Value.Occurrences) t.Fatalf("occurrences = %#v", result.Value.Occurrences)
} }
input := client.requests[0].Inputs[ItemRegistryReferenceSlot] input := client.requests[0].Inputs[ItemRegistryReferenceSlot]
if input.Name != ItemRegistryReferenceSlot || string(input.Content) == "" { if input.Name != ItemRegistryReferenceSlot || string(input.Content) != `{"items":[{"name":"Torch"}]}` || strings.Contains(string(input.Content), "item:sha256:") {
t.Fatalf("registry prompt input = %#v", input) t.Fatalf("registry prompt input = %#v, want names-only projection", input)
} }
} }
func TestExtractRejectsInvalidRegistryPairs(t *testing.T) { func TestExtractCanonicalizesComparisonEquivalentNames(t *testing.T) {
id := itemidentity.DeriveID("Torch")
for _, test := range []struct {
name string
occurrence itemOccurrenceResponse
wantError string
}{
{
name: "unknown item ID",
occurrence: itemOccurrenceResponse{ItemID: "unknown", Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)},
wantError: "occurrences[0].item_id is not in the item registry",
},
{
name: "mismatched item name",
occurrence: itemOccurrenceResponse{ItemID: id, Name: "Lantern", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)},
wantError: "occurrences[0].name does not match item_id",
},
} {
t.Run(test.name, func(t *testing.T) {
client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{test.occurrence}}}
req := extractionRequest()
req.References = itemRegistryReferences(t)
result, err := newExtractor(t, client, req.References).Extract(context.Background(), req)
if err == nil || !strings.Contains(err.Error(), test.wantError) {
t.Fatalf("Extract() error = %v, want %q", err, test.wantError)
}
if len(result.Value.Occurrences) != 0 {
t.Fatalf("Extract() returned partial occurrences: %#v", result.Value.Occurrences)
}
})
}
}
func TestExtractRejectsResponseWithInvalidRegistryPairAfterValidOccurrence(t *testing.T) {
id := itemidentity.DeriveID("Torch") id := itemidentity.DeriveID("Torch")
client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{ client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{
{ItemID: id, Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)}, {Name: " tORCH ", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)},
{ItemID: "unknown", Name: "Unknown", Kind: "lost", From: "party", SourceRefs: responseRefs(2, 2)},
}}} }}}
req := extractionRequest() req := extractionRequest()
req.References = itemRegistryReferences(t) req.References = itemRegistryReferences(t)
result, err := newExtractor(t, client, req.References).Extract(context.Background(), req) result, err := newExtractor(t, client, req.References).Extract(context.Background(), req)
if err == nil || !strings.Contains(err.Error(), "occurrences[1].item_id") { if err != nil {
t.Fatalf("Extract() error = %v, want occurrence index and item ID", err) t.Fatal(err)
}
occurrence := result.Value.Occurrences[0]
if occurrence.ItemID != id || occurrence.Name != "Torch" {
t.Fatalf("canonical occurrence = %#v", occurrence)
}
}
func TestExtractRejectsUnknownNameAfterValidOccurrence(t *testing.T) {
client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{
{Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)},
{Name: "Unknown", Kind: "lost", From: "party", SourceRefs: responseRefs(2, 2)},
}}}
req := extractionRequest()
req.References = itemRegistryReferences(t)
result, err := newExtractor(t, client, req.References).Extract(context.Background(), req)
if err == nil || !strings.Contains(err.Error(), "occurrences[1].name is not in the item registry") {
t.Fatalf("Extract() error = %v, want occurrence index and unknown name", err)
} }
if len(result.Value.Occurrences) != 0 { if len(result.Value.Occurrences) != 0 {
t.Fatalf("Extract() returned partial occurrences: %#v", result.Value.Occurrences) t.Fatalf("Extract() returned partial occurrences: %#v", result.Value.Occurrences)
@@ -86,9 +69,36 @@ func TestExtractRequiresItemRegistry(t *testing.T) {
} }
} }
func TestExtractAcceptsOnlyEmptyResponseForEmptyRegistry(t *testing.T) {
references := emptyItemRegistryReferences(t)
for _, test := range []struct {
name string
response extractionResponse
wantErr bool
}{
{name: "empty", response: extractionResponse{Occurrences: []itemOccurrenceResponse{}}},
{name: "selection", response: extractionResponse{Occurrences: []itemOccurrenceResponse{{Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)}}}, wantErr: true},
} {
t.Run(test.name, func(t *testing.T) {
client := &fakeItemOccurrencesLLMClient{response: test.response}
req := extractionRequest()
req.References = references
result, err := newExtractor(t, client, references).Extract(context.Background(), req)
if test.wantErr {
if err == nil || !strings.Contains(err.Error(), "name is not in the item registry") || len(result.Value.Occurrences) != 0 {
t.Fatalf("Extract() = %#v, %v; want no accepted occurrences", result, err)
}
return
}
if err != nil || result.Value.Occurrences == nil || len(result.Value.Occurrences) != 0 {
t.Fatalf("Extract() = %#v, %v; want present empty occurrences", result, err)
}
})
}
}
func TestExtractPreservesNullableFields(t *testing.T) { func TestExtractPreservesNullableFields(t *testing.T) {
id := itemidentity.DeriveID("Torch") client := &fakeItemOccurrencesLLMClient{content: []byte(`{"occurrences":[{"name":"Torch","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_segment":1,"end_segment":1}]}]}`)}
client := &fakeItemOccurrencesLLMClient{content: []byte(`{"occurrences":[{"item_id":"` + id + `","name":"Torch","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_segment":1,"end_segment":1}]}]}`)}
req := extractionRequest() req := extractionRequest()
req.References = itemRegistryReferences(t) req.References = itemRegistryReferences(t)
result, err := newExtractor(t, client, req.References).Extract(context.Background(), req) result, err := newExtractor(t, client, req.References).Extract(context.Background(), req)

View File

@@ -5,7 +5,6 @@ type extractionResponse struct {
} }
type itemOccurrenceResponse struct { type itemOccurrenceResponse struct {
ItemID string `json:"item_id"`
Name string `json:"name"` Name string `json:"name"`
Kind string `json:"kind"` Kind string `json:"kind"`
Quantity *int `json:"quantity,omitempty"` Quantity *int `json:"quantity,omitempty"`

View File

@@ -34,7 +34,7 @@ func TestPromptAssetsPrepareItemOccurrencePrompt(t *testing.T) {
"players": promptkit.Inline("item-occurrence-player"), "players": promptkit.Inline("item-occurrence-player"),
"party": promptkit.Inline(" "), "party": promptkit.Inline(" "),
"glossary": promptkit.Inline(" "), "glossary": promptkit.Inline(" "),
"item_registry": promptkit.Inline(`{"items":[{"id":"item:sha256:test","name":"Torch"}]}`), "item_registry": promptkit.Inline(`{"items":[{"name":"Torch"}]}`),
}, },
}) })
if err != nil { if err != nil {

View File

@@ -21,6 +21,15 @@ func itemRegistryReferences(t *testing.T) contracts.ReferenceSet {
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ItemRegistryReferenceSlot: {Slot: contracts.ReferenceSlot{Name: ItemRegistryReferenceSlot}, Items: []contracts.ReferenceItem{{SlotName: ItemRegistryReferenceSlot, Content: content, MediaType: "application/json", ArtifactKind: dnd.ItemRegistryKind}}}}} return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ItemRegistryReferenceSlot: {Slot: contracts.ReferenceSlot{Name: ItemRegistryReferenceSlot}, Items: []contracts.ReferenceItem{{SlotName: ItemRegistryReferenceSlot, Content: content, MediaType: "application/json", ArtifactKind: dnd.ItemRegistryKind}}}}}
} }
func emptyItemRegistryReferences(t *testing.T) contracts.ReferenceSet {
t.Helper()
content, err := itemcodec.New().Encode(dnd.ItemRegistry{Items: []dnd.Item{}})
if err != nil {
t.Fatal(err)
}
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ItemRegistryReferenceSlot: {Slot: contracts.ReferenceSlot{Name: ItemRegistryReferenceSlot}, Items: []contracts.ReferenceItem{{SlotName: ItemRegistryReferenceSlot, Content: content, MediaType: "application/json", ArtifactKind: dnd.ItemRegistryKind}}}}}
}
func testSourceRefs() []source.SourceRef { func testSourceRefs() []source.SourceRef {
return []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}} return []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}}
} }

View File

@@ -19,11 +19,11 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) {
} }
valid := map[string]any{"occurrences": []any{ valid := map[string]any{"occurrences": []any{
map[string]any{ map[string]any{
"item_id": "item:sha256:test", "name": "", "kind": "unsupported", "quantity": 0, "from": "party", "to": "Party", "name": "", "kind": "unsupported", "quantity": 0, "from": "party", "to": "Party",
"source_refs": []any{map[string]any{"start_segment": 0, "end_segment": -1}}, "source_refs": []any{map[string]any{"start_segment": 0, "end_segment": -1}},
}, },
map[string]any{ map[string]any{
"item_id": "item:sha256:test", "name": "Hidden Cache", "kind": "discovered", "quantity": nil, "from": nil, "to": nil, "name": "Hidden Cache", "kind": "discovered", "quantity": nil, "from": nil, "to": nil,
"source_refs": []any{map[string]any{"start_segment": 1, "end_segment": 1}}, "source_refs": []any{map[string]any{"start_segment": 1, "end_segment": 1}},
}, },
}} }}
@@ -41,6 +41,7 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) {
{"missing occurrences", map[string]any{}}, {"missing occurrences", map[string]any{}},
{"missing occurrence name", map[string]any{"occurrences": []any{withoutField(responseOccurrence(), "name")}}}, {"missing occurrence name", map[string]any{"occurrences": []any{withoutField(responseOccurrence(), "name")}}},
{"missing nullable field", map[string]any{"occurrences": []any{withoutField(responseOccurrence(), "quantity")}}}, {"missing nullable field", map[string]any{"occurrences": []any{withoutField(responseOccurrence(), "quantity")}}},
{"opaque item identifier", map[string]any{"occurrences": []any{withField(responseOccurrence(), "item_id", "item:sha256:opaque")}}},
{"unknown occurrence field", map[string]any{"occurrences": []any{withField(responseOccurrence(), "extra", true)}}}, {"unknown occurrence field", map[string]any{"occurrences": []any{withField(responseOccurrence(), "extra", true)}}},
{"unknown reference field", map[string]any{"occurrences": []any{withField(responseOccurrence(), "source_refs", []any{map[string]any{"start_segment": 1, "end_segment": 1, "extra": true}})}}}, {"unknown reference field", map[string]any{"occurrences": []any{withField(responseOccurrence(), "source_refs", []any{map[string]any{"start_segment": 1, "end_segment": 1, "extra": true}})}}},
{"noninteger range", map[string]any{"occurrences": []any{withField(responseOccurrence(), "source_refs", []any{map[string]any{"start_segment": 1.5, "end_segment": 1}})}}}, {"noninteger range", map[string]any{"occurrences": []any{withField(responseOccurrence(), "source_refs", []any{map[string]any{"start_segment": 1.5, "end_segment": 1}})}}},
@@ -68,7 +69,7 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) {
func responseOccurrence() map[string]any { func responseOccurrence() map[string]any {
return map[string]any{ return map[string]any{
"item_id": "item:sha256:test", "name": "Ring", "kind": "acquired", "quantity": nil, "from": nil, "to": "party", "source_refs": []any{}, "name": "Ring", "kind": "acquired", "quantity": nil, "from": nil, "to": "party", "source_refs": []any{},
} }
} }

View File

@@ -1,11 +1,13 @@
package locationoccurrences package locationoccurrences
import ( import (
"fmt"
"reflect" "reflect"
"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"
locationregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/registry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
) )
@@ -15,17 +17,21 @@ type orderedOccurrence struct {
hasEvidence bool hasEvidence bool
} }
func canonicalOccurrenceList(response extractionResponse, order shared.SourceRefOrder, sourceID string) dnd.LocationOccurrenceList { func canonicalOccurrenceList(response extractionResponse, order shared.SourceRefOrder, sourceID string, grounding *locationregistry.Grounding) (dnd.LocationOccurrenceList, error) {
if response.Occurrences == nil { if response.Occurrences == nil {
return dnd.LocationOccurrenceList{} return dnd.LocationOccurrenceList{}, nil
} }
ordered := make([]orderedOccurrence, len(response.Occurrences)) ordered := make([]orderedOccurrence, len(response.Occurrences))
for index, occurrence := range response.Occurrences { for index, occurrence := range response.Occurrences {
location, ok := grounding.Resolve(locationregistry.Selector{Name: occurrence.Name, RegistryRefs: occurrence.RegistryRefs})
if !ok {
return dnd.LocationOccurrenceList{}, fmt.Errorf("occurrence %d does not match a supplied location selector", index)
}
refs := order.Canonicalize(canonicalSourceRefs(occurrence.SourceRefs, sourceID)) refs := order.Canonicalize(canonicalSourceRefs(occurrence.SourceRefs, sourceID))
earliest, hasEvidence := order.EarliestValid(refs) earliest, hasEvidence := order.EarliestValid(refs)
ordered[index] = orderedOccurrence{value: dnd.LocationOccurrence{ ordered[index] = orderedOccurrence{value: dnd.LocationOccurrence{
LocationID: occurrence.LocationID, LocationID: location.ID,
Name: occurrence.Name, Name: location.Name,
Kind: dnd.LocationOccurrenceKind(occurrence.Kind), Kind: dnd.LocationOccurrenceKind(occurrence.Kind),
SourceRefs: refs, SourceRefs: refs,
}, earliest: earliest, hasEvidence: hasEvidence} }, earliest: earliest, hasEvidence: hasEvidence}
@@ -39,7 +45,7 @@ func canonicalOccurrenceList(response extractionResponse, order shared.SourceRef
occurrences = append(occurrences, occurrence.value) occurrences = append(occurrences, occurrence.value)
} }
} }
return dnd.LocationOccurrenceList{Occurrences: occurrences} return dnd.LocationOccurrenceList{Occurrences: occurrences}, nil
} }
func lessOccurrence(left, right orderedOccurrence, order shared.SourceRefOrder) bool { func lessOccurrence(left, right orderedOccurrence, order shared.SourceRefOrder) bool {

View File

@@ -15,7 +15,7 @@ import (
const ( const (
Key = "dnd/location-occurrences" Key = "dnd/location-occurrences"
mappingPolicy = "dnd.location_occurrences.extract_mapping.v1" mappingPolicy = "dnd.location_occurrences.extract_mapping.v2"
) )
const ( const (
@@ -121,7 +121,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: "location_registry", Value: e.locationResolver.Seeded().ProjectionDigest()}, {Name: "location_registry", Value: e.locationResolver.Seeded().IdentityDigest()},
} }
} }
@@ -143,17 +143,25 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
if !registry.Bound() { if !registry.Bound() {
return contracts.TypedExtractionResult[dnd.LocationOccurrenceList]{}, extractorErrorf("location registry reference is required") return contracts.TypedExtractionResult[dnd.LocationOccurrenceList]{}, extractorErrorf("location registry reference is required")
} }
grounding, err := locationregistry.NewGrounding(registry, req.Source)
if err != nil {
return contracts.TypedExtractionResult[dnd.LocationOccurrenceList]{}, extractorErrorf("prepare location grounding: %w", err)
}
var response extractionResponse var response extractionResponse
inputs := shared.PromptInputs(sourceInput, req.References) inputs := shared.PromptInputs(sourceInput, req.References)
inputs[LocationRegistryReferenceSlot] = registry.PromptInput() inputs[LocationRegistryReferenceSlot] = grounding.PromptInput()
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
StageName: Key, PromptID: PromptID, PromptVersion: SchemaVersion, StageName: Key, PromptID: PromptID, PromptVersion: SchemaVersion,
ProfileID: req.LLMProfile, SessionID: req.SessionID, Inputs: inputs, ProfileID: req.LLMProfile, SessionID: req.SessionID, Inputs: inputs,
}, &response); err != nil { }, &response); err != nil {
return contracts.TypedExtractionResult[dnd.LocationOccurrenceList]{}, extractorErrorf("complete structured output: %w", err) return contracts.TypedExtractionResult[dnd.LocationOccurrenceList]{}, extractorErrorf("complete structured output: %w", err)
} }
return contracts.TypedExtractionResult[dnd.LocationOccurrenceList]{Value: canonicalOccurrenceList(response, shared.NewSourceRefOrder(req.Source), req.Source.ID)}, nil occurrences, err := canonicalOccurrenceList(response, shared.NewSourceRefOrder(req.Source), req.Source.ID, grounding)
if err != nil {
return contracts.TypedExtractionResult[dnd.LocationOccurrenceList]{}, extractorErrorf("resolve location grounding: %w", err)
}
return contracts.TypedExtractionResult[dnd.LocationOccurrenceList]{Value: occurrences}, nil
} }
func ModuleSpec() pipeline.ModuleSpec { func ModuleSpec() pipeline.ModuleSpec {

View File

@@ -14,19 +14,20 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
locationcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/locationregistry" locationcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/locationregistry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity"
locationregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/registry"
) )
func TestExtractMapsKindsOrdersOccurrencesAndPreservesIndependentFacts(t *testing.T) { func TestExtractMapsKindsOrdersOccurrencesAndPreservesIndependentFacts(t *testing.T) {
locations := locationRegistry(t, "The Tavern", "The Tavern") locations := locationRegistry(t, "The Tavern", "The Tavern")
first, second := locations.Locations[0], locations.Locations[1] first, second := locations.Locations[0], locations.Locations[1]
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{ client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
{LocationID: second.ID, Name: second.Name, Kind: "mentioned", SourceRefs: occurrenceRefs(30, 30)}, {Name: second.Name, RegistryRefs: registryRefs(second), Kind: "mentioned", SourceRefs: occurrenceRefs(30, 30)},
{LocationID: first.ID, Name: first.Name, Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)}, {Name: first.Name, RegistryRefs: registryRefs(first), Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)},
{LocationID: first.ID, Name: first.Name, Kind: "recalled", SourceRefs: occurrenceRefs(10, 10)}, {Name: first.Name, RegistryRefs: registryRefs(first), Kind: "recalled", SourceRefs: occurrenceRefs(10, 10)},
{LocationID: first.ID, Name: first.Name, Kind: "planned", SourceRefs: occurrenceRefs(10, 10)}, {Name: first.Name, RegistryRefs: registryRefs(first), Kind: "planned", SourceRefs: occurrenceRefs(10, 10)},
{LocationID: first.ID, Name: first.Name, Kind: "visited", SourceRefs: append(occurrenceRefs(10, 10), occurrenceRefs(10, 10)...)}, {Name: first.Name, RegistryRefs: registryRefs(first), Kind: "visited", SourceRefs: append(occurrenceRefs(10, 10), occurrenceRefs(10, 10)...)},
{LocationID: first.ID, Name: first.Name, Kind: "visited", SourceRefs: occurrenceRefs(20, 20)}, {Name: first.Name, RegistryRefs: registryRefs(first), Kind: "visited", SourceRefs: occurrenceRefs(20, 20)},
{LocationID: first.ID, Name: first.Name, Kind: "visited", SourceRefs: occurrenceRefs(10, 10)}, {Name: first.Name, RegistryRefs: registryRefs(first), Kind: "visited", SourceRefs: occurrenceRefs(10, 10)},
}}} }}}
references := registryReferences(t, locations) references := registryReferences(t, locations)
req := extractionRequest() req := extractionRequest()
@@ -50,11 +51,11 @@ func TestExtractMapsKindsOrdersOccurrencesAndPreservesIndependentFacts(t *testin
} }
} }
func TestExtractUsesIDsNamesAndCurrentTranscriptEvidenceOnly(t *testing.T) { func TestExtractResolvesContextualSelectorsAndUsesCurrentTranscriptEvidenceOnly(t *testing.T) {
locations := locationRegistry(t, "The Tavern", "The Tavern") locations := locationRegistry(t, "The Tavern", "The Tavern")
first, second := locations.Locations[0], locations.Locations[1] first, second := locations.Locations[0], locations.Locations[1]
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{ client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{
LocationID: second.ID, Name: second.Name, Kind: "visited", SourceRefs: occurrenceRefs(10, 10), Name: second.Name, RegistryRefs: registryRefs(second), Kind: "visited", SourceRefs: occurrenceRefs(10, 10),
}}}} }}}}
references := registryReferences(t, locations) references := registryReferences(t, locations)
req := extractionRequest() req := extractionRequest()
@@ -67,10 +68,10 @@ func TestExtractUsesIDsNamesAndCurrentTranscriptEvidenceOnly(t *testing.T) {
t.Fatalf("occurrence = %#v", occurrence) t.Fatalf("occurrence = %#v", occurrence)
} }
input := client.requests[0].Inputs[LocationRegistryReferenceSlot] input := client.requests[0].Inputs[LocationRegistryReferenceSlot]
if input.Name != LocationRegistryReferenceSlot || !strings.Contains(string(input.Content), first.ID) || !strings.Contains(string(input.Content), second.ID) { if input.Name != LocationRegistryReferenceSlot || !strings.Contains(string(input.Content), `"registry_refs":[{"start_unit_id":20,"end_unit_id":20}]`) {
t.Fatalf("location prompt input = %#v", input) t.Fatalf("location prompt input = %#v", input)
} }
for _, forbidden := range []string{"source_refs", "source_id", "other-session"} { for _, forbidden := range []string{"source_refs", "source_id", first.ID, second.ID} {
if strings.Contains(string(input.Content), forbidden) { if strings.Contains(string(input.Content), forbidden) {
t.Fatalf("location prompt leaked %q: %s", forbidden, input.Content) t.Fatalf("location prompt leaked %q: %s", forbidden, input.Content)
} }
@@ -84,22 +85,46 @@ func TestExtractUsesIDsNamesAndCurrentTranscriptEvidenceOnly(t *testing.T) {
} }
} }
func TestExtractPreservesUnknownOrMismatchedGroundingForValidators(t *testing.T) { func TestExtractRejectsUnknownMalformedOrMismatchedSelectorsAtomically(t *testing.T) {
locations := locationRegistry(t, "The Mill") locations := locationRegistry(t, "The Mill")
known := locations.Locations[0] known := locations.Locations[0]
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
{LocationID: "location:sha256:unknown", Name: "The Mill", Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)},
{LocationID: known.ID, Name: "A Different Mill", Kind: "mentioned", SourceRefs: occurrenceRefs(20, 20)},
}}}
references := registryReferences(t, locations) references := registryReferences(t, locations)
req := extractionRequest() req := extractionRequest()
req.References = references req.References = references
result, err := newExtractor(t, client, references).Extract(context.Background(), req) for _, occurrences := range [][]occurrenceResponse{
if err != nil { {{Name: "Unknown", RegistryRefs: []locationregistry.RegistryRef{}, Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)}},
t.Fatal(err) {{Name: known.Name, RegistryRefs: []locationregistry.RegistryRef{{StartUnitID: 10, EndUnitID: 0}}, Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)}},
{{Name: known.Name, RegistryRefs: []locationregistry.RegistryRef{{StartUnitID: 10, EndUnitID: 10}}, Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)}},
{{Name: known.Name, RegistryRefs: []locationregistry.RegistryRef{}, Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)}, {Name: "Unknown", RegistryRefs: []locationregistry.RegistryRef{}, Kind: "mentioned", SourceRefs: occurrenceRefs(20, 20)}},
} {
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: occurrences}}
if result, err := newExtractor(t, client, references).Extract(context.Background(), req); err == nil || result.Value.Occurrences != nil || !strings.Contains(err.Error(), "location selector") {
t.Fatalf("Extract() = %#v, %v", result, err)
}
} }
if result.Value.Occurrences[0].LocationID != "location:sha256:unknown" || result.Value.Occurrences[1].Name != "A Different Mill" {
t.Fatalf("extractor repaired validator-owned grounding errors: %#v", result.Value.Occurrences) sharedName := "The Tavern"
firstRefs := []source.SourceRef{{SourceID: req.Source.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: req.Source.ID, StartUnitID: 20, EndUnitID: 20}}
secondRefs := []source.SourceRef{{SourceID: req.Source.ID, StartUnitID: 30, EndUnitID: 30}}
duplicateLocations := dnd.LocationRegistry{Locations: []dnd.Location{
{ID: identity.DeriveID(sharedName, firstRefs), Name: sharedName, SourceRefs: firstRefs},
{ID: identity.DeriveID(sharedName, secondRefs), Name: sharedName, SourceRefs: secondRefs},
}}
duplicateReferences := registryReferences(t, duplicateLocations)
for _, selector := range []struct {
name string
refs []locationregistry.RegistryRef
}{
{name: sharedName, refs: []locationregistry.RegistryRef{}},
{name: sharedName, refs: []locationregistry.RegistryRef{{StartUnitID: 10, EndUnitID: 10}}},
{name: sharedName, refs: []locationregistry.RegistryRef{{StartUnitID: 20, EndUnitID: 20}, {StartUnitID: 10, EndUnitID: 10}}},
} {
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{Name: selector.name, RegistryRefs: selector.refs, Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)}}}}
duplicateRequest := extractionRequest()
duplicateRequest.References = duplicateReferences
if _, err := newExtractor(t, client, duplicateReferences).Extract(context.Background(), duplicateRequest); err == nil || !strings.Contains(err.Error(), "location selector") {
t.Fatalf("Extract(%#v) error = %v", selector, err)
}
} }
} }
@@ -125,7 +150,7 @@ func TestExtractResolvesGeneratedRegistryAtOperationTimeAndDoesNotMutateResponse
locations := locationRegistry(t, "The Mill") locations := locationRegistry(t, "The Mill")
location := locations.Locations[0] location := locations.Locations[0]
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{ client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{
LocationID: location.ID, Name: location.Name, Kind: "mentioned", SourceRefs: occurrenceRefs(30, 30), Name: location.Name, RegistryRefs: []locationregistry.RegistryRef{}, Kind: "mentioned", SourceRefs: occurrenceRefs(30, 30),
}}}} }}}}
references := registryReferences(t, locations) references := registryReferences(t, locations)
req := extractionRequest() req := extractionRequest()
@@ -135,7 +160,7 @@ func TestExtractResolvesGeneratedRegistryAtOperationTimeAndDoesNotMutateResponse
if err != nil || result.Value.Occurrences[0].Name != "The Mill" { if err != nil || result.Value.Occurrences[0].Name != "The Mill" {
t.Fatalf("Extract() = %#v, %v", result, err) t.Fatalf("Extract() = %#v, %v", result, err)
} }
if input := client.requests[0].Inputs[LocationRegistryReferenceSlot]; !strings.Contains(string(input.Content), location.ID) || input.OriginURI != "" { if input := client.requests[0].Inputs[LocationRegistryReferenceSlot]; strings.Contains(string(input.Content), location.ID) || input.OriginURI != "" {
t.Fatalf("generated registry prompt input = %#v", input) t.Fatalf("generated registry prompt input = %#v", input)
} }
if _, ok := extractor.ManifestMetadata()["location_registry_digest"]; ok { if _, ok := extractor.ManifestMetadata()["location_registry_digest"]; ok {
@@ -215,21 +240,38 @@ func TestExtractorContractsMetadataAndFailures(t *testing.T) {
t.Fatalf("metadata[%q] = %#v", key, metadata[key]) t.Fatalf("metadata[%q] = %#v", key, metadata[key])
} }
} }
if got := newExtractor(t, &fakeOccurrencesLLMClient{}, references).CheckpointFingerprints(); len(got) != 4 || got[3].Name != "location_registry" { if got := newExtractor(t, &fakeOccurrencesLLMClient{}, references).CheckpointFingerprints(); len(got) != 4 || got[3].Name != "location_registry" || got[3].Value != locationRegistryIdentityDigest(t, references) {
t.Fatalf("fingerprints = %#v", got) t.Fatalf("fingerprints = %#v", got)
} }
} }
func locationRegistryIdentityDigest(t *testing.T, references contracts.ReferenceSet) string {
t.Helper()
resolver, err := locationregistry.NewResolver(references)
if err != nil {
t.Fatal(err)
}
return resolver.Seeded().IdentityDigest()
}
func locationRegistry(t *testing.T, names ...string) dnd.LocationRegistry { func locationRegistry(t *testing.T, names ...string) dnd.LocationRegistry {
t.Helper() t.Helper()
locations := make([]dnd.Location, len(names)) locations := make([]dnd.Location, len(names))
for index, name := range names { for index, name := range names {
refs := []source.SourceRef{{SourceID: "other-session", StartUnitID: index + 1, EndUnitID: index + 1}} refs := []source.SourceRef{{SourceID: sourceDocument().ID, StartUnitID: (index + 1) * 10, EndUnitID: (index + 1) * 10}}
locations[index] = dnd.Location{ID: identity.DeriveID(name, refs), Name: name, SourceRefs: refs} locations[index] = dnd.Location{ID: identity.DeriveID(name, refs), Name: name, SourceRefs: refs}
} }
return dnd.LocationRegistry{Locations: locations} return dnd.LocationRegistry{Locations: locations}
} }
func registryRefs(location dnd.Location) []locationregistry.RegistryRef {
refs := make([]locationregistry.RegistryRef, len(location.SourceRefs))
for index, ref := range location.SourceRefs {
refs[index] = locationregistry.RegistryRef{StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}
}
return refs
}
func registryReferences(t *testing.T, locations dnd.LocationRegistry) contracts.ReferenceSet { func registryReferences(t *testing.T, locations dnd.LocationRegistry) contracts.ReferenceSet {
t.Helper() t.Helper()
content, err := locationcodec.New().Encode(locations) content, err := locationcodec.New().Encode(locations)

View File

@@ -1,14 +1,16 @@
package locationoccurrences package locationoccurrences
import locationregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/registry"
type extractionResponse struct { type extractionResponse struct {
Occurrences []occurrenceResponse `json:"occurrences"` Occurrences []occurrenceResponse `json:"occurrences"`
} }
type occurrenceResponse struct { type occurrenceResponse struct {
LocationID string `json:"location_id"` Name string `json:"name"`
Name string `json:"name"` RegistryRefs []locationregistry.RegistryRef `json:"registry_refs"`
Kind string `json:"kind"` Kind string `json:"kind"`
SourceRefs []occurrenceSourceRefResponse `json:"source_refs"` SourceRefs []occurrenceSourceRefResponse `json:"source_refs"`
} }
type occurrenceSourceRefResponse struct { type occurrenceSourceRefResponse struct {

View File

@@ -30,7 +30,7 @@ func TestRegisterPromptAssetsPreparesLocationOccurrencePrompt(t *testing.T) {
PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: "location-occurrences-test", PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: "location-occurrences-test",
Inputs: map[string]promptkit.ArtifactRef{ Inputs: map[string]promptkit.ArtifactRef{
"transcript": promptkit.Inline(`{"units":[{"sentinel":"location-occurrence-transcript"}]}`), "players": promptkit.Inline("location-occurrence-player"), "party": promptkit.Inline(" "), "glossary": promptkit.Inline(" "), "transcript": promptkit.Inline(`{"units":[{"sentinel":"location-occurrence-transcript"}]}`), "players": promptkit.Inline("location-occurrence-player"), "party": promptkit.Inline(" "), "glossary": promptkit.Inline(" "),
"location_registry": promptkit.Inline(`{"locations":[{"id":"location:sha256:test","name":"location-occurrence-registry"}]}`), "location_registry": promptkit.Inline(`{"locations":[{"name":"location-occurrence-registry","registry_refs":[{"start_unit_id":7,"end_unit_id":7}],"context":[{"unit_id":7,"text":"registry context"}]}]}`),
}, },
}) })
if err != nil { if err != nil {
@@ -41,11 +41,11 @@ func TestRegisterPromptAssetsPreparesLocationOccurrencePrompt(t *testing.T) {
} }
var registryMessage string var registryMessage string
for _, message := range prepared.Messages { for _, message := range prepared.Messages {
if strings.Contains(message.Content, "normalized location registry") { if strings.Contains(message.Content, "contextual location registry") {
registryMessage = message.Content registryMessage = message.Content
} }
} }
if !strings.Contains(registryMessage, "location:sha256:test") || !strings.Contains(registryMessage, "location-occurrence-registry") || strings.Contains(registryMessage, "source_refs") { if !strings.Contains(registryMessage, "location-occurrence-registry") || !strings.Contains(registryMessage, `"registry_refs":[{"start_unit_id":7,"end_unit_id":7}]`) || strings.Contains(registryMessage, "source_id") || strings.Contains(registryMessage, "location:sha256") {
t.Fatalf("rendered prompt did not preserve source-free registry grounding: %s", registryMessage) t.Fatalf("rendered prompt did not preserve source-free registry grounding: %s", registryMessage)
} }
content := make([]string, len(prepared.Messages)) content := make([]string, len(prepared.Messages))
@@ -54,7 +54,7 @@ func TestRegisterPromptAssetsPreparesLocationOccurrencePrompt(t *testing.T) {
} }
rendered := strings.Join(content, "\n") rendered := strings.Join(content, "\n")
policy := strings.ReplaceAll(rendered, "\n", " ") policy := strings.ReplaceAll(rendered, "\n", " ")
if !strings.Contains(policy, "context supports that coreference") || !strings.Contains(policy, "must not create a registry location") || !strings.Contains(policy, "provenance must never replace current-chunk evidence") { if !strings.Contains(policy, "context supports that coreference") || !strings.Contains(policy, "must not create a registry location") || !strings.Contains(policy, "provenance must never replace current-chunk evidence") || !strings.Contains(policy, "complete, ordered") {
t.Fatalf("rendered prompt = %q, want contextual coreference without registry-derived evidence", rendered) t.Fatalf("rendered prompt = %q, want contextual coreference without registry-derived evidence", rendered)
} }
} }

View File

@@ -18,16 +18,17 @@ func TestResponseSchemaRestrictsPrivateOccurrenceStructureAndKinds(t *testing.T)
t.Fatalf("schema = %#v", schema) t.Fatalf("schema = %#v", schema)
} }
valid := map[string]any{"occurrences": []any{map[string]any{ valid := map[string]any{"occurrences": []any{map[string]any{
"location_id": "location:sha256:test", "name": "The Mill", "kind": "visited", "name": "The Mill", "registry_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}}, "kind": "visited",
"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}},
}}} }}}
if err := validateSchema(t, valid, schema.JSONSchema); err != nil { if err := validateSchema(t, valid, schema.JSONSchema); err != nil {
t.Fatalf("valid response rejected: %v", err) t.Fatalf("valid response rejected: %v", err)
} }
for _, mutate := range []func(map[string]any){ for _, mutate := range []func(map[string]any){
func(value map[string]any) { delete(value, "location_id") }, func(value map[string]any) { delete(value, "registry_refs") },
func(value map[string]any) { value["kind"] = "other" }, func(value map[string]any) { value["kind"] = "other" },
func(value map[string]any) { value["unexpected"] = true }, func(value map[string]any) { value["unexpected"] = true },
func(value map[string]any) { value["registry_refs"].([]any)[0].(map[string]any)["start_unit_id"] = 0 },
func(value map[string]any) { func(value map[string]any) {
value["source_refs"].([]any)[0].(map[string]any)["source_id"] = "assigned later" value["source_refs"].([]any)[0].(map[string]any)["source_id"] = "assigned later"
}, },

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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 }}}
}{ req := extractionRequest()
{"unknown ID", occurrenceResponse{NPCID: "npc:unknown", Name: "Mira Thorn", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)}, "npc_id is not in the NPC registry"}, req.References = references
{"mismatched name", occurrenceResponse{NPCID: identity.DeriveID("Mira Thorn"), Name: "Hooded Guard", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)}, "name does not match npc_id"}, result, err := newExtractor(t, client, references).Extract(context.Background(), req)
} { if err == nil || !strings.Contains(err.Error(), "name is not in the NPC registry") {
t.Run(test.name, func(t *testing.T) { t.Fatalf("Extract() error = %v, want unknown name failure", err)
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{test.occurrence}}} }
req := extractionRequest() if len(result.Value.Occurrences) != 0 {
req.References = references t.Fatalf("Extract() returned partial result = %#v", result.Value)
if _, err := newExtractor(t, client, references).Extract(context.Background(), req); err == nil || !strings.Contains(err.Error(), test.want) { }
t.Fatalf("Extract() error = %v, want %q", err, test.want) }
}
}) 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()

View File

@@ -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"`

View File

@@ -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)
} }
} }

View File

@@ -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 {

View File

@@ -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}},
}}} }}}
} }

View File

@@ -185,7 +185,7 @@ func (r *Registry) Digest() string {
return r.digest return r.digest
} }
// ProjectionDigest returns the SHA-256 digest of the exact ID/name prompt // ProjectionDigest returns the SHA-256 digest of the exact names-only prompt
// projection, including for an unbound or empty registry. // projection, including for an unbound or empty registry.
func (r *Registry) ProjectionDigest() string { func (r *Registry) ProjectionDigest() string {
if r == nil { if r == nil {
@@ -202,8 +202,8 @@ func (r *Registry) Count() int {
return len(r.list.Items) return len(r.list.Items)
} }
// PromptInput returns the ordered ID/name registry projection as a content-safe // PromptInput returns the ordered names-only registry projection as a
// prompt input. Evidence and reference provenance are omitted. // content-safe prompt input. Durable IDs, evidence, and provenance are omitted.
func (r *Registry) PromptInput() contracts.LLMInputMaterial { func (r *Registry) PromptInput() contracts.LLMInputMaterial {
if r == nil { if r == nil {
return contracts.LLMInputMaterial{} return contracts.LLMInputMaterial{}
@@ -242,7 +242,6 @@ func semanticDigest(content []byte) string {
} }
type projectedItem struct { type projectedItem struct {
ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
} }
@@ -253,7 +252,7 @@ type projectedItemRegistry struct {
func promptProjection(list dnd.ItemRegistry) ([]byte, error) { func promptProjection(list dnd.ItemRegistry) ([]byte, error) {
projection := projectedItemRegistry{Items: make([]projectedItem, len(list.Items))} projection := projectedItemRegistry{Items: make([]projectedItem, len(list.Items))}
for index, item := range list.Items { for index, item := range list.Items {
projection.Items[index] = projectedItem{ID: item.ID, Name: item.Name} projection.Items[index] = projectedItem{Name: item.Name}
} }
return json.Marshal(projection) return json.Marshal(projection)
} }

View File

@@ -25,16 +25,16 @@ func TestResolveUnboundRegistryHasExactEmptyProjection(t *testing.T) {
} }
} }
func TestResolveProjectsOrderedIDsAndNamesWithoutEvidence(t *testing.T) { func TestResolveProjectsOrderedNamesWithoutEvidence(t *testing.T) {
registry := resolveRegistry(t, fixture()) registry := resolveRegistry(t, fixture())
if !registry.Bound() || registry.Digest() == "" || registry.Count() != 2 { if !registry.Bound() || registry.Digest() == "" || registry.Count() != 2 {
t.Fatalf("registry identity = bound %t digest %q count %d", registry.Bound(), registry.Digest(), registry.Count()) t.Fatalf("registry identity = bound %t digest %q count %d", registry.Bound(), registry.Digest(), registry.Count())
} }
want := `{"items":[{"id":"` + identity.DeriveID("Silver Key") + `","name":"Silver Key"},{"id":"` + identity.DeriveID("Healer's Kit") + `","name":"Healer's Kit"}]}` want := `{"items":[{"name":"Silver Key"},{"name":"Healer's Kit"}]}`
if got := string(registry.PromptInput().Content); got != want { if got := string(registry.PromptInput().Content); got != want {
t.Fatalf("prompt projection = %s, want %s", got, want) t.Fatalf("prompt projection = %s, want %s", got, want)
} }
for _, forbidden := range []string{"source_refs", "source_id", "session-alpha"} { for _, forbidden := range []string{"item:sha256:", "source_refs", "source_id", "session-alpha"} {
if strings.Contains(string(registry.PromptInput().Content), forbidden) { if strings.Contains(string(registry.PromptInput().Content), forbidden) {
t.Fatalf("projection leaked %q: %s", forbidden, registry.PromptInput().Content) t.Fatalf("projection leaked %q: %s", forbidden, registry.PromptInput().Content)
} }
@@ -52,6 +52,9 @@ func TestRegistryAccessorsAndLookupsAreDefensive(t *testing.T) {
if item, ok := registry.LookupID(identity.DeriveID("Healer's Kit")); !ok || item.Name != "Healer's Kit" { if item, ok := registry.LookupID(identity.DeriveID("Healer's Kit")); !ok || item.Name != "Healer's Kit" {
t.Fatalf("LookupID() = %#v, %t", item, ok) t.Fatalf("LookupID() = %#v, %t", item, ok)
} }
if _, ok := registry.Lookup("Unknown Item"); ok {
t.Fatal("Lookup() accepted an unknown item")
}
items := registry.Items() items := registry.Items()
items[0].Name = "changed" items[0].Name = "changed"

View File

@@ -0,0 +1,187 @@
package registry
import (
"encoding/json"
"fmt"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
locationcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/locationregistry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
// Selector identifies one location using its canonical name and, when needed,
// source-free registry ranges.
type Selector struct {
Name string `json:"name"`
RegistryRefs []RegistryRef `json:"registry_refs"`
}
// RegistryRef is a source-free range used only for location identity grounding.
type RegistryRef struct {
StartUnitID int `json:"start_unit_id"`
EndUnitID int `json:"end_unit_id"`
}
// ContextUnit supplies the bounded transcript context for an ambiguous
// location's registry ranges.
type ContextUnit struct {
UnitID int `json:"unit_id"`
Text string `json:"text"`
}
// Grounding is an immutable, operation-scoped location selector projection.
// Its prompt input contains no durable IDs or source IDs.
type Grounding struct {
promptInput contracts.LLMInputMaterial
locationsBySelector map[string]dnd.Location
}
type groundingProjection struct {
Locations []groundingProjectionLocation `json:"locations"`
}
type groundingProjectionLocation struct {
Name string `json:"name"`
RegistryRefs []RegistryRef `json:"registry_refs"`
Context []ContextUnit `json:"context"`
}
// NewGrounding creates the contextual location projection for one source
// document and resolved registry.
func NewGrounding(registry *Registry, doc *source.SourceDocument) (*Grounding, error) {
if registry == nil {
return nil, fmt.Errorf("location registry is required")
}
if doc == nil {
return nil, fmt.Errorf("source document is required")
}
index := source.NewDocumentIndex(doc)
order := shared.NewSourceRefOrderFromIndex(index)
locations := registry.Locations()
grouped := make(map[string][]int, len(locations))
for locationIndex, location := range locations {
key := identity.ComparisonKey(location.Name)
if key == "" {
return nil, fmt.Errorf("location registry record %d has an empty comparison name", locationIndex)
}
grouped[key] = append(grouped[key], locationIndex)
}
projection := groundingProjection{Locations: make([]groundingProjectionLocation, len(locations))}
locationsBySelector := make(map[string]dnd.Location, len(locations))
for locationIndex, location := range locations {
key := identity.ComparisonKey(location.Name)
registryRefs := make([]RegistryRef, 0)
context := make([]ContextUnit, 0)
if len(grouped[key]) > 1 {
var err error
registryRefs, context, err = contextualRanges(locationIndex, location.SourceRefs, doc, index, order)
if err != nil {
return nil, err
}
}
projection.Locations[locationIndex] = groundingProjectionLocation{
Name: location.Name,
RegistryRefs: cloneRegistryRefs(registryRefs),
Context: cloneContextUnits(context),
}
selectorKey := contextualSelectorKey(key, registryRefs)
if _, exists := locationsBySelector[selectorKey]; exists {
return nil, fmt.Errorf("location registry records produce the same contextual selector")
}
locationsBySelector[selectorKey] = cloneLocation(location)
}
content, err := json.Marshal(projection)
if err != nil {
return nil, fmt.Errorf("encode location grounding projection: %w", err)
}
digest := semanticDigest(content)
return &Grounding{
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, locationcodec.MediaType, content, digest, ""),
locationsBySelector: locationsBySelector,
}, nil
}
// PromptInput returns a defensive copy of the contextual projection for the
// location_registry prompt slot.
func (g *Grounding) PromptInput() contracts.LLMInputMaterial {
if g == nil {
return contracts.LLMInputMaterial{}
}
return g.promptInput.Clone()
}
// Resolve maps a contextual selector to one canonical location.
func (g *Grounding) Resolve(selector Selector) (dnd.Location, bool) {
if g == nil {
return dnd.Location{}, false
}
key := identity.ComparisonKey(selector.Name)
if key == "" {
return dnd.Location{}, false
}
location, ok := g.locationsBySelector[contextualSelectorKey(key, selector.RegistryRefs)]
if !ok {
return dnd.Location{}, false
}
return cloneLocation(location), true
}
func contextualRanges(locationIndex int, refs []source.SourceRef, doc *source.SourceDocument, index source.DocumentIndex, order shared.SourceRefOrder) ([]RegistryRef, []ContextUnit, error) {
for _, ref := range refs {
if ref.SourceID != doc.ID || index.ValidateRef(ref) != nil {
return nil, nil, fmt.Errorf("location registry record %d has an invalid source reference", locationIndex)
}
}
canonical := order.Canonicalize(refs)
ranges := make([]RegistryRef, len(canonical))
for refIndex, ref := range canonical {
ranges[refIndex] = RegistryRef{StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}
}
return ranges, contextUnits(doc, index, canonical), nil
}
func contextUnits(doc *source.SourceDocument, index source.DocumentIndex, refs []source.SourceRef) []ContextUnit {
units := make([]ContextUnit, 0)
seen := make(map[int]struct{})
for _, ref := range refs {
start, _ := index.Position(ref.StartUnitID)
end, _ := index.Position(ref.EndUnitID)
for position := start; position <= end; position++ {
unit := doc.Units[position]
if _, exists := seen[unit.ID]; exists {
continue
}
seen[unit.ID] = struct{}{}
units = append(units, ContextUnit{UnitID: unit.ID, Text: unit.Text})
}
}
return units
}
func contextualSelectorKey(name string, refs []RegistryRef) string {
key, _ := json.Marshal(struct {
Name string `json:"name"`
RegistryRefs []RegistryRef `json:"registry_refs"`
}{Name: name, RegistryRefs: cloneRegistryRefs(refs)})
return string(key)
}
func cloneRegistryRefs(values []RegistryRef) []RegistryRef {
if len(values) == 0 {
return []RegistryRef{}
}
return append([]RegistryRef(nil), values...)
}
func cloneContextUnits(values []ContextUnit) []ContextUnit {
if len(values) == 0 {
return []ContextUnit{}
}
return append([]ContextUnit(nil), values...)
}

View File

@@ -18,21 +18,20 @@ import (
) )
const ( const (
ReferenceSlot = "location_registry" ReferenceSlot = "location_registry"
MaxBytes = 1048576 MaxBytes = 1048576
emptyPrompt = `{"locations":[]}` emptyRegistryContent = `{"locations":[]}`
) )
// Registry is an immutable, validated location registry prepared for prompt // Registry is an immutable, validated location registry prepared for prompt
// grounding. All accessors return defensive copies. // grounding. All accessors return defensive copies.
type Registry struct { type Registry struct {
bound bool bound bool
list dnd.LocationRegistry list dnd.LocationRegistry
canonical []byte canonical []byte
digest string digest string
projectionDigest string identityDigest string
promptInput contracts.LLMInputMaterial lookupByID map[string]int
lookupByID map[string]int
} }
// Resolver selects and memoizes immutable location registry views. // Resolver selects and memoizes immutable location registry views.
@@ -99,14 +98,13 @@ func locationReferenceSpec() registryresolver.ReferenceSpec {
} }
func emptyRegistry() *Registry { func emptyRegistry() *Registry {
content := []byte(emptyPrompt) content := []byte(emptyRegistryContent)
projectionDigest := semanticDigest(content) identityDigest := semanticDigest(content)
return &Registry{ return &Registry{
list: dnd.LocationRegistry{Locations: []dnd.Location{}}, list: dnd.LocationRegistry{Locations: []dnd.Location{}},
canonical: append([]byte(nil), content...), canonical: append([]byte(nil), content...),
projectionDigest: projectionDigest, identityDigest: identityDigest,
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, locationcodec.MediaType, content, projectionDigest, ""), lookupByID: map[string]int{},
lookupByID: map[string]int{},
} }
} }
@@ -129,20 +127,19 @@ func loadRegistry(referenceContent []byte) (*Registry, error) {
for index, location := range list.Locations { for index, location := range list.Locations {
lookupByID[location.ID] = index lookupByID[location.ID] = index
} }
projection, err := promptProjection(list) projection, err := identityProjection(list)
if err != nil { if err != nil {
return nil, fmt.Errorf("encode location prompt projection: %w", err) return nil, fmt.Errorf("encode location prompt projection: %w", err)
} }
digest := semanticDigest(content) digest := semanticDigest(content)
projectionDigest := semanticDigest(projection) identityDigest := semanticDigest(projection)
return &Registry{ return &Registry{
bound: true, bound: true,
list: list, list: list,
canonical: append([]byte(nil), content...), canonical: append([]byte(nil), content...),
digest: digest, digest: digest,
projectionDigest: projectionDigest, identityDigest: identityDigest,
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, locationcodec.MediaType, projection, projectionDigest, ""), lookupByID: lookupByID,
lookupByID: lookupByID,
}, nil }, nil
} }
@@ -182,13 +179,13 @@ func (r *Registry) Digest() string {
return r.digest return r.digest
} }
// ProjectionDigest returns the digest of the exact source-free prompt // IdentityDigest returns the digest of the ordered ID/name identity projection
// projection, including for an unbound or empty registry. // used by deterministic consumers.
func (r *Registry) ProjectionDigest() string { func (r *Registry) IdentityDigest() string {
if r == nil { if r == nil {
return "" return ""
} }
return r.projectionDigest return r.identityDigest
} }
// Count returns the number of validated location records. // Count returns the number of validated location records.
@@ -199,15 +196,6 @@ func (r *Registry) Count() int {
return len(r.list.Locations) return len(r.list.Locations)
} }
// PromptInput returns the ordered ID-and-name projection without evidence or
// reference provenance.
func (r *Registry) PromptInput() contracts.LLMInputMaterial {
if r == nil {
return contracts.LLMInputMaterial{}
}
return r.promptInput.Clone()
}
// Lookup returns the canonical location for an exact durable location ID. // Lookup returns the canonical location for an exact durable location ID.
func (r *Registry) Lookup(id string) (dnd.Location, bool) { func (r *Registry) Lookup(id string) (dnd.Location, bool) {
if r == nil { if r == nil {
@@ -231,19 +219,19 @@ func semanticDigest(content []byte) string {
return "sha256:" + hex.EncodeToString(sum[:]) return "sha256:" + hex.EncodeToString(sum[:])
} }
type projectedLocation struct { type identityProjectedLocation struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
} }
type projectedLocationRegistry struct { type identityProjectedLocationRegistry struct {
Locations []projectedLocation `json:"locations"` Locations []identityProjectedLocation `json:"locations"`
} }
func promptProjection(list dnd.LocationRegistry) ([]byte, error) { func identityProjection(list dnd.LocationRegistry) ([]byte, error) {
projection := projectedLocationRegistry{Locations: make([]projectedLocation, len(list.Locations))} projection := identityProjectedLocationRegistry{Locations: make([]identityProjectedLocation, len(list.Locations))}
for index, location := range list.Locations { for index, location := range list.Locations {
projection.Locations[index] = projectedLocation{ID: location.ID, Name: location.Name} projection.Locations[index] = identityProjectedLocation{ID: location.ID, Name: location.Name}
} }
return json.Marshal(projection) return json.Marshal(projection)
} }

View File

@@ -2,6 +2,8 @@ package registry
import ( import (
"bytes" "bytes"
"encoding/json"
"reflect"
"strings" "strings"
"testing" "testing"
@@ -12,36 +14,26 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity"
) )
func TestResolveUnboundRegistryHasEmptyProjection(t *testing.T) { func TestResolveUnboundRegistryHasEmptyIdentity(t *testing.T) {
registry, err := Resolve(contracts.ReferenceSet{}) registry, err := Resolve(contracts.ReferenceSet{})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
input := registry.PromptInput() if registry.Bound() || registry.Digest() != "" || registry.Count() != 0 || string(registry.CanonicalBytes()) != emptyRegistryContent {
if registry.Bound() || registry.Digest() != "" || registry.Count() != 0 || string(input.Content) != emptyPrompt { t.Fatalf("unbound registry = %#v", registry)
t.Fatalf("unbound registry = %#v, input = %#v", registry, input)
} }
if registry.ProjectionDigest() == "" || input.Digest != registry.ProjectionDigest() || input.OriginURI != "" { if registry.IdentityDigest() == "" {
t.Fatalf("projection digest/input = %q/%#v", registry.ProjectionDigest(), input) t.Fatal("unbound registry identity digest is empty")
} }
} }
func TestResolveProjectsOrderedLocationsWithoutEvidence(t *testing.T) { func TestResolveRetainsSeparateDurableAndIdentityDigests(t *testing.T) {
registry := resolveList(t, registryFixture()) registry := resolveList(t, registryFixture())
if !registry.Bound() || registry.Count() != 2 || registry.Digest() == "" { if !registry.Bound() || registry.Count() != 2 || registry.Digest() == "" {
t.Fatalf("registry identity = bound %t count %d digest %q", registry.Bound(), registry.Count(), registry.Digest()) t.Fatalf("registry identity = bound %t count %d digest %q", registry.Bound(), registry.Count(), registry.Digest())
} }
projection := string(registry.PromptInput().Content) if registry.IdentityDigest() == "" || registry.Digest() == registry.IdentityDigest() {
if !strings.Contains(projection, `"locations":[{"id":`) || !strings.Contains(projection, `"name":"The Tavern"`) || !strings.Contains(projection, `"name":"The Tavern"},{"id":`) { t.Fatalf("full/identity digests = %q/%q", registry.Digest(), registry.IdentityDigest())
t.Fatalf("projection ordering = %s", projection)
}
for _, forbidden := range []string{"source_refs", "source_id", "session-alpha"} {
if strings.Contains(projection, forbidden) {
t.Fatalf("projection leaked %q: %s", forbidden, projection)
}
}
if registry.PromptInput().Digest != registry.ProjectionDigest() || registry.Digest() == registry.ProjectionDigest() {
t.Fatalf("full/projection digests = %q/%q", registry.Digest(), registry.ProjectionDigest())
} }
} }
@@ -60,12 +52,10 @@ func TestRegistryLookupUsesIDAndReturnsDefensiveCopies(t *testing.T) {
locations[0].SourceRefs[0].SourceID = "changed" locations[0].SourceRefs[0].SourceID = "changed"
canonical := registry.CanonicalBytes() canonical := registry.CanonicalBytes()
canonical[0] = '[' canonical[0] = '['
input := registry.PromptInput()
input.Content[0] = '['
if next, ok := registry.Lookup(first.ID); !ok || next.Name != first.Name || next.SourceRefs[0].SourceID != "session-alpha" { if next, ok := registry.Lookup(first.ID); !ok || next.Name != first.Name || next.SourceRefs[0].SourceID != "session-alpha" {
t.Fatalf("registry mutated through accessor: %#v, %t", next, ok) t.Fatalf("registry mutated through accessor: %#v, %t", next, ok)
} }
if registry.CanonicalBytes()[0] != '{' || registry.PromptInput().Content[0] != '{' { if registry.CanonicalBytes()[0] != '{' {
t.Fatal("registry bytes mutated through accessor") t.Fatal("registry bytes mutated through accessor")
} }
} }
@@ -189,3 +179,180 @@ func item(content []byte) contracts.ReferenceItem {
func referenceSet(items ...contracts.ReferenceItem) contracts.ReferenceSet { func referenceSet(items ...contracts.ReferenceItem) contracts.ReferenceSet {
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ReferenceSlot: {Items: items}}} return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ReferenceSlot: {Items: items}}}
} }
func TestGroundingProjectsAndResolvesUniqueAndSameNameLocations(t *testing.T) {
doc := groundingDocument()
sharedName := "The Tavern"
firstRefs := []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}
secondRefs := []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}
marketRefs := []source.SourceRef{{SourceID: doc.ID, StartUnitID: 40, EndUnitID: 40}}
list := dnd.LocationRegistry{Locations: []dnd.Location{
location(sharedName, firstRefs),
location(sharedName, secondRefs),
location("Market", marketRefs),
}}
registry := resolveList(t, list)
grounding, err := NewGrounding(registry, doc)
if err != nil {
t.Fatal(err)
}
projection := decodeGroundingProjection(t, grounding.PromptInput().Content)
if len(projection.Locations) != 3 {
t.Fatalf("projection = %#v", projection)
}
first := projection.Locations[0]
if first.Name != sharedName || !reflect.DeepEqual(first.RegistryRefs, []RegistryRef{{StartUnitID: 30, EndUnitID: 30}, {StartUnitID: 20, EndUnitID: 20}}) || !reflect.DeepEqual(first.Context, []ContextUnit{{UnitID: 30, Text: "Tavern entrance"}, {UnitID: 20, Text: "Tavern cellar"}}) {
t.Fatalf("first contextual projection = %#v", first)
}
second := projection.Locations[1]
if !reflect.DeepEqual(second.RegistryRefs, []RegistryRef{{StartUnitID: 10, EndUnitID: 10}}) || !reflect.DeepEqual(second.Context, []ContextUnit{{UnitID: 10, Text: "Tavern common room"}}) {
t.Fatalf("second contextual projection = %#v", second)
}
market := projection.Locations[2]
if market.Name != "Market" || len(market.RegistryRefs) != 0 || len(market.Context) != 0 {
t.Fatalf("unique-name projection = %#v", market)
}
if bytes.Contains(grounding.PromptInput().Content, []byte("location:sha256:")) || bytes.Contains(grounding.PromptInput().Content, []byte(`"source_id"`)) {
t.Fatalf("grounding projection exposed durable identity: %s", grounding.PromptInput().Content)
}
if grounding.PromptInput().Digest == "" || grounding.PromptInput().Digest == registry.IdentityDigest() {
t.Fatalf("grounding/identity digests = %q/%q", grounding.PromptInput().Digest, registry.IdentityDigest())
}
resolved, ok := grounding.Resolve(Selector{Name: " the tavern ", RegistryRefs: []RegistryRef{{StartUnitID: 30, EndUnitID: 30}, {StartUnitID: 20, EndUnitID: 20}}})
if !ok || resolved.ID != list.Locations[0].ID || resolved.Name != sharedName {
t.Fatalf("Resolve(same name) = %#v, %t", resolved, ok)
}
if resolved, ok = grounding.Resolve(Selector{Name: "MARKET", RegistryRefs: []RegistryRef{}}); !ok || resolved.ID != list.Locations[2].ID {
t.Fatalf("Resolve(unique name) = %#v, %t", resolved, ok)
}
for _, selector := range []Selector{
{Name: "Market", RegistryRefs: []RegistryRef{{StartUnitID: 40, EndUnitID: 40}}},
{Name: sharedName, RegistryRefs: []RegistryRef{}},
{Name: sharedName, RegistryRefs: []RegistryRef{{StartUnitID: 30, EndUnitID: 30}}},
{Name: sharedName, RegistryRefs: []RegistryRef{{StartUnitID: 20, EndUnitID: 20}, {StartUnitID: 30, EndUnitID: 30}}},
{Name: "Unknown", RegistryRefs: []RegistryRef{}},
} {
if _, ok := grounding.Resolve(selector); ok {
t.Fatalf("Resolve(%#v) accepted an unsupported selector", selector)
}
}
input := grounding.PromptInput()
input.Content[0] = '['
resolved.SourceRefs[0].SourceID = "changed"
if grounding.PromptInput().Content[0] != '{' {
t.Fatal("grounding prompt input was mutable")
}
if next, ok := grounding.Resolve(Selector{Name: sharedName, RegistryRefs: []RegistryRef{{StartUnitID: 30, EndUnitID: 30}, {StartUnitID: 20, EndUnitID: 20}}}); !ok || next.SourceRefs[0].SourceID != doc.ID {
t.Fatalf("grounding resolved location was mutable: %#v, %t", next, ok)
}
}
func TestGroundingHandlesEmptyRegistriesAndIdentityFingerprints(t *testing.T) {
doc := groundingDocument()
empty := resolveList(t, dnd.LocationRegistry{Locations: []dnd.Location{}})
grounding, err := NewGrounding(empty, doc)
if err != nil {
t.Fatal(err)
}
if projection := decodeGroundingProjection(t, grounding.PromptInput().Content); projection.Locations == nil || len(projection.Locations) != 0 {
t.Fatalf("empty projection = %#v", projection)
}
if _, ok := grounding.Resolve(Selector{Name: "Market", RegistryRefs: []RegistryRef{}}); ok {
t.Fatal("empty grounding resolved a location")
}
baseRefs := []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}
base := resolveList(t, dnd.LocationRegistry{Locations: []dnd.Location{location("Market", baseRefs)}})
expandedRefs := append(append([]source.SourceRef(nil), baseRefs...), source.SourceRef{SourceID: doc.ID, StartUnitID: 40, EndUnitID: 40})
expanded := resolveList(t, dnd.LocationRegistry{Locations: []dnd.Location{location("Market", expandedRefs)}})
baseGrounding, err := NewGrounding(base, doc)
if err != nil {
t.Fatal(err)
}
expandedGrounding, err := NewGrounding(expanded, doc)
if err != nil {
t.Fatal(err)
}
if base.IdentityDigest() != expanded.IdentityDigest() || baseGrounding.PromptInput().Digest != expandedGrounding.PromptInput().Digest {
t.Fatalf("identity/model fingerprints = %q/%q and %q/%q", base.IdentityDigest(), expanded.IdentityDigest(), baseGrounding.PromptInput().Digest, expandedGrounding.PromptInput().Digest)
}
}
func TestGroundingRejectsUnsafeContextualConstruction(t *testing.T) {
doc := groundingDocument()
validRefs := []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}
valid := resolveList(t, dnd.LocationRegistry{Locations: []dnd.Location{location("Market", validRefs)}})
if _, err := NewGrounding(valid, nil); err == nil {
t.Fatal("NewGrounding(nil source) error = nil")
}
for _, test := range []struct {
name string
registry *Registry
}{
{
name: "foreign reference",
registry: resolveList(t, dnd.LocationRegistry{Locations: []dnd.Location{
location("The Tavern", []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}),
location("The Tavern", []source.SourceRef{{SourceID: "other-source", StartUnitID: 10, EndUnitID: 10}}),
}}),
},
{
name: "invalid reference",
registry: resolveList(t, dnd.LocationRegistry{Locations: []dnd.Location{
location("The Tavern", []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}),
location("The Tavern", []source.SourceRef{{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999}}),
}}),
},
{
name: "selector collision",
registry: &Registry{list: dnd.LocationRegistry{Locations: []dnd.Location{
{ID: "first", Name: "The Tavern", SourceRefs: validRefs},
{ID: "second", Name: "The Tavern", SourceRefs: validRefs},
}}},
},
{
name: "empty comparison name",
registry: &Registry{list: dnd.LocationRegistry{Locations: []dnd.Location{{ID: "first", Name: " ", SourceRefs: validRefs}}}},
},
} {
t.Run(test.name, func(t *testing.T) {
if _, err := NewGrounding(test.registry, doc); err == nil || strings.Contains(err.Error(), "Tavern entrance") {
t.Fatalf("NewGrounding() error = %v, want content-safe failure", err)
}
})
}
}
type decodedGroundingProjection struct {
Locations []struct {
Name string `json:"name"`
RegistryRefs []RegistryRef `json:"registry_refs"`
Context []ContextUnit `json:"context"`
} `json:"locations"`
}
func decodeGroundingProjection(t *testing.T, content []byte) decodedGroundingProjection {
t.Helper()
var projection decodedGroundingProjection
if err := json.Unmarshal(content, &projection); err != nil {
t.Fatal(err)
}
return projection
}
func groundingDocument() *source.SourceDocument {
return &source.SourceDocument{ID: "session-alpha", Units: []source.SourceUnit{
{ID: 30, Text: "Tavern entrance"},
{ID: 10, Text: "Tavern common room"},
{ID: 20, Text: "Tavern cellar"},
{ID: 40, Text: "Market square"},
}}
}
func location(name string, refs []source.SourceRef) dnd.Location {
return dnd.Location{ID: identity.DeriveID(name, refs), Name: name, SourceRefs: refs}
}

View File

@@ -23,7 +23,7 @@ import (
const ( const (
Key = "dnd/item-registry" Key = "dnd/item-registry"
PromptID = "dnd.item_registry.normalize" PromptID = "dnd.item_registry.normalize"
normalizationPolicy = "dnd.item_registry.normalize.v1" normalizationPolicy = "dnd.item_registry.normalize.v2"
semanticContextPolicy = "dnd.entity_reconcile.context.v1" semanticContextPolicy = "dnd.entity_reconcile.context.v1"
semanticContextRadius = 2 semanticContextRadius = 2
NormalizationPolicy = normalizationPolicy NormalizationPolicy = normalizationPolicy

View File

@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"reflect" "reflect"
"strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -101,7 +102,7 @@ func TestNormalizeAppliesSafeAliasProposal(t *testing.T) {
t.Fatalf("merged item = %#v, warnings = %#v", merged, result.Warnings) t.Fatalf("merged item = %#v, warnings = %#v", merged, result.Warnings)
} }
encoded := string(client.requests[0].Inputs["candidates"].Content) + string(client.requests[0].Inputs["transcript"].Content) encoded := string(client.requests[0].Inputs["candidates"].Content) + string(client.requests[0].Inputs["transcript"].Content)
if strings.Contains(encoded, doc.ID) || !strings.Contains(encoded, "candidate-000001") || strings.Contains(encoded, merged.ID) { if strings.Contains(encoded, doc.ID) || strings.Contains(encoded, "candidate-") || strings.Contains(encoded, merged.ID) || !strings.Contains(encoded, `"source_refs"`) {
t.Fatalf("private inputs = %s", encoded) t.Fatalf("private inputs = %s", encoded)
} }
} }
@@ -261,7 +262,7 @@ func TestRegisterPromptAssetsPreparesItemNormalizationPrompt(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion, ProfileID: "item-normalize-test", Inputs: map[string]promptkit.ArtifactRef{"candidates": promptkit.Inline(`{"candidates":[{"key":"candidate-000001","name":"Rope","source_refs":[]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`)}}) prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion, ProfileID: "item-normalize-test", Inputs: map[string]promptkit.ArtifactRef{"candidates": promptkit.Inline(`{"candidates":[{"name":"Rope","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`)}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -285,10 +286,51 @@ func (c *recordingNormalizerClient) CompleteStructured(_ context.Context, reques
if response == "" { if response == "" {
response = `{"duplicate_groups":[]}` response = `{"duplicate_groups":[]}`
} }
if err := json.Unmarshal([]byte(response), output); err != nil { content, err := contextualProposalResponse(response, request.Inputs["candidates"].Content)
if err != nil {
return contracts.StructuredCompletionResponse{}, err return contracts.StructuredCompletionResponse{}, err
} }
return contracts.StructuredCompletionResponse{Content: json.RawMessage(response)}, nil if err := json.Unmarshal(content, output); err != nil {
return contracts.StructuredCompletionResponse{}, err
}
return contracts.StructuredCompletionResponse{Content: content}, nil
}
func contextualProposalResponse(response string, candidateContent []byte) ([]byte, error) {
if !strings.Contains(response, "candidate-") {
return []byte(response), nil
}
var selection struct {
DuplicateGroups []struct {
Members []string `json:"members"`
Canonical string `json:"canonical"`
} `json:"duplicate_groups"`
}
if err := json.Unmarshal([]byte(response), &selection); err != nil {
return nil, err
}
var candidates struct {
Candidates []entityreconcile.Selector `json:"candidates"`
}
if err := json.Unmarshal(candidateContent, &candidates); err != nil {
return nil, err
}
selector := func(key string) entityreconcile.Selector {
index, err := strconv.Atoi(strings.TrimPrefix(key, "candidate-"))
if err != nil || index < 1 || index > len(candidates.Candidates) {
return entityreconcile.Selector{Name: key, SourceRefs: []entityreconcile.SourceRange{}}
}
return candidates.Candidates[index-1].Clone()
}
proposal := entityreconcile.ProposalResponse{DuplicateGroups: make([]entityreconcile.DuplicateGroup, len(selection.DuplicateGroups))}
for index, group := range selection.DuplicateGroups {
members := make([]entityreconcile.Selector, len(group.Members))
for memberIndex, key := range group.Members {
members[memberIndex] = selector(key)
}
proposal.DuplicateGroups[index] = entityreconcile.DuplicateGroup{Members: members, Canonical: selector(group.Canonical)}
}
return json.Marshal(proposal)
} }
func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normalizer { func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normalizer {

View File

@@ -86,7 +86,7 @@ func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
} }
return []pipeline.CheckpointFingerprint{ return []pipeline.CheckpointFingerprint{
{Name: "normalization_policy", Value: normalizationPolicy}, {Name: "normalization_policy", Value: normalizationPolicy},
{Name: "location_registry", Value: n.locationResolver.Seeded().ProjectionDigest()}, {Name: "location_registry", Value: n.locationResolver.Seeded().IdentityDigest()},
} }
} }

View File

@@ -23,7 +23,7 @@ import (
const ( const (
Key = "dnd/location-registry" Key = "dnd/location-registry"
PromptID = "dnd.location_registry.normalize" PromptID = "dnd.location_registry.normalize"
normalizationPolicy = "dnd.location_registry.normalize.v1" normalizationPolicy = "dnd.location_registry.normalize.v2"
semanticContextPolicy = "dnd.entity_reconcile.context.v1" semanticContextPolicy = "dnd.entity_reconcile.context.v1"
semanticContextRadius = 2 semanticContextRadius = 2
NormalizationPolicy = normalizationPolicy NormalizationPolicy = normalizationPolicy

View File

@@ -60,7 +60,7 @@ func TestNormalizePreparesOnlyExactDuplicatesAndRetainsSameNameAndNestedPlaces(t
} }
} }
func TestNormalizeAppliesSafeAliasGroupAndUsesOpaqueInputs(t *testing.T) { func TestNormalizeAppliesSafeAliasGroupAndUsesContextualInputs(t *testing.T) {
client := &recordingLocationNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"}]}`} client := &recordingLocationNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"}]}`}
doc := semanticDocument() doc := semanticDocument()
input := dnd.LocationRegistry{Locations: []dnd.Location{ input := dnd.LocationRegistry{Locations: []dnd.Location{
@@ -77,7 +77,7 @@ func TestNormalizeAppliesSafeAliasGroupAndUsesOpaqueInputs(t *testing.T) {
t.Fatalf("merged location = %#v, warnings = %#v", merged, result.Warnings) t.Fatalf("merged location = %#v, warnings = %#v", merged, result.Warnings)
} }
encoded := string(client.requests[0].Inputs["candidates"].Content) + string(client.requests[0].Inputs["transcript"].Content) encoded := string(client.requests[0].Inputs["candidates"].Content) + string(client.requests[0].Inputs["transcript"].Content)
if strings.Contains(encoded, doc.ID) || !strings.Contains(encoded, "candidate-000001") || strings.Contains(encoded, merged.ID) { if strings.Contains(encoded, doc.ID) || strings.Contains(encoded, "candidate-") || strings.Contains(encoded, merged.ID) || !strings.Contains(encoded, `"source_refs"`) {
t.Fatalf("private inputs = %s", encoded) t.Fatalf("private inputs = %s", encoded)
} }
} }

View File

@@ -28,7 +28,7 @@ func TestRegisterPromptAssetsPreparesLocationNormalizationPrompt(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion, ProfileID: "location-normalize-test", Inputs: map[string]promptkit.ArtifactRef{"candidates": promptkit.Inline(`{"candidates":[{"key":"candidate-000001","name":"The Tavern","source_refs":[]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`)}}) prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion, ProfileID: "location-normalize-test", Inputs: map[string]promptkit.ArtifactRef{"candidates": promptkit.Inline(`{"candidates":[{"name":"The Tavern","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`)}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -40,14 +40,14 @@ func TestRegisterPromptAssetsPreparesLocationNormalizationPrompt(t *testing.T) {
t.Fatalf("message %d cache = %#v", index, prepared.Messages[index].CacheControl) t.Fatalf("message %d cache = %#v", index, prepared.Messages[index].CacheControl)
} }
} }
if !strings.Contains(prepared.Messages[3].Content, "candidate-000001") || strings.Contains(prepared.Messages[3].Content, `"windows"`) { if !strings.Contains(prepared.Messages[3].Content, `"The Tavern"`) || strings.Contains(prepared.Messages[3].Content, `"candidate-`) || strings.Contains(prepared.Messages[3].Content, `"windows"`) {
t.Fatalf("candidate message = %q", prepared.Messages[3].Content) t.Fatalf("candidate message = %q", prepared.Messages[3].Content)
} }
if !strings.Contains(prepared.Messages[4].Content, `"windows"`) || strings.Contains(prepared.Messages[4].Content, "candidate-000001") { if !strings.Contains(prepared.Messages[4].Content, `"windows"`) || strings.Contains(prepared.Messages[4].Content, `"The Tavern"`) {
t.Fatalf("transcript message = %q", prepared.Messages[4].Content) t.Fatalf("transcript message = %q", prepared.Messages[4].Content)
} }
for index, message := range prepared.Messages { for index, message := range prepared.Messages {
if index != 3 && strings.Contains(message.Content, "candidate-000001") { if index != 3 && strings.Contains(message.Content, `"The Tavern"`) {
t.Errorf("message %d unexpectedly rendered candidate input", index) t.Errorf("message %d unexpectedly rendered candidate input", index)
} }
if index != 4 && strings.Contains(message.Content, `"windows"`) { if index != 4 && strings.Contains(message.Content, `"windows"`) {

View File

@@ -3,11 +3,14 @@ package locationregistry
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"strconv"
"strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
) )
type recordingLocationNormalizerClient struct { type recordingLocationNormalizerClient struct {
@@ -25,10 +28,51 @@ func (c *recordingLocationNormalizerClient) CompleteStructured(_ context.Context
if response == "" { if response == "" {
response = `{"duplicate_groups":[]}` response = `{"duplicate_groups":[]}`
} }
if err := json.Unmarshal([]byte(response), output); err != nil { content, err := contextualProposalResponse(response, request.Inputs["candidates"].Content)
if err != nil {
return contracts.StructuredCompletionResponse{}, err return contracts.StructuredCompletionResponse{}, err
} }
return contracts.StructuredCompletionResponse{Content: json.RawMessage(response)}, nil if err := json.Unmarshal(content, output); err != nil {
return contracts.StructuredCompletionResponse{}, err
}
return contracts.StructuredCompletionResponse{Content: content}, nil
}
func contextualProposalResponse(response string, candidateContent []byte) ([]byte, error) {
if !strings.Contains(response, "candidate-") {
return []byte(response), nil
}
var selection struct {
DuplicateGroups []struct {
Members []string `json:"members"`
Canonical string `json:"canonical"`
} `json:"duplicate_groups"`
}
if err := json.Unmarshal([]byte(response), &selection); err != nil {
return nil, err
}
var candidates struct {
Candidates []entityreconcile.Selector `json:"candidates"`
}
if err := json.Unmarshal(candidateContent, &candidates); err != nil {
return nil, err
}
selector := func(key string) entityreconcile.Selector {
index, err := strconv.Atoi(strings.TrimPrefix(key, "candidate-"))
if err != nil || index < 1 || index > len(candidates.Candidates) {
return entityreconcile.Selector{Name: key, SourceRefs: []entityreconcile.SourceRange{}}
}
return candidates.Candidates[index-1].Clone()
}
proposal := entityreconcile.ProposalResponse{DuplicateGroups: make([]entityreconcile.DuplicateGroup, len(selection.DuplicateGroups))}
for index, group := range selection.DuplicateGroups {
members := make([]entityreconcile.Selector, len(group.Members))
for memberIndex, key := range group.Members {
members[memberIndex] = selector(key)
}
proposal.DuplicateGroups[index] = entityreconcile.DuplicateGroup{Members: members, Canonical: selector(group.Canonical)}
}
return json.Marshal(proposal)
} }
func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normalizer { func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normalizer {

View File

@@ -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()},
} }
} }

View File

@@ -23,7 +23,7 @@ import (
const ( const (
Key = "dnd/npc-registry" Key = "dnd/npc-registry"
PromptID = "dnd.npc_registry.normalize" PromptID = "dnd.npc_registry.normalize"
normalizationPolicy = "dnd.npc_registry.normalize.v3" normalizationPolicy = "dnd.npc_registry.normalize.v4"
semanticContextPolicy = "dnd.entity_reconcile.context.v1" semanticContextPolicy = "dnd.entity_reconcile.context.v1"
NormalizationPolicy = normalizationPolicy NormalizationPolicy = normalizationPolicy

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"reflect" "reflect"
"strconv"
"strings" "strings"
"testing" "testing"
@@ -156,10 +157,51 @@ func (c *recordingNPCNormalizerClient) CompleteStructured(_ context.Context, req
if response == "" { if response == "" {
response = `{"duplicate_groups":[]}` response = `{"duplicate_groups":[]}`
} }
if err := json.Unmarshal([]byte(response), output); err != nil { content, err := contextualProposalResponse(response, request.Inputs["candidates"].Content)
if err != nil {
return contracts.StructuredCompletionResponse{}, err return contracts.StructuredCompletionResponse{}, err
} }
return contracts.StructuredCompletionResponse{Content: json.RawMessage(response)}, nil if err := json.Unmarshal(content, output); err != nil {
return contracts.StructuredCompletionResponse{}, err
}
return contracts.StructuredCompletionResponse{Content: content}, nil
}
func contextualProposalResponse(response string, candidateContent []byte) ([]byte, error) {
if !strings.Contains(response, "candidate-") {
return []byte(response), nil
}
var selection struct {
DuplicateGroups []struct {
Members []string `json:"members"`
Canonical string `json:"canonical"`
} `json:"duplicate_groups"`
}
if err := json.Unmarshal([]byte(response), &selection); err != nil {
return nil, err
}
var candidates struct {
Candidates []entityreconcile.Selector `json:"candidates"`
}
if err := json.Unmarshal(candidateContent, &candidates); err != nil {
return nil, err
}
selector := func(key string) entityreconcile.Selector {
index, err := strconv.Atoi(strings.TrimPrefix(key, "candidate-"))
if err != nil || index < 1 || index > len(candidates.Candidates) {
return entityreconcile.Selector{Name: key, SourceRefs: []entityreconcile.SourceRange{}}
}
return candidates.Candidates[index-1].Clone()
}
proposal := entityreconcile.ProposalResponse{DuplicateGroups: make([]entityreconcile.DuplicateGroup, len(selection.DuplicateGroups))}
for index, group := range selection.DuplicateGroups {
members := make([]entityreconcile.Selector, len(group.Members))
for memberIndex, key := range group.Members {
members[memberIndex] = selector(key)
}
proposal.DuplicateGroups[index] = entityreconcile.DuplicateGroup{Members: members, Canonical: selector(group.Canonical)}
}
return json.Marshal(proposal)
} }
func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normalizer { func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normalizer {

View File

@@ -36,7 +36,7 @@ func TestRegisterPromptAssetsPreparesNormalizationPrompt(t *testing.T) {
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{ prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion, ProfileID: "normalize-test-profile", PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion, ProfileID: "normalize-test-profile",
Inputs: map[string]promptkit.ArtifactRef{ Inputs: map[string]promptkit.ArtifactRef{
"candidates": promptkit.Inline(`{"candidates":[{"key":"candidate-000001","name":"Mira","source_refs":[]}]}`), "candidates": promptkit.Inline(`{"candidates":[{"name":"Mira","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`),
"transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`),
}, },
}) })

View File

@@ -2,6 +2,7 @@ package npcregistry
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"math" "math"
"reflect" "reflect"
@@ -174,8 +175,8 @@ func TestNormalizeDoesNotAccumulateSafeGroupsAcrossAttempts(t *testing.T) {
} }
} }
func TestNormalizeExcludesIneligibleRecordsFromSemanticGroups(t *testing.T) { func TestNormalizeRetainsRecordsWhenAProposalUsesAnUnknownDescriptor(t *testing.T) {
client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000001"}]}`} client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","unknown"],"canonical":"candidate-000001"}]}`}
normalizer := newNormalizer(t, client) normalizer := newNormalizer(t, client)
doc := semanticDocument() doc := semanticDocument()
input := dnd.NPCRegistry{NPCs: []dnd.NPC{ input := dnd.NPCRegistry{NPCs: []dnd.NPC{
@@ -187,8 +188,8 @@ func TestNormalizeExcludesIneligibleRecordsFromSemanticGroups(t *testing.T) {
if err != nil || result.Retry == nil || len(result.Value.NPCs) != 3 { if err != nil || result.Retry == nil || len(result.Value.NPCs) != 3 {
t.Fatalf("Normalize() = %#v, %v; want unchanged retry fallback", result, err) t.Fatalf("Normalize() = %#v, %v; want unchanged retry fallback", result, err)
} }
if !strings.Contains(result.Retry.Message, "member_ineligible") || result.Value.NPCs[1].Name != "Broken" { if !strings.Contains(result.Retry.Message, "member_unknown") || result.Value.NPCs[1].Name != "Broken" {
t.Fatalf("result = %#v, want ineligible record excluded but preserved", result) t.Fatalf("result = %#v, want unknown descriptor rejected without dropping a record", result)
} }
} }
@@ -206,8 +207,14 @@ func TestReconciliationCandidatesKeepEqualDisplayNamesDistinct(t *testing.T) {
if !reflect.DeepEqual(keys, []string{"candidate-000001", "candidate-000002"}) || strings.Count(string(materials.Candidates.Content), `"The Guard"`) != 2 { if !reflect.DeepEqual(keys, []string{"candidate-000001", "candidate-000002"}) || strings.Count(string(materials.Candidates.Content), `"The Guard"`) != 2 {
t.Fatalf("candidate keys and inputs = %#v, %s; want distinct equal-display candidates", keys, materials.Candidates.Content) t.Fatalf("candidate keys and inputs = %#v, %s; want distinct equal-display candidates", keys, materials.Candidates.Content)
} }
var candidateInput struct {
Candidates []entityreconcile.Selector `json:"candidates"`
}
if err := json.Unmarshal(materials.Candidates.Content, &candidateInput); err != nil {
t.Fatal(err)
}
assessment := materials.Assess(entityreconcile.ProposalResponse{DuplicateGroups: []entityreconcile.DuplicateGroup{{ assessment := materials.Assess(entityreconcile.ProposalResponse{DuplicateGroups: []entityreconcile.DuplicateGroup{{
Members: keys, Canonical: keys[1], Members: candidateInput.Candidates, Canonical: candidateInput.Candidates[1],
}}}) }}})
groups := reconciliationGroups(assessment, keys) groups := reconciliationGroups(assessment, keys)
if assessment.DiscardedGroups() != 0 || len(groups) != 1 || groups[0].canonical != 1 { if assessment.DiscardedGroups() != 0 || len(groups) != 1 || groups[0].canonical != 1 {

View File

@@ -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) {

View File

@@ -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)
} }
} }

View File

@@ -22,13 +22,34 @@ type Candidate struct {
SourceRefs []source.SourceRef SourceRefs []source.SourceRef
} }
// Selector identifies one candidate through its canonical name and source-free
// evidence ranges. It is the complete model-facing candidate descriptor.
type Selector struct {
Name string `json:"name"`
SourceRefs []SourceRange `json:"source_refs"`
}
// Clone returns an owned copy of the selector.
func (s Selector) Clone() Selector {
s.SourceRefs = cloneSourceRanges(s.SourceRefs)
return s
}
// SourceRange is a source-free evidence coordinate used in a selector.
type SourceRange struct {
StartUnitID int `json:"start_unit_id"`
EndUnitID int `json:"end_unit_id"`
}
// Materials contains owned prompt inputs and opaque candidate-key mappings. // Materials contains owned prompt inputs and opaque candidate-key mappings.
type Materials struct { type Materials struct {
Candidates contracts.LLMInputMaterial Candidates contracts.LLMInputMaterial
Transcript contracts.LLMInputMaterial Transcript contracts.LLMInputMaterial
candidateKeys []string candidateKeys []string
eligible map[string]struct{} eligible map[string]struct{}
keyBySelector map[string]string
collidedSelectors map[string]struct{}
} }
// CandidateKeys returns all deterministic keys in candidate input order. // CandidateKeys returns all deterministic keys in candidate input order.
@@ -49,18 +70,7 @@ func (m Materials) EligibleCandidateKeys() []string {
} }
type candidateInput struct { type candidateInput struct {
Candidates []candidateView `json:"candidates"` Candidates []Selector `json:"candidates"`
}
type candidateView struct {
Key string `json:"key"`
Name string `json:"name"`
SourceRefs []candidateSourceRef `json:"source_refs"`
}
type candidateSourceRef struct {
StartUnitID int `json:"start_unit_id"`
EndUnitID int `json:"end_unit_id"`
} }
type transcriptInput struct { type transcriptInput struct {
@@ -91,8 +101,10 @@ func BuildContext(doc *source.SourceDocument, candidates []Candidate, radius int
return Materials{}, false, fmt.Errorf("build entity reconciliation context: radius must not be negative") return Materials{}, false, fmt.Errorf("build entity reconciliation context: radius must not be negative")
} }
materials := Materials{ materials := Materials{
candidateKeys: make([]string, len(candidates)), candidateKeys: make([]string, len(candidates)),
eligible: make(map[string]struct{}), eligible: make(map[string]struct{}),
keyBySelector: make(map[string]string),
collidedSelectors: make(map[string]struct{}),
} }
for index := range candidates { for index := range candidates {
key := fmt.Sprintf(candidateKeyFormat, index+1) key := fmt.Sprintf(candidateKeyFormat, index+1)
@@ -103,7 +115,15 @@ func BuildContext(doc *source.SourceDocument, candidates []Candidate, radius int
} }
index := source.NewDocumentIndex(doc) index := source.NewDocumentIndex(doc)
views := make([]candidateView, 0, len(candidates)) type preparedCandidate struct {
key string
selector Selector
intervals []sourceInterval
lookupKey string
}
prepared := make([]preparedCandidate, 0, len(candidates))
selectorCounts := make(map[string]int, len(candidates))
views := make([]Selector, 0, len(candidates))
intervals := make([]sourceInterval, 0) intervals := make([]sourceInterval, 0)
cited := make([]bool, len(doc.Units)) cited := make([]bool, len(doc.Units))
for candidateIndex, candidate := range candidates { for candidateIndex, candidate := range candidates {
@@ -112,9 +132,23 @@ func BuildContext(doc *source.SourceDocument, candidates []Candidate, radius int
continue continue
} }
key := materials.candidateKeys[candidateIndex] key := materials.candidateKeys[candidateIndex]
materials.eligible[key] = struct{}{} selector := Selector{Name: candidate.Name, SourceRefs: references}
views = append(views, candidateView{Key: key, Name: candidate.Name, SourceRefs: references}) lookupKey, err := selectorLookupKey(selector)
for _, interval := range candidateIntervals { if err != nil {
return Materials{}, false, fmt.Errorf("build entity reconciliation context: invalid candidate material")
}
prepared = append(prepared, preparedCandidate{key: key, selector: selector, intervals: candidateIntervals, lookupKey: lookupKey})
selectorCounts[lookupKey]++
}
for _, candidate := range prepared {
if selectorCounts[candidate.lookupKey] != 1 {
materials.collidedSelectors[candidate.lookupKey] = struct{}{}
continue
}
materials.eligible[candidate.key] = struct{}{}
materials.keyBySelector[candidate.lookupKey] = candidate.key
views = append(views, candidate.selector.Clone())
for _, interval := range candidate.intervals {
for position := interval.start; position <= interval.end; position++ { for position := interval.start; position <= interval.end; position++ {
cited[position] = true cited[position] = true
} }
@@ -145,24 +179,56 @@ func BuildContext(doc *source.SourceDocument, candidates []Candidate, radius int
return materials, true, nil return materials, true, nil
} }
func candidateReferences(index source.DocumentIndex, refs []source.SourceRef) ([]candidateSourceRef, []sourceInterval, bool) { func candidateReferences(index source.DocumentIndex, refs []source.SourceRef) ([]SourceRange, []sourceInterval, bool) {
if len(refs) == 0 { if len(refs) == 0 {
return nil, nil, false return nil, nil, false
} }
references := make([]candidateSourceRef, 0, len(refs)) type referencedInterval struct {
intervals := make([]sourceInterval, 0, len(refs)) reference SourceRange
interval sourceInterval
}
prepared := make([]referencedInterval, 0, len(refs))
for _, ref := range refs { for _, ref := range refs {
if err := index.ValidateRef(ref); err != nil { if err := index.ValidateRef(ref); err != nil {
return nil, nil, false return nil, nil, false
} }
start, _ := index.Position(ref.StartUnitID) start, _ := index.Position(ref.StartUnitID)
end, _ := index.Position(ref.EndUnitID) end, _ := index.Position(ref.EndUnitID)
references = append(references, candidateSourceRef{StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}) prepared = append(prepared, referencedInterval{reference: SourceRange{StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}, interval: sourceInterval{start: start, end: end}})
intervals = append(intervals, sourceInterval{start: start, end: end}) }
sort.Slice(prepared, func(left, right int) bool {
if prepared[left].interval.start != prepared[right].interval.start {
return prepared[left].interval.start < prepared[right].interval.start
}
return prepared[left].interval.end < prepared[right].interval.end
})
references := make([]SourceRange, 0, len(prepared))
intervals := make([]sourceInterval, 0, len(prepared))
for _, item := range prepared {
if len(references) > 0 && references[len(references)-1] == item.reference {
continue
}
references = append(references, item.reference)
intervals = append(intervals, item.interval)
} }
return references, intervals, true return references, intervals, true
} }
func selectorLookupKey(selector Selector) (string, error) {
content, err := json.Marshal(selector.Clone())
if err != nil {
return "", err
}
return string(content), nil
}
func cloneSourceRanges(values []SourceRange) []SourceRange {
if len(values) == 0 {
return []SourceRange{}
}
return append([]SourceRange(nil), values...)
}
func coalesceIntervals(intervals []sourceInterval) []sourceInterval { func coalesceIntervals(intervals []sourceInterval) []sourceInterval {
if len(intervals) == 0 { if len(intervals) == 0 {
return nil return nil

View File

@@ -13,7 +13,7 @@ import (
"github.com/santhosh-tekuri/jsonschema/v6" "github.com/santhosh-tekuri/jsonschema/v6"
) )
func TestBuildContextUsesOpaqueKeysSourceOrderAndOwnedData(t *testing.T) { func TestBuildContextUsesContextualSelectorsSourceOrderAndOwnedData(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{ doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{
{ID: 40, Kind: "narration", Text: "zero"}, {ID: 40, Kind: "narration", Text: "zero"},
{ID: 10, Kind: "speech", Text: "one", Metadata: map[string]any{"speaker": map[string]any{"name": "Mira"}}}, {ID: 10, Kind: "speech", Text: "one", Metadata: map[string]any{"speaker": map[string]any{"name": "Mira"}}},
@@ -52,10 +52,10 @@ func TestBuildContextUsesOpaqueKeysSourceOrderAndOwnedData(t *testing.T) {
if err := json.Unmarshal(materials.Candidates.Content, &candidatePayload); err != nil { if err := json.Unmarshal(materials.Candidates.Content, &candidatePayload); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(candidatePayload.Candidates) != 2 || candidatePayload.Candidates[0].Key != "candidate-000001" || candidatePayload.Candidates[1].Key != "candidate-000002" || candidatePayload.Candidates[0].Name != candidatePayload.Candidates[1].Name { if len(candidatePayload.Candidates) != 2 || candidatePayload.Candidates[0].Name != candidatePayload.Candidates[1].Name || strings.Contains(string(materials.Candidates.Content), "candidate-") {
t.Fatalf("candidate payload = %#v, want distinct opaque keys for equal names", candidatePayload) t.Fatalf("candidate payload = %#v, want contextual descriptors without keys", candidatePayload)
} }
if got := candidatePayload.Candidates[0].SourceRefs[0]; got != (candidateSourceRef{StartUnitID: 10, EndUnitID: 20}) { if got := candidatePayload.Candidates[0].SourceRefs[0]; got != (SourceRange{StartUnitID: 10, EndUnitID: 20}) {
t.Fatalf("candidate reference = %#v", got) t.Fatalf("candidate reference = %#v", got)
} }
@@ -86,6 +86,50 @@ func TestBuildContextUsesOpaqueKeysSourceOrderAndOwnedData(t *testing.T) {
} }
} }
func TestBuildContextExcludesCollidingDescriptors(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}, {ID: 3}}}
candidates := []Candidate{
{Name: "The Tavern", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}},
{Name: "The Tavern", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}},
{Name: "The Market", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 3, EndUnitID: 3}}},
}
materials, ready, err := BuildContext(doc, candidates, 0)
if err != nil || ready || len(materials.EligibleCandidateKeys()) != 1 || strings.Contains(string(materials.Candidates.Content), "The Tavern") {
t.Fatalf("BuildContext() = %#v, %t, %v", materials, ready, err)
}
assessment := materials.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{{
Members: []Selector{{Name: "The Tavern", SourceRefs: []SourceRange{{StartUnitID: 1, EndUnitID: 1}}}, {Name: "The Market", SourceRefs: []SourceRange{{StartUnitID: 3, EndUnitID: 3}}}},
Canonical: Selector{Name: "The Market", SourceRefs: []SourceRange{{StartUnitID: 3, EndUnitID: 3}}},
}}})
if !hasIssue(assessment.Issues(), "member_ineligible") {
t.Fatalf("Assess() issues = %#v, want collided descriptor rejection", assessment.Issues())
}
}
func TestAssessmentRejectsPartialAndReorderedDescriptors(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}, {ID: 30}}}
materials, ready, err := BuildContext(doc, []Candidate{
{Name: "The Tavern", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}, {SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
{Name: "The Market", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
}, 0)
if err != nil || !ready {
t.Fatalf("BuildContext() = %#v, %t, %v", materials, ready, err)
}
selectors := materialSelectors(t, materials)
for _, refs := range [][]SourceRange{
{{StartUnitID: 10, EndUnitID: 10}},
{{StartUnitID: 20, EndUnitID: 20}, {StartUnitID: 10, EndUnitID: 10}},
} {
assessment := materials.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{{
Members: []Selector{{Name: "The Tavern", SourceRefs: refs}, selectors[1]},
Canonical: selectors[1],
}}})
if !hasIssue(assessment.Issues(), "member_unknown") {
t.Fatalf("Assess(%#v) issues = %#v, want descriptor mismatch rejection", refs, assessment.Issues())
}
}
}
func TestBuildContextExcludesUnsafeReferencesAndCoalescesAdjacentWindows(t *testing.T) { func TestBuildContextExcludesUnsafeReferencesAndCoalescesAdjacentWindows(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 9}, {ID: 3}, {ID: 8}, {ID: 1}, {ID: 7}}} doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 9}, {ID: 3}, {ID: 8}, {ID: 1}, {ID: 7}}}
candidates := []Candidate{ candidates := []Candidate{
@@ -116,22 +160,21 @@ func TestBuildContextExcludesUnsafeReferencesAndCoalescesAdjacentWindows(t *test
func TestAssessmentRejectsEveryUnsafeProposalCategory(t *testing.T) { func TestAssessmentRejectsEveryUnsafeProposalCategory(t *testing.T) {
materials := preparedMaterials(t, 4, true) materials := preparedMaterials(t, 4, true)
keys := materials.CandidateKeys() selectors := materialSelectors(t, materials)
unsafe := []struct { unsafe := []struct {
name string name string
response ProposalResponse response ProposalResponse
category string category string
}{ }{
{"blank member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{"", keys[1]}, Canonical: keys[1]}}}, "member_blank"}, {"blank member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{{}, selectors[1]}, Canonical: selectors[1]}}}, "member_blank"},
{"unknown member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{"candidate-999999", keys[1]}, Canonical: keys[1]}}}, "member_unknown"}, {"unknown member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{{Name: "unknown", SourceRefs: []SourceRange{{StartUnitID: 99, EndUnitID: 99}}}, selectors[1]}, Canonical: selectors[1]}}}, "member_unknown"},
{"ineligible member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{keys[0], keys[3]}, Canonical: keys[0]}}}, "member_ineligible"}, {"repeated member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{selectors[0], selectors[0]}, Canonical: selectors[0]}}}, "repeated_member"},
{"repeated member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{keys[0], keys[0]}, Canonical: keys[0]}}}, "repeated_member"}, {"too small", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{selectors[0]}, Canonical: selectors[0]}}}, "fewer_than_two_members"},
{"too small", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{keys[0]}, Canonical: keys[0]}}}, "fewer_than_two_members"}, {"canonical blank", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{selectors[0], selectors[1]}, Canonical: Selector{}}}}, "canonical_blank"},
{"canonical blank", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{keys[0], keys[1]}, Canonical: ""}}}, "canonical_blank"}, {"canonical not member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{selectors[0], selectors[1]}, Canonical: selectors[2]}}}, "canonical_not_member"},
{"canonical not member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{keys[0], keys[1]}, Canonical: keys[2]}}}, "canonical_not_member"},
{"overlapping", ProposalResponse{DuplicateGroups: []DuplicateGroup{ {"overlapping", ProposalResponse{DuplicateGroups: []DuplicateGroup{
{Members: []string{keys[0], keys[1]}, Canonical: keys[0]}, {Members: []Selector{selectors[0], selectors[1]}, Canonical: selectors[0]},
{Members: []string{keys[1], keys[2]}, Canonical: keys[2]}, {Members: []Selector{selectors[1], selectors[2]}, Canonical: selectors[2]},
}}, "overlapping_member"}, }}, "overlapping_member"},
} }
for _, test := range unsafe { for _, test := range unsafe {
@@ -147,9 +190,10 @@ func TestAssessmentRejectsEveryUnsafeProposalCategory(t *testing.T) {
func TestAssessmentReturnsNonOverlappingSafeGroupsAndDefensiveCopies(t *testing.T) { func TestAssessmentReturnsNonOverlappingSafeGroupsAndDefensiveCopies(t *testing.T) {
materials := preparedMaterials(t, 4, false) materials := preparedMaterials(t, 4, false)
keys := materials.CandidateKeys() keys := materials.CandidateKeys()
selectors := materialSelectors(t, materials)
assessment := materials.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{ assessment := materials.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{
{Members: []string{keys[1], keys[0]}, Canonical: keys[1]}, {Members: []Selector{selectors[1], selectors[0]}, Canonical: selectors[1]},
{Members: []string{keys[3], keys[2]}, Canonical: keys[2]}, {Members: []Selector{selectors[3], selectors[2]}, Canonical: selectors[2]},
}}) }})
groups := assessment.SafeGroups() groups := assessment.SafeGroups()
if assessment.DiscardedGroups() != 0 || len(assessment.Issues()) != 0 || len(groups) != 2 { if assessment.DiscardedGroups() != 0 || len(assessment.Issues()) != 0 || len(groups) != 2 {
@@ -183,12 +227,12 @@ func TestSharedResponseSchemaIsPrivateStrictAndRegisterableOnce(t *testing.T) {
valid bool valid bool
}{ }{
{"empty groups", map[string]any{"duplicate_groups": []any{}}, true}, {"empty groups", map[string]any{"duplicate_groups": []any{}}, true},
{"semantic proposal problem", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{""}, "canonical": ""}}}, true}, {"semantic proposal problem", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{map[string]any{"name": "Mira", "source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 1}}}}, "canonical": map[string]any{"name": "Mira", "source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 1}}}}}}, true},
{"missing groups", map[string]any{}, false}, {"missing groups", map[string]any{}, false},
{"unknown top level", map[string]any{"duplicate_groups": []any{}, "extra": true}, false}, {"unknown top level", map[string]any{"duplicate_groups": []any{}, "extra": true}, false},
{"replacement name", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{}, "canonical": "candidate-000001", "name": "replacement"}}}, false}, {"replacement name", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{}, "canonical": map[string]any{"name": "Mira", "source_refs": []any{}}, "name": "replacement"}}}, false},
{"replacement evidence", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{}, "canonical": "candidate-000001", "source_refs": []any{}}}}, false}, {"missing selector evidence", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{}, "canonical": map[string]any{"name": "Mira"}}}}, false},
{"wrong key type", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{1}, "canonical": "candidate-000001"}}}, false}, {"invalid range", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{map[string]any{"name": "Mira", "source_refs": []any{map[string]any{"start_unit_id": 0, "end_unit_id": 1}}}}, "canonical": map[string]any{"name": "Mira", "source_refs": []any{}}}}}, false},
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
content, err := json.Marshal(test.value) content, err := json.Marshal(test.value)
@@ -236,6 +280,15 @@ func preparedMaterials(t *testing.T, count int, includeIneligible bool) Material
return materials return materials
} }
func materialSelectors(t *testing.T, materials Materials) []Selector {
t.Helper()
var input candidateInput
if err := json.Unmarshal(materials.Candidates.Content, &input); err != nil {
t.Fatal(err)
}
return input.Candidates
}
func cloneCandidates(input []Candidate) []Candidate { func cloneCandidates(input []Candidate) []Candidate {
output := make([]Candidate, len(input)) output := make([]Candidate, len(input))
copy(output, input) copy(output, input)

View File

@@ -6,15 +6,16 @@ import (
) )
// ProposalResponse is the private structured response exchanged with the // ProposalResponse is the private structured response exchanged with the
// reconciliation prompt. It identifies candidates only by opaque keys. // reconciliation prompt. It identifies candidates by contextual selectors.
type ProposalResponse struct { type ProposalResponse struct {
DuplicateGroups []DuplicateGroup `json:"duplicate_groups"` DuplicateGroups []DuplicateGroup `json:"duplicate_groups"`
} }
// DuplicateGroup proposes candidate keys that might denote one entity. // DuplicateGroup proposes contextual candidate descriptors that might denote
// one entity.
type DuplicateGroup struct { type DuplicateGroup struct {
Members []string `json:"members"` Members []Selector `json:"members"`
Canonical string `json:"canonical"` Canonical Selector `json:"canonical"`
} }
// Issue identifies one unsafe proposal category without prescribing a warning // Issue identifies one unsafe proposal category without prescribing a warning
@@ -59,16 +60,13 @@ func (a Assessment) DiscardedGroups() int { return a.discardedGroups }
// Issues returns the deterministic rejection categories in proposal order. // Issues returns the deterministic rejection categories in proposal order.
func (a Assessment) Issues() []Issue { return append([]Issue(nil), a.issues...) } func (a Assessment) Issues() []Issue { return append([]Issue(nil), a.issues...) }
// Assess validates a proposal against the opaque keys created by BuildContext. // Assess resolves contextual descriptors to internal candidate keys, then
// validates the proposal without exposing those keys to the model.
func (m Materials) Assess(response ProposalResponse) Assessment { func (m Materials) Assess(response ProposalResponse) Assessment {
all := make(map[string]struct{}, len(m.candidateKeys))
for _, key := range m.candidateKeys {
all[key] = struct{}{}
}
groups := make([]assessedGroup, len(response.DuplicateGroups)) groups := make([]assessedGroup, len(response.DuplicateGroups))
issues := make([]Issue, 0) issues := make([]Issue, 0)
for groupIndex, proposal := range response.DuplicateGroups { for groupIndex, proposal := range response.DuplicateGroups {
groups[groupIndex] = assessGroup(proposal, all, m.eligible) groups[groupIndex] = m.assessGroup(proposal)
for _, category := range groups[groupIndex].issues { for _, category := range groups[groupIndex].issues {
issues = append(issues, Issue{GroupIndex: groupIndex, Category: category}) issues = append(issues, Issue{GroupIndex: groupIndex, Category: category})
} }
@@ -115,12 +113,13 @@ type assessedGroup struct {
conflicting bool conflicting bool
} }
func assessGroup(proposal DuplicateGroup, all, eligible map[string]struct{}) assessedGroup { func (m Materials) assessGroup(proposal DuplicateGroup) assessedGroup {
issues := make([]string, 0) issues := make([]string, 0)
members := make([]string, 0, len(proposal.Members)) members := make([]string, 0, len(proposal.Members))
seen := make(map[string]struct{}, len(proposal.Members)) seen := make(map[string]struct{}, len(proposal.Members))
for _, key := range proposal.Members { for _, selector := range proposal.Members {
if category := keyCategory(key, all, eligible); category != "" { key, category := m.selectorKey(selector)
if category != "" {
issues = append(issues, "member_"+category) issues = append(issues, "member_"+category)
continue continue
} }
@@ -131,31 +130,39 @@ func assessGroup(proposal DuplicateGroup, all, eligible map[string]struct{}) ass
seen[key] = struct{}{} seen[key] = struct{}{}
members = append(members, key) members = append(members, key)
} }
canonicalCategory := keyCategory(proposal.Canonical, all, eligible) canonical, canonicalCategory := m.selectorKey(proposal.Canonical)
if canonicalCategory != "" { if canonicalCategory != "" {
issues = append(issues, "canonical_"+canonicalCategory) issues = append(issues, "canonical_"+canonicalCategory)
} }
if len(members) < 2 { if len(members) < 2 {
issues = append(issues, "fewer_than_two_members") issues = append(issues, "fewer_than_two_members")
} }
if canonicalCategory == "" && !contains(members, proposal.Canonical) { if canonicalCategory == "" && !contains(members, canonical) {
issues = append(issues, "canonical_not_member") issues = append(issues, "canonical_not_member")
} }
sort.Strings(members) sort.Strings(members)
return assessedGroup{members: members, canonical: proposal.Canonical, issues: issues, locallyValid: len(issues) == 0} return assessedGroup{members: members, canonical: canonical, issues: issues, locallyValid: len(issues) == 0}
} }
func keyCategory(key string, all, eligible map[string]struct{}) string { func (m Materials) selectorKey(selector Selector) (string, string) {
if strings.TrimSpace(key) == "" { if strings.TrimSpace(selector.Name) == "" {
return "blank" return "", "blank"
} }
if _, ok := all[key]; !ok { lookupKey, err := selectorLookupKey(selector)
return "unknown" if err != nil {
return "", "unknown"
} }
if _, ok := eligible[key]; !ok { if _, collided := m.collidedSelectors[lookupKey]; collided {
return "ineligible" return "", "ineligible"
} }
return "" key, ok := m.keyBySelector[lookupKey]
if !ok {
return "", "unknown"
}
if _, eligible := m.eligible[key]; !eligible {
return "", "ineligible"
}
return key, ""
} }
func contains(values []string, want string) bool { func contains(values []string, want string) bool {

View File

@@ -61,7 +61,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
if v == nil || v.locationResolver == nil { if v == nil || v.locationResolver == nil {
return nil return nil
} }
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}, {Name: "location_registry", Value: v.locationResolver.Seeded().ProjectionDigest()}} return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}, {Name: "location_registry", Value: v.locationResolver.Seeded().IdentityDigest()}}
} }
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.LocationOccurrenceList]) (contracts.ValidationResult, error) { func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.LocationOccurrenceList]) (contracts.ValidationResult, error) {

View File

@@ -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()},
} }
} }

View File

@@ -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()},
} }
} }

View File

@@ -63,8 +63,8 @@ func TestLocationRegistryHandoffProducesOccurrencesAndEvidence(t *testing.T) {
request := client.requestFor(t, locationoccurrences.PromptID) request := client.requestFor(t, locationoccurrences.PromptID)
registryInput := request.Inputs["location_registry"] registryInput := request.Inputs["location_registry"]
if registryInput.MediaType != locationcodec.MediaType || strings.Contains(string(registryInput.Content), "source_refs") || !strings.Contains(string(registryInput.Content), registry.Locations[0].ID) || !strings.Contains(string(registryInput.Content), registry.Locations[1].ID) { if registryInput.MediaType != locationcodec.MediaType || strings.Contains(string(registryInput.Content), "source_refs") || strings.Contains(string(registryInput.Content), registry.Locations[0].ID) || strings.Contains(string(registryInput.Content), registry.Locations[1].ID) || !strings.Contains(string(registryInput.Content), "registry_refs") {
t.Fatalf("occurrence registry input = %#v, want source-free generated ID projection", registryInput) t.Fatalf("occurrence registry input = %#v, want contextual selector projection", registryInput)
} }
contextArtifact := outputFileContent(t, output.OutputFiles, "evidence-context.json") contextArtifact := outputFileContent(t, output.OutputFiles, "evidence-context.json")
evidence, err := evidencecontext.New().Decode(contextArtifact) evidence, err := evidencecontext.New().Decode(contextArtifact)
@@ -218,7 +218,11 @@ func (client *locationHandoffLLMClient) CompleteStructured(ctx context.Context,
case locationoccurrences.PromptID: case locationoccurrences.PromptID:
var projection struct { var projection struct {
Locations []struct { Locations []struct {
ID string `json:"id"` Name string `json:"name"`
RegistryRefs []struct {
StartUnitID int `json:"start_unit_id"`
EndUnitID int `json:"end_unit_id"`
} `json:"registry_refs"`
} `json:"locations"` } `json:"locations"`
} }
if err := json.Unmarshal(request.Inputs["location_registry"].Content, &projection); err != nil { if err := json.Unmarshal(request.Inputs["location_registry"].Content, &projection); err != nil {
@@ -227,7 +231,7 @@ func (client *locationHandoffLLMClient) CompleteStructured(ctx context.Context,
if len(projection.Locations) != 2 { if len(projection.Locations) != 2 {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated location registry has %d locations, want 2", len(projection.Locations)) return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated location registry has %d locations, want 2", len(projection.Locations))
} }
payload = map[string]any{"occurrences": []any{map[string]any{"location_id": projection.Locations[0].ID, "name": "Moon Gate", "kind": "visited", "source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}}}, map[string]any{"location_id": projection.Locations[1].ID, "name": "Moon Gate", "kind": "mentioned", "source_refs": []any{map[string]int{"start_unit_id": 3, "end_unit_id": 3}}}}} payload = map[string]any{"occurrences": []any{map[string]any{"name": projection.Locations[0].Name, "registry_refs": projection.Locations[0].RegistryRefs, "kind": "visited", "source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}}}, map[string]any{"name": projection.Locations[1].Name, "registry_refs": projection.Locations[1].RegistryRefs, "kind": "mentioned", "source_refs": []any{map[string]int{"start_unit_id": 3, "end_unit_id": 3}}}}}
default: default:
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", request.PromptID) return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", request.PromptID)
} }

View File

@@ -51,7 +51,7 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T
for name, value := range map[string]string{ for name, value := range map[string]string{
"extract:npc_registry:dnd/npc-registry:mapping_policy": "dnd.npc_registry.extract_mapping.v2", "extract:npc_registry:dnd/npc-registry:mapping_policy": "dnd.npc_registry.extract_mapping.v2",
"normalize:npc_registry:dnd/npc-registry:identity_policy": "dnd.npc_registry.identity.v1", "normalize:npc_registry:dnd/npc-registry:identity_policy": "dnd.npc_registry.identity.v1",
"normalize:npc_registry:dnd/npc-registry:normalization_policy": "dnd.npc_registry.normalize.v3", "normalize:npc_registry:dnd/npc-registry:normalization_policy": "dnd.npc_registry.normalize.v4",
"normalize:npc_registry:dnd/npc-registry:semantic_context_policy": "dnd.entity_reconcile.context.v1:2", "normalize:npc_registry:dnd/npc-registry:semantic_context_policy": "dnd.entity_reconcile.context.v1:2",
"extract:spells:dnd/spells:mapping_policy": "dnd.spells.extract_mapping.v2", "extract:spells:dnd/spells:mapping_policy": "dnd.spells.extract_mapping.v2",
"extract:combat:dnd/combat-turns:scene_gate_policy": "dnd.combat_turns.scene_gate.v1", "extract:combat:dnd/combat-turns:scene_gate_policy": "dnd.combat_turns.scene_gate.v1",

View File

@@ -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 {
@@ -289,7 +292,14 @@ func (client *semanticNPCOccurrenceClient) CompleteStructured(_ context.Context,
} }
payload = map[string]any{"npcs": []any{map[string]any{"name": name, "source_refs": []any{map[string]int{"start_unit_id": client.npcCalls, "end_unit_id": client.npcCalls}}}}} payload = map[string]any{"npcs": []any{map[string]any{"name": name, "source_refs": []any{map[string]int{"start_unit_id": client.npcCalls, "end_unit_id": client.npcCalls}}}}}
case npcnormalize.PromptID: case npcnormalize.PromptID:
payload = map[string]any{"duplicate_groups": []any{map[string]any{"members": []string{"candidate-000001", "candidate-000002"}, "canonical": "candidate-000001"}}} content, err := contextualReconciliationContent([]byte(`{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000001"}]}`), request.Inputs["candidates"].Content)
if err != nil {
return contracts.StructuredCompletionResponse{}, err
}
if err := json.Unmarshal(content, out); err != nil {
return contracts.StructuredCompletionResponse{}, err
}
return contracts.StructuredCompletionResponse{Content: content}, nil
case occurrenceextract.PromptID: case occurrenceextract.PromptID:
payload = map[string]any{"occurrences": []any{map[string]any{"npc_id": identity.DeriveID("Mira Thorn"), "name": "Mira Thorn", "kind": "dialogue", "source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}}}}} payload = map[string]any{"occurrences": []any{map[string]any{"npc_id": identity.DeriveID("Mira Thorn"), "name": "Mira Thorn", "kind": "dialogue", "source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}}}}}
default: default:

View File

@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"strconv"
"strings" "strings"
"testing" "testing"
@@ -266,6 +267,11 @@ func (client *fakeNPCProductionLLMClient) CompleteStructured(_ context.Context,
} }
content = append([]byte(nil), client.normalizeResponses[index]...) content = append([]byte(nil), client.normalizeResponses[index]...)
} }
var err error
content, err = contextualReconciliationContent(content, req.Inputs["candidates"].Content)
if err != nil {
return contracts.StructuredCompletionResponse{}, err
}
default: default:
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected fake NPC prompt %q", req.PromptID) return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected fake NPC prompt %q", req.PromptID)
} }
@@ -275,6 +281,43 @@ func (client *fakeNPCProductionLLMClient) CompleteStructured(_ context.Context,
return contracts.StructuredCompletionResponse{Content: content}, nil return contracts.StructuredCompletionResponse{Content: content}, nil
} }
func contextualReconciliationContent(content, candidateContent []byte) ([]byte, error) {
if !strings.Contains(string(content), "candidate-") {
return content, nil
}
var selection struct {
DuplicateGroups []struct {
Members []string `json:"members"`
Canonical string `json:"canonical"`
} `json:"duplicate_groups"`
}
if err := json.Unmarshal(content, &selection); err != nil {
return nil, err
}
var candidates struct {
Candidates []entityreconcile.Selector `json:"candidates"`
}
if err := json.Unmarshal(candidateContent, &candidates); err != nil {
return nil, err
}
selector := func(key string) entityreconcile.Selector {
index, err := strconv.Atoi(strings.TrimPrefix(key, "candidate-"))
if err != nil || index < 1 || index > len(candidates.Candidates) {
return entityreconcile.Selector{Name: key, SourceRefs: []entityreconcile.SourceRange{}}
}
return candidates.Candidates[index-1].Clone()
}
proposal := entityreconcile.ProposalResponse{DuplicateGroups: make([]entityreconcile.DuplicateGroup, len(selection.DuplicateGroups))}
for index, group := range selection.DuplicateGroups {
members := make([]entityreconcile.Selector, len(group.Members))
for memberIndex, key := range group.Members {
members[memberIndex] = selector(key)
}
proposal.DuplicateGroups[index] = entityreconcile.DuplicateGroup{Members: members, Canonical: selector(group.Canonical)}
}
return json.Marshal(proposal)
}
func (client *fakeNPCProductionLLMClient) requestCount(promptID string) int { func (client *fakeNPCProductionLLMClient) requestCount(promptID string) int {
count := 0 count := 0
for _, request := range client.requests { for _, request := range client.requests {