Compare commits
7 Commits
516af12916
...
d9b87347b8
| Author | SHA1 | Date | |
|---|---|---|---|
| d9b87347b8 | |||
| 20397ef710 | |||
| fc449863f2 | |||
| 51d62de1f3 | |||
| fc76805075 | |||
| 8e680cf96e | |||
| ece1bca460 |
@@ -14,9 +14,32 @@
|
||||
"properties": {
|
||||
"members": {
|
||||
"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}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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
|
||||
item registry. Record a stated quantity as an integer and leave it null when the transcript
|
||||
does not state one. Use a concise observed item name and preserve the stated
|
||||
currency denomination.
|
||||
For every occurrence, use the supplied canonical item `name`. Record a stated
|
||||
quantity as an integer and leave it null when the transcript does not state
|
||||
one. Preserve the stated currency denomination through the selected canonical
|
||||
registry name.
|
||||
|
||||
Use `discovered` when the party learns of or encounters an item without
|
||||
establishing possession. Use `acquired` when the party or a party member gains
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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,
|
||||
rename, merge, or infer registry items. The registry is not transcript
|
||||
evidence: cite only the current transcript chunk in `source_refs`.
|
||||
must use one registry item's canonical `name`; do not invent, rename, merge,
|
||||
or infer registry items. The registry is not transcript evidence: cite only the
|
||||
current transcript chunk in `source_refs`.
|
||||
|
||||
{{ input "item_registry" }}
|
||||
|
||||
@@ -10,9 +10,8 @@
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["item_id", "name", "kind", "quantity", "from", "to", "source_refs"],
|
||||
"required": ["name", "kind", "quantity", "from", "to", "source_refs"],
|
||||
"properties": {
|
||||
"item_id": {"type": "string"},
|
||||
"name": {"type": "string"},
|
||||
"kind": {"type": "string"},
|
||||
"quantity": {"type": ["integer", "null"]},
|
||||
|
||||
@@ -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
|
||||
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;
|
||||
planned outranks recalled and mentioned; recalled outranks mentioned. A passage
|
||||
may produce multiple records when it independently establishes separate facts,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
A normalized location registry is provided below for identity grounding. It may
|
||||
be empty. Each record contains the exact location ID and canonical display name
|
||||
to copy when the transcript establishes an occurrence of that place.
|
||||
A contextual location registry is provided below for identity grounding. It may
|
||||
be empty. Every record supplies a canonical display name. A name that appears
|
||||
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
|
||||
occurrence or a source range from the registry, and do not infer a location
|
||||
that is absent from it.
|
||||
occurrence or `source_refs` range from the registry. Do not invent a location
|
||||
or selector that is absent from it.
|
||||
|
||||
{{ input "location_registry" }}
|
||||
|
||||
@@ -10,10 +10,21 @@
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["location_id", "name", "kind", "source_refs"],
|
||||
"required": ["name", "registry_refs", "kind", "source_refs"],
|
||||
"properties": {
|
||||
"location_id": {"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"]},
|
||||
"source_refs": {
|
||||
"type": "array",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
Extract Dungeons & Dragons NPC occurrences from the supplied
|
||||
transcript. Include an occurrence only when the transcript establishes one
|
||||
supplied NPC, one occurrence kind, and a coherent passage supporting both.
|
||||
Use the exact `npc_id` and matching `name` pair from the supplied NPC registry;
|
||||
never invent an ID or substitute a similar name.
|
||||
Use the supplied canonical NPC `name`; never invent or substitute a similar
|
||||
name. Cite current-transcript evidence for every occurrence.
|
||||
|
||||
Do not summarize, infer relationships, sentiment, factions, motives, aliases,
|
||||
or persistent state. Do not identify player characters, anonymous groups, or
|
||||
|
||||
@@ -10,11 +10,8 @@
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["npc_id", "name", "kind", "source_refs"],
|
||||
"required": ["name", "kind", "source_refs"],
|
||||
"properties": {
|
||||
"npc_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
Identify only well-supported duplicate groups among the supplied candidates.
|
||||
|
||||
Candidate keys are opaque identifiers. Copy each selected key exactly. A group
|
||||
must contain at least two supplied keys, and its `canonical` key must be one of
|
||||
its members. Do not create keys, records, names, source references, evidence,
|
||||
or replacement values. Omit any uncertain or unsafe group.
|
||||
Return each selected candidate's supplied contextual descriptor exactly: its
|
||||
`name` and complete ordered `source_refs`. A group must contain at least two
|
||||
supplied descriptors, and its `canonical` descriptor must be one of its
|
||||
members. Do not invent names, ranges, records, evidence, or replacement values.
|
||||
Omit any uncertain or unsafe group.
|
||||
|
||||
@@ -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.
|
||||
@@ -24,12 +24,13 @@ An incompatible shape change requires a new schema version.
|
||||
|
||||
Both extraction and normalization require an `item_registry` reference bound to
|
||||
an earlier normalized `dnd/item-registry` artifact. The registry is immutable
|
||||
for an operation and contributes only its ordered `{id,name}` projection after
|
||||
the shared evidence message. It is never occurrence evidence.
|
||||
for an operation and contributes names-only grounding after the shared 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
|
||||
with an unknown ID or mismatched name is rejected as invalid model output; the
|
||||
configured pipeline may retry it and never accepts a partial artifact.
|
||||
with an unknown or ambiguous selected name is rejected as invalid model output;
|
||||
the configured pipeline may retry it and never accepts a partial artifact.
|
||||
Normalization and validation remain defense in depth for artifacts entering
|
||||
through other boundaries: normalization canonicalizes a recognized name by ID,
|
||||
preserves unknown values for the registry validator, and the registry validator
|
||||
|
||||
@@ -85,9 +85,10 @@ for later artifacts.
|
||||
|
||||
`dnd/item-occurrences` requires one approved item registry through its
|
||||
`item_registry` reference slot for both extraction and normalization. Its
|
||||
consumer receives only an ordered, source-free `{id,name}` projection; the
|
||||
registry’s source references are never occurrence evidence. Unknown IDs and
|
||||
mismatched pairs are rejected by the occurrence contract. See the
|
||||
consumer receives names-only grounding; Notarius resolves the selected name
|
||||
into the unchanged exact durable ID/name pair. The registry’s source references
|
||||
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
|
||||
wire contract, [Configuration](../config.md#d-d-reference-slots) for binding
|
||||
rules and validator selection, and the [JSON output contract](json-output.md)
|
||||
|
||||
@@ -73,10 +73,12 @@ complete canonical evidence sequence.
|
||||
|
||||
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
|
||||
registry provides identity grounding only: unknown IDs and mismatched ID/name
|
||||
pairs 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.
|
||||
registry provides identity grounding only. The model selects a supplied
|
||||
contextual name-and-registry-reference descriptor, and Notarius resolves it
|
||||
into the exact durable ID/name pair. Unknown, partial, or ambiguous selections
|
||||
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
|
||||
and generated-handoff compatibility, [D&D module internals](../internal/dnd.md)
|
||||
|
||||
@@ -83,9 +83,11 @@ not evidence for later artifacts.
|
||||
## Consumers and publication
|
||||
|
||||
`dnd/location-occurrences` requires one approved location registry through its
|
||||
`location_registry` reference slot. Its prompt receives an ordered source-free `{id,
|
||||
name}` projection and must not treat registry references as occurrence
|
||||
evidence. See the [location-occurrence artifact](dnd-location-occurrence-artifacts.md)
|
||||
`location_registry` reference slot. Its prompt receives contextual selectors
|
||||
containing a canonical name and registry references; Notarius resolves a
|
||||
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 binding rules, and the [JSON output contract](json-output.md) for
|
||||
publication.
|
||||
|
||||
@@ -67,10 +67,12 @@ for uncertain classification.
|
||||
|
||||
## Identity, evidence, and order
|
||||
|
||||
The required normalized [NPC registry artifact](dnd-npc-registry-artifacts.md) resolves
|
||||
the exact `{npc_id, name}` pair. Unknown IDs and names that do not match their
|
||||
ID are rejected; normalization does not repair names by similarity. Registry
|
||||
references are provenance only and never replace an occurrence's own evidence.
|
||||
The required normalized [NPC registry artifact](dnd-npc-registry-artifacts.md)
|
||||
supplies names-only contextual grounding to the model. Notarius resolves the
|
||||
selected name and writes the exact `{npc_id, name}` pair. An unknown or
|
||||
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
|
||||
mention; that provenance alone does not create a `mentioned` occurrence. Each
|
||||
occurrence remains a separately cited fact in the current transcript.
|
||||
|
||||
@@ -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)
|
||||
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).
|
||||
Occurrence consumers receive an ordered source-free `{id,name}` projection;
|
||||
spells, combat turns, and the [enemy-event artifact](dnd-enemy-event-artifacts.md)
|
||||
receive names-only grounding for actor or subject display. None of these
|
||||
Occurrence consumers receive names-only grounding; Notarius resolves the
|
||||
selected canonical name and writes the unchanged exact durable ID/name pair.
|
||||
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)
|
||||
owns the `npc_registry` binding rules.
|
||||
The [JSON output contract](json-output.md) defines publication, and
|
||||
|
||||
@@ -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.
|
||||
|
||||
Normalizers are deterministic for spells, combat turns, item occurrences, NPC
|
||||
occurrences, scene descriptions, enemy events, and location occurrences. They canonicalize display
|
||||
values and evidence, use source-document order for stable output, and issue
|
||||
bounded warnings for changes or collapsed duplicates. The NPC and location
|
||||
normalizers are intentional exceptions: each first produces a deterministic
|
||||
candidate set, then may use a bounded structured-LLM proposal to reconcile
|
||||
identity groups. Invalid or unusable proposals retain the deterministic result
|
||||
and surface retry or fallback diagnostics; the model does not directly replace
|
||||
durable records.
|
||||
occurrences, scene descriptions, enemy events, and location occurrences. They
|
||||
canonicalize display values and evidence, use source-document order for stable
|
||||
output, and issue bounded warnings for changes or collapsed duplicates. NPC,
|
||||
item, and location registry normalizers are intentional exceptions: each first
|
||||
produces a deterministic candidate set, then may use a bounded structured-LLM
|
||||
proposal to reconcile identity groups. The proposal selects supplied
|
||||
descriptors—names with their candidate source references—not durable IDs.
|
||||
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
|
||||
|
||||
@@ -144,11 +147,19 @@ producer provenance; consumers resolve the handed-off artifact into an
|
||||
immutable, validated projection for each operation. External files are checked
|
||||
during preparation, while generated artifacts are resolved at the handoff.
|
||||
|
||||
NPC, location, and item registries project ordered, source-free `{id, name}`
|
||||
pairs to their respective occurrence extractors and normalizers. Exact ID/name
|
||||
matching preserves every identity the registry recognizes, including same-name
|
||||
locations with distinct source anchors. The NPC registry additionally supplies
|
||||
names-only actor grounding to spells, combat turns, and enemy events.
|
||||
NPC and item registry consumers receive names-only grounding. Location
|
||||
consumers receive a contextual selector containing the canonical name and the
|
||||
registry references needed to distinguish same-name places. The calling module
|
||||
resolves those supplied selections locally and maps them into the unchanged
|
||||
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
|
||||
classification data, not scene prose or evidence, and exist to route combat
|
||||
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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
|
||||
@@ -24,6 +24,9 @@ adapter does not own source evidence, artifact conversion, normalization, or
|
||||
durable schemas. Those responsibilities remain with the module and its
|
||||
[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
|
||||
named material to a PromptKit inline artifact while preserving its origin URI,
|
||||
passes the supplied request session through to PromptKit's direct per-run
|
||||
|
||||
@@ -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-
|
||||
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
|
||||
timeouts. Concurrency control belongs in shared runtime plumbing rather than in
|
||||
individual modules.
|
||||
|
||||
350
docs/roadmap/contextual-entity-grounding.md
Normal file
350
docs/roadmap/contextual-entity-grounding.md
Normal file
@@ -0,0 +1,350 @@
|
||||
# Contextual Entity Grounding
|
||||
|
||||
## Purpose
|
||||
|
||||
Notarius should use an LLM for semantic interpretation of source evidence, not
|
||||
for referential-integrity work that deterministic code can perform more
|
||||
reliably. D&D prompts must therefore stop requiring models to reproduce opaque
|
||||
machine identifiers such as hash-derived entity IDs. Models should identify
|
||||
entities through human-readable, evidence-grounded context, after which
|
||||
Notarius resolves the selection and attaches the canonical durable identity.
|
||||
|
||||
This roadmap defines the policy, affected D&D prompt families, and intended
|
||||
end state. The ordered work needed to reach that state is maintained in
|
||||
[Implementation Plan](implementation.md).
|
||||
|
||||
## User Intent
|
||||
|
||||
The change has two goals:
|
||||
|
||||
- prevent otherwise useful model responses from failing because a long,
|
||||
non-semantic string was copied incorrectly; and
|
||||
- avoid spending prompt space and model effort on exact-copy work that provides
|
||||
no semantic value.
|
||||
|
||||
The policy is not a ban on identifiers. Durable artifacts may continue to use
|
||||
application-owned IDs, and prompts may continue to request source-unit ranges
|
||||
that locate evidence. The policy governs which identity work is assigned to
|
||||
the model.
|
||||
|
||||
## Policy
|
||||
|
||||
An LLM-facing prompt input or private response schema must not require a model
|
||||
to reproduce an opaque machine identifier when Notarius can establish the same
|
||||
association deterministically.
|
||||
|
||||
Opaque machine identifiers include cryptographic hashes, UUIDs, digests,
|
||||
database keys, durable entity IDs, and other tokens whose characters do not
|
||||
carry source-grounded meaning for the model. These values may remain in
|
||||
application state, provenance, diagnostics, checkpoints, and durable artifact
|
||||
contracts, but should be omitted from model-visible material when they do not
|
||||
help the model make a semantic decision.
|
||||
|
||||
The intended responsibility boundary is:
|
||||
|
||||
- the model decides which contextual entity is supported by the supplied
|
||||
evidence and returns the bounded semantic facts requested by the module;
|
||||
- the calling module validates that the contextual selection resolves to
|
||||
exactly one supplied candidate;
|
||||
- deterministic code supplies the canonical display value and durable entity
|
||||
ID; and
|
||||
- existing validators continue to enforce referential integrity at later
|
||||
artifact boundaries.
|
||||
|
||||
Transcript `start_unit_id` and `end_unit_id` values are permitted. They are
|
||||
contextual source coordinates and form part of the evidence contract rather
|
||||
than arbitrary identity tokens. Prompt IDs, schema IDs, fingerprints, session
|
||||
IDs, and digests may also remain in runtime metadata that the model is not
|
||||
asked to reproduce.
|
||||
|
||||
Short request-local labels are a narrowly permitted fallback only when a
|
||||
contextual selector cannot uniquely represent the available choices without
|
||||
unreasonable prompt cost. Such a label must be compact, scoped to one request,
|
||||
validated against the supplied candidate set, and never reused as a durable
|
||||
identity. Current D&D occurrence and reconciliation prompts should be designed
|
||||
without this exception; adopting it later requires a concrete demonstrated
|
||||
need and documented rationale.
|
||||
|
||||
## Current State
|
||||
|
||||
The initial NPC, item, and location registry extractors already follow the
|
||||
desired pattern: the model returns contextual names and evidence, and Notarius
|
||||
derives durable IDs afterward. Spells, combat turns, and enemy events use
|
||||
contextual actor names rather than requiring hash-derived NPC IDs.
|
||||
|
||||
Two current prompt families diverge from that pattern:
|
||||
|
||||
1. `dnd/npc-occurrences`, `dnd/item-occurrences`, and
|
||||
`dnd/location-occurrences` place durable registry IDs in model-visible
|
||||
projections and require the private LLM response to repeat those IDs.
|
||||
2. NPC-, item-, and location-registry normalization use the shared entity
|
||||
reconciliation prompt, which labels candidates with opaque
|
||||
`candidate-000001`-style keys and requires the model to copy those keys into
|
||||
duplicate-group proposals.
|
||||
|
||||
The durable occurrence artifacts correctly retain canonical entity IDs. The
|
||||
problem is the private model transport contract, not the published artifact
|
||||
contract.
|
||||
|
||||
## Target Architecture
|
||||
|
||||
### Model proposals and durable artifacts
|
||||
|
||||
Private LLM response types must express contextual semantic proposals rather
|
||||
than reuse the durable artifact type when that type contains an opaque entity
|
||||
ID. The extractor maps a validated private response into the existing durable
|
||||
artifact only after identity resolution succeeds.
|
||||
|
||||
No affected durable artifact kind, media type, schema ID, schema version, or
|
||||
JSON field changes as part of this work. NPC, item, and location occurrence
|
||||
artifacts continue to publish their exact canonical ID/name pair. Registry
|
||||
artifacts likewise retain their IDs and evidence.
|
||||
|
||||
The private schemas and prompt declarations may remain at their current `v1`
|
||||
identities because Notarius is pre-release and these are not external
|
||||
contracts. Their content hashes, mapping-policy fingerprints, and affected
|
||||
prompt fingerprints must change so incompatible checkpoints are not reused.
|
||||
|
||||
### NPC occurrence grounding
|
||||
|
||||
The NPC occurrence prompt receives an ordered names-only projection of the
|
||||
normalized NPC registry. Its private response contains the canonical NPC name,
|
||||
occurrence kind, and current-transcript source ranges, but no `npc_id`.
|
||||
|
||||
The extractor resolves the returned name under the existing NPC comparison
|
||||
policy. Resolution must produce exactly one registry entry. It then writes that
|
||||
entry's canonical display name and durable ID into the `dnd.NPCOccurrence`.
|
||||
An unknown or ambiguous selection invalidates the extraction operation; the
|
||||
extractor must not guess, use fuzzy matching, silently omit the record, or
|
||||
accept a partial response.
|
||||
|
||||
### Item occurrence grounding
|
||||
|
||||
The item occurrence prompt receives an ordered names-only projection of the
|
||||
normalized item registry. Its private response contains the canonical item
|
||||
name, occurrence kind, kind-specific fields, and current-transcript source
|
||||
ranges, but no `item_id`.
|
||||
|
||||
The extractor resolves the returned name under the existing item comparison
|
||||
and identity policies. Resolution must produce exactly one registry entry,
|
||||
whose canonical name and durable ID are attached deterministically. Unknown or
|
||||
ambiguous selections invalidate the complete extraction operation rather than
|
||||
being guessed, repaired by similarity, or dropped.
|
||||
|
||||
### Location occurrence grounding
|
||||
|
||||
Location identity cannot always be resolved from a display name alone: the
|
||||
current registry intentionally permits same-name locations with distinct
|
||||
source anchors. The location occurrence prompt must therefore receive a
|
||||
contextual registry descriptor that contains the canonical display name plus
|
||||
the minimum source-grounded registry evidence needed to distinguish same-name
|
||||
records. It must not contain the durable `location:sha256:...` value.
|
||||
|
||||
The private response uses two required selector fields: `name` and
|
||||
`registry_refs`. For a comparison-unique canonical name, `registry_refs` is an
|
||||
empty array and Notarius resolves the name under the location comparison
|
||||
policy. For a name shared by multiple registry records, `registry_refs`
|
||||
contains that record's complete canonically ordered registry ranges as
|
||||
`start_unit_id` and `end_unit_id` pairs, without `source_id`.
|
||||
|
||||
Every model-facing registry entry uses one fixed shape with required `name`,
|
||||
`registry_refs`, and `context` fields. `context` is an array of strict objects
|
||||
containing only `unit_id` and `text`. Comparison-unique entries use empty
|
||||
`registry_refs` and `context` arrays. Same-name entries use the complete
|
||||
registry-range selector and the bounded context described below. The model
|
||||
returns only `name` and `registry_refs`; it does not reproduce `context`.
|
||||
|
||||
For same-name groups, the projection also supplies bounded transcript units
|
||||
covered by each record's registry ranges so the model receives meaningful
|
||||
identity context rather than coordinates alone. Those ranges must resolve
|
||||
against the current source document, and the resulting contextual selectors
|
||||
must be unique. An invalid range or selector collision prevents the LLM call
|
||||
and fails the operation. Unique-name entries do not repeat registry ranges or
|
||||
context in the selector, preserving compatibility with a valid registry from
|
||||
another source when the name alone is unambiguous.
|
||||
|
||||
The private response separately supplies current-transcript `source_refs` that
|
||||
prove the occurrence. Registry identity evidence and occurrence evidence must
|
||||
remain different fields and must never be merged. The model should omit an
|
||||
occurrence when the transcript does not support choosing among same-name
|
||||
locations. If a returned selector does not resolve to exactly one supplied
|
||||
registry record, the extractor invalidates the complete operation rather than
|
||||
guessing.
|
||||
|
||||
### Registry reconciliation
|
||||
|
||||
The shared entity-reconciliation input replaces opaque candidate keys with
|
||||
contextual candidate descriptors. At minimum, a descriptor contains the
|
||||
candidate's display name and its canonically ordered source-reference ranges;
|
||||
the existing transcript windows remain available for semantic judgment.
|
||||
|
||||
Duplicate-group members and the canonical member in the private response use
|
||||
the same contextual descriptor shape. The shared reconciliation helper maps
|
||||
each descriptor back to exactly one internal candidate before assessing the
|
||||
proposal. Exact deterministic duplicates should already be removed before the
|
||||
LLM call; any remaining descriptor collision makes the affected candidate
|
||||
ineligible for model-assisted reconciliation rather than authorizing an
|
||||
arbitrary choice.
|
||||
|
||||
Existing safety behavior remains in force: groups must contain at least two
|
||||
supplied candidates, the canonical candidate must be a member, groups must not
|
||||
overlap, and domain-specific eligibility rules remain authoritative. Invalid,
|
||||
ambiguous, or unsafe groups are discarded through the existing bounded
|
||||
fallback and diagnostic behavior. The model never directly mutates the
|
||||
durable registry.
|
||||
|
||||
The shared private reconciliation schema and helper must remain domain-neutral
|
||||
within the D&D family. NPC-, item-, and location-specific duplicate policy
|
||||
continues to live in the owning normalizer.
|
||||
|
||||
## Prompt And Asset Changes
|
||||
|
||||
The following LLM-facing assets are in scope:
|
||||
|
||||
- the prompt instructions, registry input fragments, and private response
|
||||
schemas for NPC, item, and location occurrences;
|
||||
- the prompt manifests where input shape or selected fragments change;
|
||||
- the shared D&D entity-reconciliation fragment and private response schema;
|
||||
and
|
||||
- the NPC-, item-, and location-registry normalization prompt inputs that use
|
||||
the shared reconciliation contract.
|
||||
|
||||
Affected projections must exclude durable entity IDs rather than merely stop
|
||||
mentioning them in prose. Prompt instructions should describe the contextual
|
||||
selection rule once at the narrowest owning asset and must preserve the current
|
||||
distinction between registry grounding and transcript evidence.
|
||||
|
||||
Prompt ordering and cache controls should remain unchanged unless the new
|
||||
contextual input requires an intentional manifest change. Unrelated shared
|
||||
prompt bytes should not be edited. Prompt and schema fingerprints should
|
||||
invalidate only the operations whose selected assets or mapping semantics
|
||||
changed.
|
||||
|
||||
## Code And Validation Changes
|
||||
|
||||
The occurrence extractors need private response types and deterministic
|
||||
registry-resolution paths appropriate to their domain. Shared code is
|
||||
appropriate only for demonstrated mechanics that have identical semantics;
|
||||
NPC, item, and location ambiguity policies must not be forced behind a generic
|
||||
resolver merely to reduce line count.
|
||||
|
||||
Registry projections should expose explicit model-facing methods whose names
|
||||
describe whether they are names-only or contextual identity projections. The
|
||||
existing ID/name projections may remain only for deterministic consumers that
|
||||
genuinely require them; they must no longer be wired to an LLM input.
|
||||
|
||||
Mapping-policy and normalization-policy identifiers must be reviewed and
|
||||
advanced wherever their semantics change. Checkpoint fingerprints must cover
|
||||
the new projection content, private schema, prompt assets, and mapping policy,
|
||||
while continuing to exclude irrelevant internal implementation details.
|
||||
|
||||
Durable occurrence normalizers and registry validators remain defense in
|
||||
depth. They continue to validate exact ID/name pairs on artifacts entering
|
||||
through checkpoints, codecs, or other boundaries even though the LLM no longer
|
||||
produces the ID directly.
|
||||
|
||||
## Testing And Evaluation
|
||||
|
||||
Tests should protect the behavioral boundary rather than prompt prose or
|
||||
private helper structure. The completed work should demonstrate that:
|
||||
|
||||
- affected model-facing registry projections do not contain durable entity
|
||||
IDs;
|
||||
- private occurrence schemas reject opaque ID fields and accept the intended
|
||||
contextual shape;
|
||||
- valid contextual selections map to the exact canonical durable ID/name pair;
|
||||
- unknown, mismatched, and ambiguous selections fail without fuzzy matching,
|
||||
partial acceptance, or arbitrary reassignment;
|
||||
- same-name locations remain distinguishable through contextual evidence;
|
||||
- reconciliation preserves equal-name candidates, resolves valid contextual
|
||||
groups, and discards ambiguous or unsafe proposals;
|
||||
- registry evidence never becomes occurrence evidence;
|
||||
- durable codec, normalization, and validator behavior remains compatible; and
|
||||
- representative assembled D&D pipelines still prepare and execute with fake
|
||||
structured-LLM responses.
|
||||
|
||||
Do not add repository-wide prompt-prose snapshots, exact-message-count tests,
|
||||
or a change-detector test that merely scans for today's field names. Focused
|
||||
projection, schema, mapping, fallback, and integration tests are the stable
|
||||
owners of these risks. Model-quality evaluation with representative
|
||||
transcripts remains a manual development aid rather than an offline test gate.
|
||||
|
||||
## Documentation And Architectural Record
|
||||
|
||||
This policy is durable and applies to future modules, so it warrants
|
||||
`docs/adr/0012-resolve-opaque-entity-identifiers-deterministically.md`, which
|
||||
records:
|
||||
|
||||
- the semantic-proposal versus referential-integrity boundary;
|
||||
- why durable opaque IDs are excluded from model response contracts;
|
||||
- why contextual evidence coordinates remain permitted;
|
||||
- the narrowly scoped request-local-label exception;
|
||||
- alternatives including durable IDs, names-only matching, and short opaque
|
||||
handles; and
|
||||
- the consequences for private schemas, deterministic resolution, debugging,
|
||||
and ambiguous identities.
|
||||
|
||||
`docs/policy/architecture.md` states the general LLM boundary invariant and
|
||||
links to the ADR. `docs/internal/dnd.md` describes the concrete occurrence
|
||||
projections, contextual reconciliation selectors, resolution and failure
|
||||
behavior, and the continued separation of registry grounding from occurrence
|
||||
evidence. `docs/internal/llm.md` contains only a short clarification that
|
||||
caller-owned modules, not PromptKit or the transport adapter, resolve
|
||||
contextual model selections into application identities.
|
||||
|
||||
The NPC, item, and location occurrence and registry integration documents must
|
||||
continue to own their durable wire contracts, while removing current claims
|
||||
that the model-facing consumer projection contains `{id,name}` or that the raw
|
||||
LLM response supplies the durable ID. They should instead explain that
|
||||
Notarius resolves contextual model output and publishes the same exact durable
|
||||
ID/name pair. No public schema examples need to remove those IDs.
|
||||
|
||||
The generic LLM-assisted deduplication entry in `docs/roadmap/future.md` must be
|
||||
reconciled with this policy: stable IDs may exist inside deterministic state,
|
||||
but a future model-facing proposal should use contextual selectors or a
|
||||
documented request-local-label exception rather than durable IDs.
|
||||
|
||||
## Compatibility And Operational Effects
|
||||
|
||||
This work intentionally changes private prompt inputs, private structured
|
||||
responses, and mapping semantics. It will invalidate affected checkpoints
|
||||
through existing prompt, schema, projection, and policy fingerprints. No
|
||||
manual checkpoint migration is required.
|
||||
|
||||
Durable D&D artifacts and generated-reference compatibility remain unchanged.
|
||||
Operators do not receive new configuration fields or CLI controls. The feature
|
||||
does not change PromptKit, provider routing, profile selection, retries,
|
||||
concurrency, or public output placement.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
This work does not:
|
||||
|
||||
- remove canonical IDs from durable registries or occurrence artifacts;
|
||||
- change occurrence categories, evidence rules, or registry identity policy;
|
||||
- add fuzzy, probabilistic, or embedding-based entity resolution;
|
||||
- allow registry provenance to substitute for occurrence evidence;
|
||||
- introduce a general entity graph or cross-artifact identity framework;
|
||||
- redesign unrelated D&D prompts or their schemas;
|
||||
- implement the future generic deduplication normalizer; or
|
||||
- add provider-specific prompt behavior.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
The target state is complete when:
|
||||
|
||||
- no maintained D&D prompt requires a model to reproduce a durable opaque
|
||||
entity ID;
|
||||
- current D&D reconciliation prompts no longer require opaque candidate keys;
|
||||
- NPC, item, and location occurrence LLM outputs are resolved
|
||||
deterministically into their unchanged durable artifacts;
|
||||
- same-name location and reconciliation cases remain safe and unambiguous;
|
||||
- invalid contextual selections preserve the existing extraction-failure or
|
||||
normalization-fallback semantics appropriate to their stage;
|
||||
- affected checkpoint identities change without altering public schema
|
||||
versions;
|
||||
- focused and repository-wide tests pass offline;
|
||||
- the ADR, architecture invariant, D&D internal guide, LLM internal guide,
|
||||
relevant integration contracts, and future roadmap accurately describe
|
||||
their canonical portions of the implemented policy; and
|
||||
- no unrelated code, prompt behavior, or public contract changes are included.
|
||||
@@ -29,13 +29,15 @@ not as committed release dates.
|
||||
- Add a reusable normalizer that asks an LLM to identify duplicate sets in a
|
||||
list and propose one replacement element for each set.
|
||||
- Define the minimum domain-neutral input contract, initially an ordered list
|
||||
whose elements have stable unique IDs. Artifact-kind registrations or
|
||||
adapters may expose that structure without moving domain rules into the
|
||||
generic package.
|
||||
whose elements retain stable unique IDs as internal deterministic state.
|
||||
Model proposals use contextual descriptors, or a specifically justified
|
||||
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,
|
||||
require every referenced ID to exist, reject overlapping or malformed groups,
|
||||
prevent unrelated insertion or deletion, and apply only approved replacement
|
||||
operations in code.
|
||||
resolve every supplied descriptor or local label exactly, reject overlapping
|
||||
or malformed groups, prevent unrelated insertion or deletion, and apply only
|
||||
approved replacement operations in code.
|
||||
- Preserve provenance needed for audit and downstream validation, and emit
|
||||
warnings describing every collapsed group.
|
||||
- Evaluate batching and context-window limits before applying the normalizer to
|
||||
|
||||
612
docs/roadmap/implementation.md
Normal file
612
docs/roadmap/implementation.md
Normal 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.
|
||||
@@ -219,14 +219,15 @@ func TestMaintainedCompleteExamplePublishesRegistryBackedEntityOccurrences(t *te
|
||||
}
|
||||
for _, request := range locationRequests {
|
||||
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") {
|
||||
t.Fatalf("location occurrence registry input = %q, want source-free ID grounding", registryInput.Content)
|
||||
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 contextual selector grounding", registryInput.Content)
|
||||
}
|
||||
}
|
||||
for _, test := range []struct {
|
||||
promptID string
|
||||
slot string
|
||||
name string
|
||||
promptID string
|
||||
slot string
|
||||
name string
|
||||
requiresIDs bool
|
||||
}{
|
||||
{promptID: npcoccurrences.PromptID, slot: "npc_registry", name: "Kesh"},
|
||||
{promptID: itemoccurrences.PromptID, slot: "item_registry", name: "Moonblade"},
|
||||
@@ -237,8 +238,9 @@ func TestMaintainedCompleteExamplePublishesRegistryBackedEntityOccurrences(t *te
|
||||
}
|
||||
for _, request := range requests {
|
||||
registryInput := request.Inputs[test.slot]
|
||||
if !strings.Contains(string(registryInput.Content), test.name) || !strings.Contains(string(registryInput.Content), `"id"`) || strings.Contains(string(registryInput.Content), "source_refs") {
|
||||
t.Fatalf("%s registry input = %q, want source-free ID grounding", test.promptID, registryInput.Content)
|
||||
hasID := strings.Contains(string(registryInput.Content), `"id"`)
|
||||
if !strings.Contains(string(registryInput.Content), test.name) || hasID != test.requiresIDs || strings.Contains(string(registryInput.Content), "source_refs") {
|
||||
t.Fatalf("%s registry input = %q, want source-free configured grounding", test.promptID, registryInput.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,16 +321,16 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque
|
||||
} else {
|
||||
var registry struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(request.Inputs["item_registry"].Content, ®istry); err != nil {
|
||||
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))
|
||||
}
|
||||
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:
|
||||
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 {
|
||||
var registry struct {
|
||||
NPCs []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"npcs"`
|
||||
}
|
||||
if err := json.Unmarshal(request.Inputs["npc_registry"].Content, ®istry); err != nil {
|
||||
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")
|
||||
}
|
||||
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 {
|
||||
content = []byte(`{"occurrences":[]}`)
|
||||
}
|
||||
case locationoccurrences.PromptID:
|
||||
var registry 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"`
|
||||
}
|
||||
if err := json.Unmarshal(request.Inputs["location_registry"].Content, ®istry); 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")
|
||||
}
|
||||
unitID := 1
|
||||
locationID := registry.Locations[0].ID
|
||||
location := registry.Locations[0]
|
||||
if combatScene {
|
||||
unitID = 7
|
||||
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:
|
||||
content = []byte(`{"events":[{"name":"Kesh","kind":"fled","source_refs":[{"start_unit_id":10,"end_unit_id":10}]}]}`)
|
||||
default:
|
||||
|
||||
@@ -10,24 +10,34 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
type orderedItemOccurrenceResponse struct {
|
||||
value itemOccurrenceResponse
|
||||
type orderedItemOccurrence struct {
|
||||
value dnd.ItemOccurrence
|
||||
earliest int
|
||||
hasEvidence bool
|
||||
}
|
||||
|
||||
func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOrder, sourceID string, registry *itemregistry.Registry) error {
|
||||
if response == nil {
|
||||
return nil
|
||||
func canonicalItemOccurrenceList(response extractionResponse, order shared.SourceRefOrder, sourceID string, registry *itemregistry.Registry) (dnd.ItemOccurrenceList, error) {
|
||||
if response.Occurrences == nil {
|
||||
return dnd.ItemOccurrenceList{}, nil
|
||||
}
|
||||
ordered := make([]orderedItemOccurrenceResponse, len(response.Occurrences))
|
||||
for index := range response.Occurrences {
|
||||
if err := validateRegistryPair(index, response.Occurrences[index], registry); err != nil {
|
||||
return err
|
||||
ordered := make([]orderedItemOccurrence, len(response.Occurrences))
|
||||
for index, occurrence := range response.Occurrences {
|
||||
item, found := registry.Lookup(occurrence.Name)
|
||||
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)
|
||||
ordered[index] = orderedItemOccurrenceResponse{
|
||||
value: response.Occurrences[index],
|
||||
refs := order.Canonicalize(itemOccurrenceSourceRefs(occurrence.SourceRefs, sourceID))
|
||||
earliest, hasEvidence := order.EarliestValid(refs)
|
||||
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,
|
||||
hasEvidence: hasEvidence,
|
||||
}
|
||||
@@ -41,49 +51,11 @@ func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOr
|
||||
}
|
||||
return ordered[left].earliest < ordered[right].earliest
|
||||
})
|
||||
for index := range ordered {
|
||||
response.Occurrences[index] = ordered[index].value
|
||||
occurrences := make([]dnd.ItemOccurrence, len(ordered))
|
||||
for index, occurrence := range ordered {
|
||||
occurrences[index] = occurrence.value
|
||||
}
|
||||
return 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}
|
||||
return dnd.ItemOccurrenceList{Occurrences: occurrences}, nil
|
||||
}
|
||||
|
||||
func itemOccurrenceSourceRefs(refs []itemOccurrenceSourceRefResponse, sourceID string) []source.SourceRef {
|
||||
@@ -97,17 +69,6 @@ func itemOccurrenceSourceRefs(refs []itemOccurrenceSourceRefResponse, sourceID s
|
||||
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 {
|
||||
if value == nil {
|
||||
return nil
|
||||
|
||||
@@ -20,7 +20,7 @@ const (
|
||||
ItemRegistryMaxBytes = itemregistry.MaxBytes
|
||||
)
|
||||
|
||||
const mappingPolicy = "dnd.item_occurrences.extract_mapping.v1"
|
||||
const mappingPolicy = "dnd.item_occurrences.extract_mapping.v2"
|
||||
|
||||
var requiredCapabilities = []string{
|
||||
"chunks",
|
||||
@@ -161,10 +161,11 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
||||
}, &response); err != nil {
|
||||
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]{Value: canonicalItemOccurrenceList(response, req.Source.ID)}, nil
|
||||
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{Value: value}, nil
|
||||
}
|
||||
|
||||
func ModuleSpec() pipeline.ModuleSpec {
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
func TestExtractGroundsOccurrencesInRequiredRegistry(t *testing.T) {
|
||||
id := itemidentity.DeriveID("Torch")
|
||||
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.References = itemRegistryReferences(t)
|
||||
@@ -24,55 +24,38 @@ func TestExtractGroundsOccurrencesInRequiredRegistry(t *testing.T) {
|
||||
t.Fatalf("occurrences = %#v", result.Value.Occurrences)
|
||||
}
|
||||
input := client.requests[0].Inputs[ItemRegistryReferenceSlot]
|
||||
if input.Name != ItemRegistryReferenceSlot || string(input.Content) == "" {
|
||||
t.Fatalf("registry prompt input = %#v", input)
|
||||
if input.Name != ItemRegistryReferenceSlot || string(input.Content) != `{"items":[{"name":"Torch"}]}` || strings.Contains(string(input.Content), "item:sha256:") {
|
||||
t.Fatalf("registry prompt input = %#v, want names-only projection", input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRejectsInvalidRegistryPairs(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) {
|
||||
func TestExtractCanonicalizesComparisonEquivalentNames(t *testing.T) {
|
||||
id := itemidentity.DeriveID("Torch")
|
||||
client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{
|
||||
{ItemID: id, Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)},
|
||||
{ItemID: "unknown", Name: "Unknown", Kind: "lost", From: "party", SourceRefs: responseRefs(2, 2)},
|
||||
{Name: " tORCH ", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)},
|
||||
}}}
|
||||
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].item_id") {
|
||||
t.Fatalf("Extract() error = %v, want occurrence index and item ID", err)
|
||||
if err != nil {
|
||||
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 {
|
||||
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) {
|
||||
id := itemidentity.DeriveID("Torch")
|
||||
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}]}]}`)}
|
||||
client := &fakeItemOccurrencesLLMClient{content: []byte(`{"occurrences":[{"name":"Torch","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_segment":1,"end_segment":1}]}]}`)}
|
||||
req := extractionRequest()
|
||||
req.References = itemRegistryReferences(t)
|
||||
result, err := newExtractor(t, client, req.References).Extract(context.Background(), req)
|
||||
|
||||
@@ -5,7 +5,6 @@ type extractionResponse struct {
|
||||
}
|
||||
|
||||
type itemOccurrenceResponse struct {
|
||||
ItemID string `json:"item_id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Quantity *int `json:"quantity,omitempty"`
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestPromptAssetsPrepareItemOccurrencePrompt(t *testing.T) {
|
||||
"players": promptkit.Inline("item-occurrence-player"),
|
||||
"party": 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 {
|
||||
|
||||
@@ -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}}}}}
|
||||
}
|
||||
|
||||
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 {
|
||||
return []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}}
|
||||
}
|
||||
|
||||
@@ -19,11 +19,11 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) {
|
||||
}
|
||||
valid := map[string]any{"occurrences": []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}},
|
||||
},
|
||||
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}},
|
||||
},
|
||||
}}
|
||||
@@ -41,6 +41,7 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) {
|
||||
{"missing occurrences", map[string]any{}},
|
||||
{"missing occurrence name", map[string]any{"occurrences": []any{withoutField(responseOccurrence(), "name")}}},
|
||||
{"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 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}})}}},
|
||||
@@ -68,7 +69,7 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) {
|
||||
|
||||
func responseOccurrence() 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{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package locationoccurrences
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -15,17 +17,21 @@ type orderedOccurrence struct {
|
||||
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 {
|
||||
return dnd.LocationOccurrenceList{}
|
||||
return dnd.LocationOccurrenceList{}, nil
|
||||
}
|
||||
ordered := make([]orderedOccurrence, len(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))
|
||||
earliest, hasEvidence := order.EarliestValid(refs)
|
||||
ordered[index] = orderedOccurrence{value: dnd.LocationOccurrence{
|
||||
LocationID: occurrence.LocationID,
|
||||
Name: occurrence.Name,
|
||||
LocationID: location.ID,
|
||||
Name: location.Name,
|
||||
Kind: dnd.LocationOccurrenceKind(occurrence.Kind),
|
||||
SourceRefs: refs,
|
||||
}, earliest: earliest, hasEvidence: hasEvidence}
|
||||
@@ -39,7 +45,7 @@ func canonicalOccurrenceList(response extractionResponse, order shared.SourceRef
|
||||
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 {
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
|
||||
const (
|
||||
Key = "dnd/location-occurrences"
|
||||
mappingPolicy = "dnd.location_occurrences.extract_mapping.v1"
|
||||
mappingPolicy = "dnd.location_occurrences.extract_mapping.v2"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -121,7 +121,7 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
{Name: "prompt", Value: e.promptSHA},
|
||||
{Name: "response_schema", Value: e.responseSchemaSHA},
|
||||
{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() {
|
||||
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
|
||||
inputs := shared.PromptInputs(sourceInput, req.References)
|
||||
inputs[LocationRegistryReferenceSlot] = registry.PromptInput()
|
||||
inputs[LocationRegistryReferenceSlot] = grounding.PromptInput()
|
||||
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
||||
StageName: Key, PromptID: PromptID, PromptVersion: SchemaVersion,
|
||||
ProfileID: req.LLMProfile, SessionID: req.SessionID, Inputs: inputs,
|
||||
}, &response); err != nil {
|
||||
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 {
|
||||
|
||||
@@ -14,19 +14,20 @@ import (
|
||||
"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"
|
||||
locationregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/registry"
|
||||
)
|
||||
|
||||
func TestExtractMapsKindsOrdersOccurrencesAndPreservesIndependentFacts(t *testing.T) {
|
||||
locations := locationRegistry(t, "The Tavern", "The Tavern")
|
||||
first, second := locations.Locations[0], locations.Locations[1]
|
||||
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
|
||||
{LocationID: second.ID, Name: second.Name, Kind: "mentioned", SourceRefs: occurrenceRefs(30, 30)},
|
||||
{LocationID: first.ID, Name: first.Name, Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)},
|
||||
{LocationID: first.ID, Name: first.Name, Kind: "recalled", SourceRefs: occurrenceRefs(10, 10)},
|
||||
{LocationID: first.ID, Name: first.Name, Kind: "planned", SourceRefs: occurrenceRefs(10, 10)},
|
||||
{LocationID: first.ID, Name: first.Name, Kind: "visited", SourceRefs: append(occurrenceRefs(10, 10), occurrenceRefs(10, 10)...)},
|
||||
{LocationID: first.ID, Name: first.Name, Kind: "visited", SourceRefs: occurrenceRefs(20, 20)},
|
||||
{LocationID: first.ID, Name: first.Name, Kind: "visited", SourceRefs: occurrenceRefs(10, 10)},
|
||||
{Name: second.Name, RegistryRefs: registryRefs(second), Kind: "mentioned", SourceRefs: occurrenceRefs(30, 30)},
|
||||
{Name: first.Name, RegistryRefs: registryRefs(first), Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)},
|
||||
{Name: first.Name, RegistryRefs: registryRefs(first), Kind: "recalled", SourceRefs: occurrenceRefs(10, 10)},
|
||||
{Name: first.Name, RegistryRefs: registryRefs(first), Kind: "planned", SourceRefs: occurrenceRefs(10, 10)},
|
||||
{Name: first.Name, RegistryRefs: registryRefs(first), Kind: "visited", SourceRefs: append(occurrenceRefs(10, 10), occurrenceRefs(10, 10)...)},
|
||||
{Name: first.Name, RegistryRefs: registryRefs(first), Kind: "visited", SourceRefs: occurrenceRefs(20, 20)},
|
||||
{Name: first.Name, RegistryRefs: registryRefs(first), Kind: "visited", SourceRefs: occurrenceRefs(10, 10)},
|
||||
}}}
|
||||
references := registryReferences(t, locations)
|
||||
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")
|
||||
first, second := locations.Locations[0], locations.Locations[1]
|
||||
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)
|
||||
req := extractionRequest()
|
||||
@@ -67,10 +68,10 @@ func TestExtractUsesIDsNamesAndCurrentTranscriptEvidenceOnly(t *testing.T) {
|
||||
t.Fatalf("occurrence = %#v", occurrence)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
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")
|
||||
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)
|
||||
req := extractionRequest()
|
||||
req.References = references
|
||||
result, err := newExtractor(t, client, references).Extract(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
for _, occurrences := range [][]occurrenceResponse{
|
||||
{{Name: "Unknown", RegistryRefs: []locationregistry.RegistryRef{}, Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)}},
|
||||
{{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")
|
||||
location := locations.Locations[0]
|
||||
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)
|
||||
req := extractionRequest()
|
||||
@@ -135,7 +160,7 @@ func TestExtractResolvesGeneratedRegistryAtOperationTimeAndDoesNotMutateResponse
|
||||
if err != nil || result.Value.Occurrences[0].Name != "The Mill" {
|
||||
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)
|
||||
}
|
||||
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])
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
t.Helper()
|
||||
locations := make([]dnd.Location, len(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}
|
||||
}
|
||||
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 {
|
||||
t.Helper()
|
||||
content, err := locationcodec.New().Encode(locations)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package locationoccurrences
|
||||
|
||||
import locationregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/registry"
|
||||
|
||||
type extractionResponse struct {
|
||||
Occurrences []occurrenceResponse `json:"occurrences"`
|
||||
}
|
||||
|
||||
type occurrenceResponse struct {
|
||||
LocationID string `json:"location_id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
SourceRefs []occurrenceSourceRefResponse `json:"source_refs"`
|
||||
Name string `json:"name"`
|
||||
RegistryRefs []locationregistry.RegistryRef `json:"registry_refs"`
|
||||
Kind string `json:"kind"`
|
||||
SourceRefs []occurrenceSourceRefResponse `json:"source_refs"`
|
||||
}
|
||||
|
||||
type occurrenceSourceRefResponse struct {
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestRegisterPromptAssetsPreparesLocationOccurrencePrompt(t *testing.T) {
|
||||
PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: "location-occurrences-test",
|
||||
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(" "),
|
||||
"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 {
|
||||
@@ -41,11 +41,11 @@ func TestRegisterPromptAssetsPreparesLocationOccurrencePrompt(t *testing.T) {
|
||||
}
|
||||
var registryMessage string
|
||||
for _, message := range prepared.Messages {
|
||||
if strings.Contains(message.Content, "normalized location registry") {
|
||||
if strings.Contains(message.Content, "contextual location registry") {
|
||||
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)
|
||||
}
|
||||
content := make([]string, len(prepared.Messages))
|
||||
@@ -54,7 +54,7 @@ func TestRegisterPromptAssetsPreparesLocationOccurrencePrompt(t *testing.T) {
|
||||
}
|
||||
rendered := strings.Join(content, "\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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,16 +18,17 @@ func TestResponseSchemaRestrictsPrivateOccurrenceStructureAndKinds(t *testing.T)
|
||||
t.Fatalf("schema = %#v", schema)
|
||||
}
|
||||
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}},
|
||||
}}}
|
||||
if err := validateSchema(t, valid, schema.JSONSchema); err != nil {
|
||||
t.Fatalf("valid response rejected: %v", err)
|
||||
}
|
||||
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["unexpected"] = true },
|
||||
func(value map[string]any) { value["registry_refs"].([]any)[0].(map[string]any)["start_unit_id"] = 0 },
|
||||
func(value map[string]any) {
|
||||
value["source_refs"].([]any)[0].(map[string]any)["source_id"] = "assigned later"
|
||||
},
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package npcoccurrences
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
@@ -50,12 +52,16 @@ func canonicalizeOccurrence(occurrence *occurrenceResponse, order shared.SourceR
|
||||
return order.EarliestValid(refs)
|
||||
}
|
||||
|
||||
func canonicalOccurrenceList(response extractionResponse, sourceID string) dnd.NPCOccurrenceList {
|
||||
func canonicalOccurrenceList(response extractionResponse, sourceID string, registry *npcregistry.Registry) (dnd.NPCOccurrenceList, error) {
|
||||
occurrences := make([]dnd.NPCOccurrence, len(response.Occurrences))
|
||||
for index, occurrence := range response.Occurrences {
|
||||
canonical, ok := registry.Lookup(occurrence.Name)
|
||||
if !ok {
|
||||
return dnd.NPCOccurrenceList{}, fmt.Errorf("occurrences[%d].name is not in the NPC registry", index)
|
||||
}
|
||||
occurrences[index] = dnd.NPCOccurrence{
|
||||
NPCID: occurrence.NPCID,
|
||||
Name: occurrence.Name,
|
||||
NPCID: canonical.ID,
|
||||
Name: canonical.Name,
|
||||
Kind: dnd.NPCOccurrenceKind(occurrence.Kind),
|
||||
SourceRefs: canonicalSourceRefs(occurrence.SourceRefs, sourceID),
|
||||
}
|
||||
@@ -63,7 +69,7 @@ func canonicalOccurrenceList(response extractionResponse, sourceID string) dnd.N
|
||||
if response.Occurrences == nil {
|
||||
occurrences = nil
|
||||
}
|
||||
return dnd.NPCOccurrenceList{Occurrences: occurrences}
|
||||
return dnd.NPCOccurrenceList{Occurrences: occurrences}, nil
|
||||
}
|
||||
|
||||
func canonicalSourceRefs(refs []occurrenceSourceRefResponse, sourceID string) []source.SourceRef {
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
const (
|
||||
Key = "dnd/npc-occurrences"
|
||||
mappingPolicy = "dnd.npc_occurrences.extract_mapping.v2"
|
||||
mappingPolicy = "dnd.npc_occurrences.extract_mapping.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -132,7 +132,7 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
{Name: "prompt", Value: e.promptSHA},
|
||||
{Name: "response_schema", Value: e.responseSchemaSHA},
|
||||
{Name: "mapping_policy", Value: mappingPolicy},
|
||||
{Name: "npc_registry", Value: seeded.IdentityPromptInput().Digest},
|
||||
{Name: "npc_registry", Value: seeded.IdentityDigest()},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
||||
|
||||
var response extractionResponse
|
||||
inputs := shared.PromptInputs(sourceInput, req.References)
|
||||
inputs[NPCRegistryReferenceSlot] = npcRegistry.IdentityPromptInput()
|
||||
inputs[NPCRegistryReferenceSlot] = npcRegistry.PromptInput()
|
||||
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
||||
StageName: Key,
|
||||
PromptID: PromptID,
|
||||
@@ -170,26 +170,13 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
||||
return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("complete structured output: %w", err)
|
||||
}
|
||||
canonicalizeResponse(&response, order, req.Source.ID)
|
||||
value := canonicalOccurrenceList(response, req.Source.ID)
|
||||
if err := validateRegistryPairs(value, npcRegistry); err != nil {
|
||||
return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("validate NPC registry pairs: %w", err)
|
||||
value, err := canonicalOccurrenceList(response, req.Source.ID, npcRegistry)
|
||||
if err != nil {
|
||||
return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("resolve NPC names against registry: %w", err)
|
||||
}
|
||||
return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{Value: value}, nil
|
||||
}
|
||||
|
||||
func validateRegistryPairs(value dnd.NPCOccurrenceList, registry *npcregistry.Registry) error {
|
||||
for index, occurrence := range value.Occurrences {
|
||||
canonical, ok := registry.LookupID(occurrence.NPCID)
|
||||
if !ok {
|
||||
return fmt.Errorf("occurrences[%d].npc_id is not in the NPC registry", index)
|
||||
}
|
||||
if occurrence.Name != canonical.Name {
|
||||
return fmt.Errorf("occurrences[%d].name does not match npc_id", index)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ModuleSpec() pipeline.ModuleSpec {
|
||||
return pipeline.ModuleSpec{
|
||||
Key: Key,
|
||||
|
||||
@@ -18,13 +18,13 @@ import (
|
||||
|
||||
func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
|
||||
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
|
||||
{NPCID: identity.DeriveID("Other"), Name: "Other", Kind: "other", SourceRefs: occurrenceRefs(30, 30)},
|
||||
{NPCID: identity.DeriveID("Opponent"), Name: "Opponent", Kind: "combat_opponent", SourceRefs: occurrenceRefs(20, 20)},
|
||||
{NPCID: identity.DeriveID("Ally"), Name: "Ally", Kind: "combat_ally", SourceRefs: occurrenceRefs(5, 5)},
|
||||
{NPCID: identity.DeriveID("Speaker"), Name: "Speaker", Kind: "dialogue", SourceRefs: append(occurrenceRefs(2, 2), occurrenceRefs(2, 2)...)},
|
||||
{NPCID: identity.DeriveID("Present"), Name: "Present", Kind: "noncombat_presence", SourceRefs: occurrenceRefs(7, 7)},
|
||||
{NPCID: identity.DeriveID("Mentioned"), Name: "Mentioned", Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)},
|
||||
{NPCID: identity.DeriveID("Invalid"), Name: "Invalid", Kind: "unsupported", SourceRefs: occurrenceRefs(0, 0)},
|
||||
{Name: "Other", Kind: "other", SourceRefs: occurrenceRefs(30, 30)},
|
||||
{Name: "Opponent", Kind: "combat_opponent", SourceRefs: occurrenceRefs(20, 20)},
|
||||
{Name: "Ally", Kind: "combat_ally", SourceRefs: occurrenceRefs(5, 5)},
|
||||
{Name: "Speaker", Kind: "dialogue", SourceRefs: append(occurrenceRefs(2, 2), occurrenceRefs(2, 2)...)},
|
||||
{Name: "Present", Kind: "noncombat_presence", SourceRefs: occurrenceRefs(7, 7)},
|
||||
{Name: "Mentioned", Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)},
|
||||
{Name: "Invalid", Kind: "unsupported", SourceRefs: occurrenceRefs(0, 0)},
|
||||
}}}
|
||||
references := requiredRegistryReferences(t, "Mentioned", "Speaker", "Present", "Ally", "Opponent", "Other", "Invalid")
|
||||
req := extractionRequest()
|
||||
@@ -51,6 +51,9 @@ func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
|
||||
if refs := result.Value.Occurrences[1].SourceRefs; !reflect.DeepEqual(refs, []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}) {
|
||||
t.Fatalf("canonical source refs = %#v", refs)
|
||||
}
|
||||
if id := result.Value.Occurrences[0].NPCID; id != identity.DeriveID("Mentioned") {
|
||||
t.Fatalf("durable NPC ID = %q, want registry identity", id)
|
||||
}
|
||||
if invalid := result.Value.Occurrences[6]; invalid.Name != "Invalid" || invalid.Kind != "unsupported" || !reflect.DeepEqual(invalid.SourceRefs, []source.SourceRef{{SourceID: "session-alpha"}}) {
|
||||
t.Fatalf("invalid candidate = %#v, want preserved values with current source identity", invalid)
|
||||
}
|
||||
@@ -61,14 +64,14 @@ func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
|
||||
|
||||
func TestExtractUsesDocumentOrderForReferencesAndOccurrences(t *testing.T) {
|
||||
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
|
||||
{NPCID: identity.DeriveID("Later"), Name: "Later", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)},
|
||||
{NPCID: identity.DeriveID("First"), Name: "First", Kind: "mentioned", SourceRefs: []occurrenceSourceRefResponse{
|
||||
{Name: "Later", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)},
|
||||
{Name: "First", Kind: "mentioned", SourceRefs: []occurrenceSourceRefResponse{
|
||||
{StartUnitID: 10, EndUnitID: 10},
|
||||
{StartUnitID: 30, EndUnitID: 30},
|
||||
{StartUnitID: 30, EndUnitID: 30},
|
||||
{StartUnitID: 999, EndUnitID: 0},
|
||||
}},
|
||||
{NPCID: identity.DeriveID("Second"), Name: "Second", Kind: "other", SourceRefs: occurrenceRefs(30, 30)},
|
||||
{Name: "Second", Kind: "other", SourceRefs: occurrenceRefs(30, 30)},
|
||||
}}}
|
||||
references := requiredRegistryReferences(t, "Later", "First", "Second")
|
||||
req := extractionRequest()
|
||||
@@ -107,9 +110,9 @@ func TestNewRequiresLLMAndRejectsAmbiguousReferenceSets(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractUsesRegistryIDsAndCurrentTranscriptEvidence(t *testing.T) {
|
||||
func TestExtractUsesNamesOnlyRegistryAndCurrentTranscriptEvidence(t *testing.T) {
|
||||
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{
|
||||
NPCID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10),
|
||||
Name: "Mira Thorn", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10),
|
||||
}}}}
|
||||
references := requiredRegistryReferences(t, "Mira Thorn", "Hooded Guard")
|
||||
req := extractionRequest()
|
||||
@@ -119,10 +122,10 @@ func TestExtractUsesRegistryIDsAndCurrentTranscriptEvidence(t *testing.T) {
|
||||
}
|
||||
request := client.requests[0]
|
||||
registry := request.Inputs[NPCRegistryReferenceSlot]
|
||||
if registry.Name != NPCRegistryReferenceSlot || registry.MediaType != "application/json" || string(registry.Content) != `{"npcs":[{"id":"`+identity.DeriveID("Mira Thorn")+`","name":"Mira Thorn"},{"id":"`+identity.DeriveID("Hooded Guard")+`","name":"Hooded Guard"}]}` {
|
||||
t.Fatalf("registry prompt input = %#v, want exact ID and name projection", registry)
|
||||
if registry.Name != NPCRegistryReferenceSlot || registry.MediaType != "application/json" || string(registry.Content) != `{"npcs":[{"name":"Mira Thorn"},{"name":"Hooded Guard"}]}` {
|
||||
t.Fatalf("registry prompt input = %#v, want names-only projection", registry)
|
||||
}
|
||||
for _, forbidden := range []string{"other-session", "start_unit_id"} {
|
||||
for _, forbidden := range []string{"npc:sha256:", "other-session", "start_unit_id"} {
|
||||
if strings.Contains(string(registry.Content), forbidden) {
|
||||
t.Fatalf("registry prompt input leaked %q: %s", forbidden, registry.Content)
|
||||
}
|
||||
@@ -162,24 +165,37 @@ func TestExtractRequiresBoundRegistryBeforeLLMCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRejectsUnknownIDsAndMismatchedNames(t *testing.T) {
|
||||
func TestExtractRejectsUnknownNamesWithoutPartialResult(t *testing.T) {
|
||||
references := requiredRegistryReferences(t, "Mira Thorn")
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
occurrence occurrenceResponse
|
||||
want string
|
||||
}{
|
||||
{"unknown ID", occurrenceResponse{NPCID: "npc:unknown", Name: "Mira Thorn", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)}, "npc_id is not in the NPC registry"},
|
||||
{"mismatched name", occurrenceResponse{NPCID: identity.DeriveID("Mira Thorn"), Name: "Hooded Guard", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)}, "name does not match npc_id"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{test.occurrence}}}
|
||||
req := extractionRequest()
|
||||
req.References = references
|
||||
if _, err := newExtractor(t, client, references).Extract(context.Background(), req); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Extract() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
|
||||
{Name: "Mira Thorn", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)},
|
||||
{Name: "Unknown NPC", Kind: "mentioned", SourceRefs: occurrenceRefs(20, 20)},
|
||||
}}}
|
||||
req := extractionRequest()
|
||||
req.References = references
|
||||
result, err := newExtractor(t, client, references).Extract(context.Background(), req)
|
||||
if err == nil || !strings.Contains(err.Error(), "name is not in the NPC registry") {
|
||||
t.Fatalf("Extract() error = %v, want unknown name failure", err)
|
||||
}
|
||||
if len(result.Value.Occurrences) != 0 {
|
||||
t.Fatalf("Extract() returned partial result = %#v", result.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractCanonicalizesComparisonEquivalentRegistryNames(t *testing.T) {
|
||||
references := requiredRegistryReferences(t, "Mira Thorn")
|
||||
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{
|
||||
Name: " mIRA\u2003thorn ", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10),
|
||||
}}}}
|
||||
req := extractionRequest()
|
||||
req.References = references
|
||||
result, err := newExtractor(t, client, references).Extract(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Extract() error = %v", err)
|
||||
}
|
||||
occurrence := result.Value.Occurrences[0]
|
||||
if occurrence.Name != "Mira Thorn" || occurrence.NPCID != identity.DeriveID("Mira Thorn") {
|
||||
t.Fatalf("canonical occurrence = %#v", occurrence)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +208,7 @@ func TestExtractResolvesGeneratedRegistryAtOperationTime(t *testing.T) {
|
||||
if _, err := extractor.Extract(context.Background(), req); err != nil {
|
||||
t.Fatalf("Extract() error = %v", err)
|
||||
}
|
||||
if input := client.requests[0].Inputs[NPCRegistryReferenceSlot]; string(input.Content) != `{"npcs":[{"id":"`+identity.DeriveID("Mira Thorn")+`","name":"Mira Thorn"}]}` || input.OriginURI != "" {
|
||||
if input := client.requests[0].Inputs[NPCRegistryReferenceSlot]; string(input.Content) != `{"npcs":[{"name":"Mira Thorn"}]}` || input.OriginURI != "" {
|
||||
t.Fatalf("generated registry prompt input = %#v", input)
|
||||
}
|
||||
metadata := extractor.ManifestMetadata()
|
||||
@@ -221,6 +237,24 @@ func TestExtractAcceptsEmptyBoundRegistryAndEmptyResponse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRejectsNonemptyResponseForEmptyBoundRegistry(t *testing.T) {
|
||||
content, err := npccodec.New().Encode(dnd.NPCRegistry{NPCs: []dnd.NPC{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{NPCRegistryReferenceSlot: {
|
||||
Slot: contracts.ReferenceSlot{Name: NPCRegistryReferenceSlot},
|
||||
Items: []contracts.ReferenceItem{{SlotName: NPCRegistryReferenceSlot, MediaType: npccodec.MediaType, Content: content}},
|
||||
}}}
|
||||
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{Name: "Mira Thorn", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)}}}}
|
||||
req := extractionRequest()
|
||||
req.References = references
|
||||
result, err := newExtractor(t, client, references).Extract(context.Background(), req)
|
||||
if err == nil || !strings.Contains(err.Error(), "name is not in the NPC registry") || len(result.Value.Occurrences) != 0 {
|
||||
t.Fatalf("Extract() = %#v, %v; want no accepted occurrences", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRejectsInvalidRequestsAndProviderFailures(t *testing.T) {
|
||||
references := requiredRegistryReferences(t, "Mira Thorn")
|
||||
valid := extractionRequest()
|
||||
|
||||
@@ -5,7 +5,6 @@ type extractionResponse struct {
|
||||
}
|
||||
|
||||
type occurrenceResponse struct {
|
||||
NPCID string `json:"npc_id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
SourceRefs []occurrenceSourceRefResponse `json:"source_refs"`
|
||||
|
||||
@@ -6,13 +6,13 @@ import (
|
||||
)
|
||||
|
||||
func TestExtractionResponsePreservesValidatorOwnedSemantics(t *testing.T) {
|
||||
content := []byte(`{"occurrences":[{"npc_id":"","name":"","kind":"unsupported","source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`)
|
||||
content := []byte(`{"occurrences":[{"name":"","kind":"unsupported","source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`)
|
||||
var response extractionResponse
|
||||
if err := json.Unmarshal(content, &response); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
occurrence := response.Occurrences[0]
|
||||
if occurrence.NPCID != "" || occurrence.Name != "" || occurrence.Kind != "unsupported" || occurrence.SourceRefs[0] != (occurrenceSourceRefResponse{StartUnitID: 0, EndUnitID: -1}) {
|
||||
if occurrence.Name != "" || occurrence.Kind != "unsupported" || occurrence.SourceRefs[0] != (occurrenceSourceRefResponse{StartUnitID: 0, EndUnitID: -1}) {
|
||||
t.Fatalf("decoded response = %#v", occurrence)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestRegisterPromptAssetsAndPrepareOccurrencePrompt(t *testing.T) {
|
||||
"players": promptkit.Inline("occurrence-player"),
|
||||
"party": promptkit.Inline("Mira: ranger"),
|
||||
"glossary": promptkit.Inline("Greencloak: title"),
|
||||
"npc_registry": promptkit.Inline(`{"npcs":[{"id":"npc:sha256:test","name":"occurrence-npc"}]}`),
|
||||
"npc_registry": promptkit.Inline(`{"npcs":[{"name":"occurrence-npc"}]}`),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -42,9 +42,9 @@ func TestResponseSchemaOwnsOnlyPrivateStructuralContract(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, mutate := range []func(map[string]any){
|
||||
func(record map[string]any) { delete(record, "npc_id") },
|
||||
func(record map[string]any) { delete(record, "name") },
|
||||
func(record map[string]any) { record["kind"] = 1 },
|
||||
func(record map[string]any) { record["npc_id"] = "npc:sha256:opaque" },
|
||||
func(record map[string]any) { record["unexpected"] = true },
|
||||
func(record map[string]any) {
|
||||
record["source_refs"].([]any)[0].(map[string]any)["source_id"] = "assigned later"
|
||||
@@ -81,7 +81,7 @@ func TestResponseSchemaIsDefensiveAndContentSafe(t *testing.T) {
|
||||
|
||||
func validOccurrenceResponse() map[string]any {
|
||||
return map[string]any{"occurrences": []any{map[string]any{
|
||||
"npc_id": "npc:sha256:test", "name": "Mira Thorn", "kind": "dialogue",
|
||||
"name": "Mira Thorn", "kind": "dialogue",
|
||||
"source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}},
|
||||
}}}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ func (r *Registry) Digest() string {
|
||||
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.
|
||||
func (r *Registry) ProjectionDigest() string {
|
||||
if r == nil {
|
||||
@@ -202,8 +202,8 @@ func (r *Registry) Count() int {
|
||||
return len(r.list.Items)
|
||||
}
|
||||
|
||||
// PromptInput returns the ordered ID/name registry projection as a content-safe
|
||||
// prompt input. Evidence and reference provenance are omitted.
|
||||
// PromptInput returns the ordered names-only registry projection as a
|
||||
// content-safe prompt input. Durable IDs, evidence, and provenance are omitted.
|
||||
func (r *Registry) PromptInput() contracts.LLMInputMaterial {
|
||||
if r == nil {
|
||||
return contracts.LLMInputMaterial{}
|
||||
@@ -242,7 +242,6 @@ func semanticDigest(content []byte) string {
|
||||
}
|
||||
|
||||
type projectedItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
@@ -253,7 +252,7 @@ type projectedItemRegistry struct {
|
||||
func promptProjection(list dnd.ItemRegistry) ([]byte, error) {
|
||||
projection := projectedItemRegistry{Items: make([]projectedItem, len(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)
|
||||
}
|
||||
|
||||
@@ -25,16 +25,16 @@ func TestResolveUnboundRegistryHasExactEmptyProjection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveProjectsOrderedIDsAndNamesWithoutEvidence(t *testing.T) {
|
||||
func TestResolveProjectsOrderedNamesWithoutEvidence(t *testing.T) {
|
||||
registry := resolveRegistry(t, fixture())
|
||||
if !registry.Bound() || registry.Digest() == "" || registry.Count() != 2 {
|
||||
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 {
|
||||
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) {
|
||||
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" {
|
||||
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[0].Name = "changed"
|
||||
|
||||
187
internal/modules/dnd/locations/registry/grounding.go
Normal file
187
internal/modules/dnd/locations/registry/grounding.go
Normal 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...)
|
||||
}
|
||||
@@ -18,21 +18,20 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
ReferenceSlot = "location_registry"
|
||||
MaxBytes = 1048576
|
||||
emptyPrompt = `{"locations":[]}`
|
||||
ReferenceSlot = "location_registry"
|
||||
MaxBytes = 1048576
|
||||
emptyRegistryContent = `{"locations":[]}`
|
||||
)
|
||||
|
||||
// Registry is an immutable, validated location registry prepared for prompt
|
||||
// grounding. All accessors return defensive copies.
|
||||
type Registry struct {
|
||||
bound bool
|
||||
list dnd.LocationRegistry
|
||||
canonical []byte
|
||||
digest string
|
||||
projectionDigest string
|
||||
promptInput contracts.LLMInputMaterial
|
||||
lookupByID map[string]int
|
||||
bound bool
|
||||
list dnd.LocationRegistry
|
||||
canonical []byte
|
||||
digest string
|
||||
identityDigest string
|
||||
lookupByID map[string]int
|
||||
}
|
||||
|
||||
// Resolver selects and memoizes immutable location registry views.
|
||||
@@ -99,14 +98,13 @@ func locationReferenceSpec() registryresolver.ReferenceSpec {
|
||||
}
|
||||
|
||||
func emptyRegistry() *Registry {
|
||||
content := []byte(emptyPrompt)
|
||||
projectionDigest := semanticDigest(content)
|
||||
content := []byte(emptyRegistryContent)
|
||||
identityDigest := semanticDigest(content)
|
||||
return &Registry{
|
||||
list: dnd.LocationRegistry{Locations: []dnd.Location{}},
|
||||
canonical: append([]byte(nil), content...),
|
||||
projectionDigest: projectionDigest,
|
||||
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, locationcodec.MediaType, content, projectionDigest, ""),
|
||||
lookupByID: map[string]int{},
|
||||
list: dnd.LocationRegistry{Locations: []dnd.Location{}},
|
||||
canonical: append([]byte(nil), content...),
|
||||
identityDigest: identityDigest,
|
||||
lookupByID: map[string]int{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,20 +127,19 @@ func loadRegistry(referenceContent []byte) (*Registry, error) {
|
||||
for index, location := range list.Locations {
|
||||
lookupByID[location.ID] = index
|
||||
}
|
||||
projection, err := promptProjection(list)
|
||||
projection, err := identityProjection(list)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode location prompt projection: %w", err)
|
||||
}
|
||||
digest := semanticDigest(content)
|
||||
projectionDigest := semanticDigest(projection)
|
||||
identityDigest := semanticDigest(projection)
|
||||
return &Registry{
|
||||
bound: true,
|
||||
list: list,
|
||||
canonical: append([]byte(nil), content...),
|
||||
digest: digest,
|
||||
projectionDigest: projectionDigest,
|
||||
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, locationcodec.MediaType, projection, projectionDigest, ""),
|
||||
lookupByID: lookupByID,
|
||||
bound: true,
|
||||
list: list,
|
||||
canonical: append([]byte(nil), content...),
|
||||
digest: digest,
|
||||
identityDigest: identityDigest,
|
||||
lookupByID: lookupByID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -182,13 +179,13 @@ func (r *Registry) Digest() string {
|
||||
return r.digest
|
||||
}
|
||||
|
||||
// ProjectionDigest returns the digest of the exact source-free prompt
|
||||
// projection, including for an unbound or empty registry.
|
||||
func (r *Registry) ProjectionDigest() string {
|
||||
// IdentityDigest returns the digest of the ordered ID/name identity projection
|
||||
// used by deterministic consumers.
|
||||
func (r *Registry) IdentityDigest() string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r.projectionDigest
|
||||
return r.identityDigest
|
||||
}
|
||||
|
||||
// Count returns the number of validated location records.
|
||||
@@ -199,15 +196,6 @@ func (r *Registry) Count() int {
|
||||
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.
|
||||
func (r *Registry) Lookup(id string) (dnd.Location, bool) {
|
||||
if r == nil {
|
||||
@@ -231,19 +219,19 @@ func semanticDigest(content []byte) string {
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
type projectedLocation struct {
|
||||
type identityProjectedLocation struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type projectedLocationRegistry struct {
|
||||
Locations []projectedLocation `json:"locations"`
|
||||
type identityProjectedLocationRegistry struct {
|
||||
Locations []identityProjectedLocation `json:"locations"`
|
||||
}
|
||||
|
||||
func promptProjection(list dnd.LocationRegistry) ([]byte, error) {
|
||||
projection := projectedLocationRegistry{Locations: make([]projectedLocation, len(list.Locations))}
|
||||
func identityProjection(list dnd.LocationRegistry) ([]byte, error) {
|
||||
projection := identityProjectedLocationRegistry{Locations: make([]identityProjectedLocation, len(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)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package registry
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -12,36 +14,26 @@ import (
|
||||
"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{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := registry.PromptInput()
|
||||
if registry.Bound() || registry.Digest() != "" || registry.Count() != 0 || string(input.Content) != emptyPrompt {
|
||||
t.Fatalf("unbound registry = %#v, input = %#v", registry, input)
|
||||
if registry.Bound() || registry.Digest() != "" || registry.Count() != 0 || string(registry.CanonicalBytes()) != emptyRegistryContent {
|
||||
t.Fatalf("unbound registry = %#v", registry)
|
||||
}
|
||||
if registry.ProjectionDigest() == "" || input.Digest != registry.ProjectionDigest() || input.OriginURI != "" {
|
||||
t.Fatalf("projection digest/input = %q/%#v", registry.ProjectionDigest(), input)
|
||||
if registry.IdentityDigest() == "" {
|
||||
t.Fatal("unbound registry identity digest is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveProjectsOrderedLocationsWithoutEvidence(t *testing.T) {
|
||||
func TestResolveRetainsSeparateDurableAndIdentityDigests(t *testing.T) {
|
||||
registry := resolveList(t, registryFixture())
|
||||
if !registry.Bound() || registry.Count() != 2 || registry.Digest() == "" {
|
||||
t.Fatalf("registry identity = bound %t count %d digest %q", registry.Bound(), registry.Count(), registry.Digest())
|
||||
}
|
||||
projection := string(registry.PromptInput().Content)
|
||||
if !strings.Contains(projection, `"locations":[{"id":`) || !strings.Contains(projection, `"name":"The Tavern"`) || !strings.Contains(projection, `"name":"The Tavern"},{"id":`) {
|
||||
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())
|
||||
if registry.IdentityDigest() == "" || registry.Digest() == registry.IdentityDigest() {
|
||||
t.Fatalf("full/identity digests = %q/%q", registry.Digest(), registry.IdentityDigest())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,12 +52,10 @@ func TestRegistryLookupUsesIDAndReturnsDefensiveCopies(t *testing.T) {
|
||||
locations[0].SourceRefs[0].SourceID = "changed"
|
||||
canonical := registry.CanonicalBytes()
|
||||
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" {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -189,3 +179,180 @@ func item(content []byte) contracts.ReferenceItem {
|
||||
func referenceSet(items ...contracts.ReferenceItem) contracts.ReferenceSet {
|
||||
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}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
const (
|
||||
Key = "dnd/item-registry"
|
||||
PromptID = "dnd.item_registry.normalize"
|
||||
normalizationPolicy = "dnd.item_registry.normalize.v1"
|
||||
normalizationPolicy = "dnd.item_registry.normalize.v2"
|
||||
semanticContextPolicy = "dnd.entity_reconcile.context.v1"
|
||||
semanticContextRadius = 2
|
||||
NormalizationPolicy = normalizationPolicy
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -101,7 +102,7 @@ func TestNormalizeAppliesSafeAliasProposal(t *testing.T) {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -261,7 +262,7 @@ func TestRegisterPromptAssetsPreparesItemNormalizationPrompt(t *testing.T) {
|
||||
if err != nil {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -285,10 +286,51 @@ func (c *recordingNormalizerClient) CompleteStructured(_ context.Context, reques
|
||||
if response == "" {
|
||||
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{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 {
|
||||
|
||||
@@ -86,7 +86,7 @@ func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
}
|
||||
return []pipeline.CheckpointFingerprint{
|
||||
{Name: "normalization_policy", Value: normalizationPolicy},
|
||||
{Name: "location_registry", Value: n.locationResolver.Seeded().ProjectionDigest()},
|
||||
{Name: "location_registry", Value: n.locationResolver.Seeded().IdentityDigest()},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
const (
|
||||
Key = "dnd/location-registry"
|
||||
PromptID = "dnd.location_registry.normalize"
|
||||
normalizationPolicy = "dnd.location_registry.normalize.v1"
|
||||
normalizationPolicy = "dnd.location_registry.normalize.v2"
|
||||
semanticContextPolicy = "dnd.entity_reconcile.context.v1"
|
||||
semanticContextRadius = 2
|
||||
NormalizationPolicy = normalizationPolicy
|
||||
|
||||
@@ -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"}]}`}
|
||||
doc := semanticDocument()
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestRegisterPromptAssetsPreparesLocationNormalizationPrompt(t *testing.T) {
|
||||
if err != nil {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -40,14 +40,14 @@ func TestRegisterPromptAssetsPreparesLocationNormalizationPrompt(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if index != 4 && strings.Contains(message.Content, `"windows"`) {
|
||||
|
||||
@@ -3,11 +3,14 @@ package locationregistry
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
|
||||
)
|
||||
|
||||
type recordingLocationNormalizerClient struct {
|
||||
@@ -25,10 +28,51 @@ func (c *recordingLocationNormalizerClient) CompleteStructured(_ context.Context
|
||||
if response == "" {
|
||||
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{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 {
|
||||
|
||||
@@ -92,7 +92,7 @@ func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
}
|
||||
return []pipeline.CheckpointFingerprint{
|
||||
{Name: "normalization_policy", Value: normalizationPolicy},
|
||||
{Name: "npc_registry", Value: n.npcResolver.Seeded().IdentityPromptInput().Digest},
|
||||
{Name: "npc_registry", Value: n.npcResolver.Seeded().IdentityDigest()},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
const (
|
||||
Key = "dnd/npc-registry"
|
||||
PromptID = "dnd.npc_registry.normalize"
|
||||
normalizationPolicy = "dnd.npc_registry.normalize.v3"
|
||||
normalizationPolicy = "dnd.npc_registry.normalize.v4"
|
||||
semanticContextPolicy = "dnd.entity_reconcile.context.v1"
|
||||
NormalizationPolicy = normalizationPolicy
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -156,10 +157,51 @@ func (c *recordingNPCNormalizerClient) CompleteStructured(_ context.Context, req
|
||||
if response == "" {
|
||||
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{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 {
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestRegisterPromptAssetsPreparesNormalizationPrompt(t *testing.T) {
|
||||
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
||||
PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion, ProfileID: "normalize-test-profile",
|
||||
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":[]}]}`),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ package npcregistry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"reflect"
|
||||
@@ -174,8 +175,8 @@ func TestNormalizeDoesNotAccumulateSafeGroupsAcrossAttempts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeExcludesIneligibleRecordsFromSemanticGroups(t *testing.T) {
|
||||
client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000001"}]}`}
|
||||
func TestNormalizeRetainsRecordsWhenAProposalUsesAnUnknownDescriptor(t *testing.T) {
|
||||
client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","unknown"],"canonical":"candidate-000001"}]}`}
|
||||
normalizer := newNormalizer(t, client)
|
||||
doc := semanticDocument()
|
||||
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 {
|
||||
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" {
|
||||
t.Fatalf("result = %#v, want ineligible record excluded but preserved", result)
|
||||
if !strings.Contains(result.Retry.Message, "member_unknown") || result.Value.NPCs[1].Name != "Broken" {
|
||||
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 {
|
||||
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{{
|
||||
Members: keys, Canonical: keys[1],
|
||||
Members: candidateInput.Candidates, Canonical: candidateInput.Candidates[1],
|
||||
}}})
|
||||
groups := reconciliationGroups(assessment, keys)
|
||||
if assessment.DiscardedGroups() != 0 || len(groups) != 1 || groups[0].canonical != 1 {
|
||||
|
||||
@@ -31,8 +31,8 @@ type Registry struct {
|
||||
canonical []byte
|
||||
digest string
|
||||
projectionDigest string
|
||||
identityDigest string
|
||||
promptInput contracts.LLMInputMaterial
|
||||
identityInput contracts.LLMInputMaterial
|
||||
lookupByKey map[string]int
|
||||
lookupByID map[string]int
|
||||
}
|
||||
@@ -110,8 +110,8 @@ func emptyRegistry() *Registry {
|
||||
list: dnd.NPCRegistry{NPCs: []dnd.NPC{}},
|
||||
canonical: append([]byte(nil), content...),
|
||||
projectionDigest: projectionDigest,
|
||||
identityDigest: projectionDigest,
|
||||
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, projectionDigest, ""),
|
||||
identityInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, projectionDigest, ""),
|
||||
lookupByKey: map[string]int{},
|
||||
lookupByID: map[string]int{},
|
||||
}
|
||||
@@ -155,9 +155,9 @@ func loadRegistry(referenceContent []byte) (*Registry, error) {
|
||||
canonical: append([]byte(nil), content...),
|
||||
digest: digest,
|
||||
projectionDigest: projectionDigest,
|
||||
identityDigest: identityProjectionDigest,
|
||||
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, projection, projectionDigest, ""),
|
||||
lookupByKey: lookupByKey,
|
||||
identityInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, identityProjection, identityProjectionDigest, ""),
|
||||
lookupByID: lookupByID,
|
||||
}, nil
|
||||
}
|
||||
@@ -207,6 +207,15 @@ func (r *Registry) ProjectionDigest() string {
|
||||
return r.projectionDigest
|
||||
}
|
||||
|
||||
// IdentityDigest returns the SHA-256 digest of the ordered ID/name identity
|
||||
// projection. It binds durable identities without exposing them to the model.
|
||||
func (r *Registry) IdentityDigest() string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r.identityDigest
|
||||
}
|
||||
|
||||
// Count returns the number of validated NPC records.
|
||||
func (r *Registry) Count() int {
|
||||
if r == nil {
|
||||
@@ -224,15 +233,6 @@ func (r *Registry) PromptInput() contracts.LLMInputMaterial {
|
||||
return r.promptInput.Clone()
|
||||
}
|
||||
|
||||
// IdentityPromptInput returns the ordered ID/name projection for consumers
|
||||
// that must bind output records to exact registry identities.
|
||||
func (r *Registry) IdentityPromptInput() contracts.LLMInputMaterial {
|
||||
if r == nil {
|
||||
return contracts.LLMInputMaterial{}
|
||||
}
|
||||
return r.identityInput.Clone()
|
||||
}
|
||||
|
||||
// Lookup returns the canonical NPC for an exact canonical-name match under the
|
||||
// NPC identity comparison policy.
|
||||
func (r *Registry) Lookup(value string) (dnd.NPC, bool) {
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestResolveUnboundRegistryHasExactEmptyProjection(t *testing.T) {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
input := registry.PromptInput()
|
||||
if registry.Bound() || registry.Digest() != "" || registry.Count() != 0 || string(input.Content) != emptyPrompt || string(registry.IdentityPromptInput().Content) != emptyPrompt {
|
||||
if registry.Bound() || registry.Digest() != "" || registry.Count() != 0 || string(input.Content) != emptyPrompt || registry.IdentityDigest() != registry.ProjectionDigest() {
|
||||
t.Fatalf("registry = %#v input = %#v, want unbound empty registry", registry, input)
|
||||
}
|
||||
if registry.ProjectionDigest() == "" || input.Digest != registry.ProjectionDigest() || input.OriginURI != "" {
|
||||
@@ -46,12 +46,15 @@ func TestResolveKeepsDurableProvenanceAndProjectsOnlyOrderedNames(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityPromptInputProjectsOrderedIDsAndNames(t *testing.T) {
|
||||
func TestIdentityDigestTracksOrderedIDsAndNames(t *testing.T) {
|
||||
registry := resolveList(t, registryFixture())
|
||||
input := registry.IdentityPromptInput()
|
||||
projection, err := identityProjection(registryFixture())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := `{"npcs":[{"id":"` + identity.DeriveID("Mira Thorn") + `","name":"Mira Thorn"},{"id":"` + identity.DeriveID("Captain Vale") + `","name":"Captain Vale"}]}`
|
||||
if string(input.Content) != want || input.Digest == registry.ProjectionDigest() {
|
||||
t.Fatalf("identity projection = %#v, want %s", input, want)
|
||||
if string(projection) != want || registry.IdentityDigest() != semanticDigest(projection) || registry.IdentityDigest() == registry.ProjectionDigest() {
|
||||
t.Fatalf("identity digest = %q, want digest for %s", registry.IdentityDigest(), want)
|
||||
}
|
||||
if npc, ok := registry.LookupID(identity.DeriveID("Mira Thorn")); !ok || npc.Name != "Mira Thorn" {
|
||||
t.Fatalf("LookupID() = %#v, %t", npc, ok)
|
||||
@@ -213,7 +216,7 @@ func TestProjectionIsStableForEquivalentNormalizedRegistries(t *testing.T) {
|
||||
secondList := registryFixture()
|
||||
secondList.NPCs[0].SourceRefs = append(secondList.NPCs[0].SourceRefs, source.SourceRef{SourceID: "session-beta", StartUnitID: 8, EndUnitID: 8})
|
||||
second := resolveList(t, secondList)
|
||||
if !reflect.DeepEqual(first.PromptInput().Content, second.PromptInput().Content) || first.ProjectionDigest() != second.ProjectionDigest() || first.Digest() == second.Digest() {
|
||||
if !reflect.DeepEqual(first.PromptInput().Content, second.PromptInput().Content) || first.ProjectionDigest() != second.ProjectionDigest() || first.IdentityDigest() != second.IdentityDigest() || first.Digest() == second.Digest() {
|
||||
t.Fatalf("projection/full identity mismatch: %#v %#v", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,13 +22,34 @@ type Candidate struct {
|
||||
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.
|
||||
type Materials struct {
|
||||
Candidates contracts.LLMInputMaterial
|
||||
Transcript contracts.LLMInputMaterial
|
||||
|
||||
candidateKeys []string
|
||||
eligible map[string]struct{}
|
||||
candidateKeys []string
|
||||
eligible map[string]struct{}
|
||||
keyBySelector map[string]string
|
||||
collidedSelectors map[string]struct{}
|
||||
}
|
||||
|
||||
// CandidateKeys returns all deterministic keys in candidate input order.
|
||||
@@ -49,18 +70,7 @@ func (m Materials) EligibleCandidateKeys() []string {
|
||||
}
|
||||
|
||||
type candidateInput struct {
|
||||
Candidates []candidateView `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"`
|
||||
Candidates []Selector `json:"candidates"`
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
materials := Materials{
|
||||
candidateKeys: make([]string, len(candidates)),
|
||||
eligible: make(map[string]struct{}),
|
||||
candidateKeys: make([]string, len(candidates)),
|
||||
eligible: make(map[string]struct{}),
|
||||
keyBySelector: make(map[string]string),
|
||||
collidedSelectors: make(map[string]struct{}),
|
||||
}
|
||||
for index := range candidates {
|
||||
key := fmt.Sprintf(candidateKeyFormat, index+1)
|
||||
@@ -103,7 +115,15 @@ func BuildContext(doc *source.SourceDocument, candidates []Candidate, radius int
|
||||
}
|
||||
|
||||
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)
|
||||
cited := make([]bool, len(doc.Units))
|
||||
for candidateIndex, candidate := range candidates {
|
||||
@@ -112,9 +132,23 @@ func BuildContext(doc *source.SourceDocument, candidates []Candidate, radius int
|
||||
continue
|
||||
}
|
||||
key := materials.candidateKeys[candidateIndex]
|
||||
materials.eligible[key] = struct{}{}
|
||||
views = append(views, candidateView{Key: key, Name: candidate.Name, SourceRefs: references})
|
||||
for _, interval := range candidateIntervals {
|
||||
selector := Selector{Name: candidate.Name, SourceRefs: references}
|
||||
lookupKey, err := selectorLookupKey(selector)
|
||||
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++ {
|
||||
cited[position] = true
|
||||
}
|
||||
@@ -145,24 +179,56 @@ func BuildContext(doc *source.SourceDocument, candidates []Candidate, radius int
|
||||
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 {
|
||||
return nil, nil, false
|
||||
}
|
||||
references := make([]candidateSourceRef, 0, len(refs))
|
||||
intervals := make([]sourceInterval, 0, len(refs))
|
||||
type referencedInterval struct {
|
||||
reference SourceRange
|
||||
interval sourceInterval
|
||||
}
|
||||
prepared := make([]referencedInterval, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
if err := index.ValidateRef(ref); err != nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
start, _ := index.Position(ref.StartUnitID)
|
||||
end, _ := index.Position(ref.EndUnitID)
|
||||
references = append(references, candidateSourceRef{StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID})
|
||||
intervals = append(intervals, sourceInterval{start: start, end: end})
|
||||
prepared = append(prepared, referencedInterval{reference: SourceRange{StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}, interval: 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
|
||||
}
|
||||
|
||||
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 {
|
||||
if len(intervals) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"github.com/santhosh-tekuri/jsonschema/v6"
|
||||
)
|
||||
|
||||
func TestBuildContextUsesOpaqueKeysSourceOrderAndOwnedData(t *testing.T) {
|
||||
func TestBuildContextUsesContextualSelectorsSourceOrderAndOwnedData(t *testing.T) {
|
||||
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{
|
||||
{ID: 40, Kind: "narration", Text: "zero"},
|
||||
{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 {
|
||||
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 {
|
||||
t.Fatalf("candidate payload = %#v, want distinct opaque keys for equal names", candidatePayload)
|
||||
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 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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 9}, {ID: 3}, {ID: 8}, {ID: 1}, {ID: 7}}}
|
||||
candidates := []Candidate{
|
||||
@@ -116,22 +160,21 @@ func TestBuildContextExcludesUnsafeReferencesAndCoalescesAdjacentWindows(t *test
|
||||
|
||||
func TestAssessmentRejectsEveryUnsafeProposalCategory(t *testing.T) {
|
||||
materials := preparedMaterials(t, 4, true)
|
||||
keys := materials.CandidateKeys()
|
||||
selectors := materialSelectors(t, materials)
|
||||
unsafe := []struct {
|
||||
name string
|
||||
response ProposalResponse
|
||||
category string
|
||||
}{
|
||||
{"blank member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{"", keys[1]}, Canonical: keys[1]}}}, "member_blank"},
|
||||
{"unknown member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{"candidate-999999", keys[1]}, Canonical: keys[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: []string{keys[0], keys[0]}, Canonical: keys[0]}}}, "repeated_member"},
|
||||
{"too small", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{keys[0]}, Canonical: keys[0]}}}, "fewer_than_two_members"},
|
||||
{"canonical blank", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{keys[0], keys[1]}, Canonical: ""}}}, "canonical_blank"},
|
||||
{"canonical not member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{keys[0], keys[1]}, Canonical: keys[2]}}}, "canonical_not_member"},
|
||||
{"blank member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{{}, selectors[1]}, Canonical: selectors[1]}}}, "member_blank"},
|
||||
{"unknown member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{{Name: "unknown", SourceRefs: []SourceRange{{StartUnitID: 99, EndUnitID: 99}}}, selectors[1]}, Canonical: selectors[1]}}}, "member_unknown"},
|
||||
{"repeated member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{selectors[0], selectors[0]}, Canonical: selectors[0]}}}, "repeated_member"},
|
||||
{"too small", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{selectors[0]}, Canonical: selectors[0]}}}, "fewer_than_two_members"},
|
||||
{"canonical blank", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{selectors[0], selectors[1]}, Canonical: Selector{}}}}, "canonical_blank"},
|
||||
{"canonical not member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{selectors[0], selectors[1]}, Canonical: selectors[2]}}}, "canonical_not_member"},
|
||||
{"overlapping", ProposalResponse{DuplicateGroups: []DuplicateGroup{
|
||||
{Members: []string{keys[0], keys[1]}, Canonical: keys[0]},
|
||||
{Members: []string{keys[1], keys[2]}, Canonical: keys[2]},
|
||||
{Members: []Selector{selectors[0], selectors[1]}, Canonical: selectors[0]},
|
||||
{Members: []Selector{selectors[1], selectors[2]}, Canonical: selectors[2]},
|
||||
}}, "overlapping_member"},
|
||||
}
|
||||
for _, test := range unsafe {
|
||||
@@ -147,9 +190,10 @@ func TestAssessmentRejectsEveryUnsafeProposalCategory(t *testing.T) {
|
||||
func TestAssessmentReturnsNonOverlappingSafeGroupsAndDefensiveCopies(t *testing.T) {
|
||||
materials := preparedMaterials(t, 4, false)
|
||||
keys := materials.CandidateKeys()
|
||||
selectors := materialSelectors(t, materials)
|
||||
assessment := materials.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{
|
||||
{Members: []string{keys[1], keys[0]}, Canonical: keys[1]},
|
||||
{Members: []string{keys[3], keys[2]}, Canonical: keys[2]},
|
||||
{Members: []Selector{selectors[1], selectors[0]}, Canonical: selectors[1]},
|
||||
{Members: []Selector{selectors[3], selectors[2]}, Canonical: selectors[2]},
|
||||
}})
|
||||
groups := assessment.SafeGroups()
|
||||
if assessment.DiscardedGroups() != 0 || len(assessment.Issues()) != 0 || len(groups) != 2 {
|
||||
@@ -183,12 +227,12 @@ func TestSharedResponseSchemaIsPrivateStrictAndRegisterableOnce(t *testing.T) {
|
||||
valid bool
|
||||
}{
|
||||
{"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},
|
||||
{"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 evidence", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{}, "canonical": "candidate-000001", "source_refs": []any{}}}}, false},
|
||||
{"wrong key type", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{1}, "canonical": "candidate-000001"}}}, 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},
|
||||
{"missing selector evidence", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{}, "canonical": map[string]any{"name": "Mira"}}}}, 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) {
|
||||
content, err := json.Marshal(test.value)
|
||||
@@ -236,6 +280,15 @@ func preparedMaterials(t *testing.T, count int, includeIneligible bool) Material
|
||||
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 {
|
||||
output := make([]Candidate, len(input))
|
||||
copy(output, input)
|
||||
|
||||
@@ -6,15 +6,16 @@ import (
|
||||
)
|
||||
|
||||
// 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 {
|
||||
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 {
|
||||
Members []string `json:"members"`
|
||||
Canonical string `json:"canonical"`
|
||||
Members []Selector `json:"members"`
|
||||
Canonical Selector `json:"canonical"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
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 {
|
||||
all := make(map[string]struct{}, len(m.candidateKeys))
|
||||
for _, key := range m.candidateKeys {
|
||||
all[key] = struct{}{}
|
||||
}
|
||||
groups := make([]assessedGroup, len(response.DuplicateGroups))
|
||||
issues := make([]Issue, 0)
|
||||
for groupIndex, proposal := range response.DuplicateGroups {
|
||||
groups[groupIndex] = assessGroup(proposal, all, m.eligible)
|
||||
groups[groupIndex] = m.assessGroup(proposal)
|
||||
for _, category := range groups[groupIndex].issues {
|
||||
issues = append(issues, Issue{GroupIndex: groupIndex, Category: category})
|
||||
}
|
||||
@@ -115,12 +113,13 @@ type assessedGroup struct {
|
||||
conflicting bool
|
||||
}
|
||||
|
||||
func assessGroup(proposal DuplicateGroup, all, eligible map[string]struct{}) assessedGroup {
|
||||
func (m Materials) assessGroup(proposal DuplicateGroup) assessedGroup {
|
||||
issues := make([]string, 0)
|
||||
members := make([]string, 0, len(proposal.Members))
|
||||
seen := make(map[string]struct{}, len(proposal.Members))
|
||||
for _, key := range proposal.Members {
|
||||
if category := keyCategory(key, all, eligible); category != "" {
|
||||
for _, selector := range proposal.Members {
|
||||
key, category := m.selectorKey(selector)
|
||||
if category != "" {
|
||||
issues = append(issues, "member_"+category)
|
||||
continue
|
||||
}
|
||||
@@ -131,31 +130,39 @@ func assessGroup(proposal DuplicateGroup, all, eligible map[string]struct{}) ass
|
||||
seen[key] = struct{}{}
|
||||
members = append(members, key)
|
||||
}
|
||||
canonicalCategory := keyCategory(proposal.Canonical, all, eligible)
|
||||
canonical, canonicalCategory := m.selectorKey(proposal.Canonical)
|
||||
if canonicalCategory != "" {
|
||||
issues = append(issues, "canonical_"+canonicalCategory)
|
||||
}
|
||||
if len(members) < 2 {
|
||||
issues = append(issues, "fewer_than_two_members")
|
||||
}
|
||||
if canonicalCategory == "" && !contains(members, proposal.Canonical) {
|
||||
if canonicalCategory == "" && !contains(members, canonical) {
|
||||
issues = append(issues, "canonical_not_member")
|
||||
}
|
||||
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 {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
return "blank"
|
||||
func (m Materials) selectorKey(selector Selector) (string, string) {
|
||||
if strings.TrimSpace(selector.Name) == "" {
|
||||
return "", "blank"
|
||||
}
|
||||
if _, ok := all[key]; !ok {
|
||||
return "unknown"
|
||||
lookupKey, err := selectorLookupKey(selector)
|
||||
if err != nil {
|
||||
return "", "unknown"
|
||||
}
|
||||
if _, ok := eligible[key]; !ok {
|
||||
return "ineligible"
|
||||
if _, collided := m.collidedSelectors[lookupKey]; collided {
|
||||
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 {
|
||||
|
||||
@@ -61,7 +61,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
if v == nil || v.locationResolver == 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) {
|
||||
|
||||
@@ -72,7 +72,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
}
|
||||
return []pipeline.CheckpointFingerprint{
|
||||
{Name: "policy", Value: policy},
|
||||
{Name: "npc_registry", Value: v.npcResolver.Seeded().IdentityPromptInput().Digest},
|
||||
{Name: "npc_registry", Value: v.npcResolver.Seeded().IdentityDigest()},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
}
|
||||
return []pipeline.CheckpointFingerprint{
|
||||
{Name: "policy", Value: policy},
|
||||
{Name: "npc_registry", Value: v.npcResolver.Seeded().IdentityPromptInput().Digest},
|
||||
{Name: "npc_registry", Value: v.npcResolver.Seeded().IdentityDigest()},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,8 +63,8 @@ func TestLocationRegistryHandoffProducesOccurrencesAndEvidence(t *testing.T) {
|
||||
|
||||
request := client.requestFor(t, locationoccurrences.PromptID)
|
||||
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) {
|
||||
t.Fatalf("occurrence registry input = %#v, want source-free generated ID projection", registryInput)
|
||||
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 contextual selector projection", registryInput)
|
||||
}
|
||||
contextArtifact := outputFileContent(t, output.OutputFiles, "evidence-context.json")
|
||||
evidence, err := evidencecontext.New().Decode(contextArtifact)
|
||||
@@ -218,7 +218,11 @@ func (client *locationHandoffLLMClient) CompleteStructured(ctx context.Context,
|
||||
case locationoccurrences.PromptID:
|
||||
var projection 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"`
|
||||
}
|
||||
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 {
|
||||
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:
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", request.PromptID)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T
|
||||
for name, value := range map[string]string{
|
||||
"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: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",
|
||||
"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",
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestNPCOccurrencePipelineUsesAcceptedRegistryAndCurrentEvidence(t *testing.
|
||||
}
|
||||
|
||||
request := client.requestFor(t, occurrenceextract.PromptID)
|
||||
wantRegistry := `{"npcs":[{"id":"` + identity.DeriveID("Mira Thorn") + `","name":"Mira Thorn"},{"id":"` + identity.DeriveID("Hooded Guard") + `","name":"Hooded Guard"}]}`
|
||||
wantRegistry := `{"npcs":[{"name":"Mira Thorn"},{"name":"Hooded Guard"}]}`
|
||||
if got := string(request.Inputs["npc_registry"].Content); got != wantRegistry {
|
||||
t.Fatalf("occurrence registry input = %s, want names-only projection %s", got, wantRegistry)
|
||||
}
|
||||
@@ -59,6 +59,9 @@ func TestNPCOccurrencePipelineUsesAcceptedRegistryAndCurrentEvidence(t *testing.
|
||||
if first.Name != "Mira Thorn" || string(first.Kind) != "dialogue" || second.Name != "Hooded Guard" || string(second.Kind) != "noncombat_presence" {
|
||||
t.Fatalf("occurrences = %#v, want canonical names, kinds, and source chronology", occurrences)
|
||||
}
|
||||
if first.NPCID != identity.DeriveID("Mira Thorn") || second.NPCID != identity.DeriveID("Hooded Guard") {
|
||||
t.Fatalf("occurrences = %#v, want durable registry IDs", occurrences)
|
||||
}
|
||||
assertOccurrenceEvidence(t, first.SourceRefs)
|
||||
assertOccurrenceEvidence(t, second.SourceRefs)
|
||||
if first.SourceRefs[0].StartUnitID >= second.SourceRefs[0].StartUnitID {
|
||||
@@ -113,8 +116,8 @@ func TestSemanticNPCNormalizationCrossesOrderedRegistryHandoff(t *testing.T) {
|
||||
t.Fatalf("occurrence output step = %q, want ordered downstream step", occurrenceOutput.StepID)
|
||||
}
|
||||
registryRequest := client.requestFor(t, occurrenceextract.PromptID)
|
||||
if got := string(registryRequest.Inputs["npc_registry"].Content); got != `{"npcs":[{"id":"`+identity.DeriveID("Mira Thorn")+`","name":"Mira Thorn"}]}` {
|
||||
t.Fatalf("downstream registry = %s, want one canonical NPC identity", got)
|
||||
if got := string(registryRequest.Inputs["npc_registry"].Content); got != `{"npcs":[{"name":"Mira Thorn"}]}` {
|
||||
t.Fatalf("downstream registry = %s, want one canonical NPC name", got)
|
||||
}
|
||||
manifestContent, err := json.Marshal(output.Manifest)
|
||||
if err != nil {
|
||||
@@ -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}}}}}
|
||||
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:
|
||||
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:
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -266,6 +267,11 @@ func (client *fakeNPCProductionLLMClient) CompleteStructured(_ context.Context,
|
||||
}
|
||||
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:
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
count := 0
|
||||
for _, request := range client.requests {
|
||||
|
||||
Reference in New Issue
Block a user