Minimize D&D NPC extraction contracts

This commit is contained in:
2026-07-22 18:51:26 +00:00
parent 14991cf58b
commit a263a0840c
43 changed files with 486 additions and 1395 deletions

View File

@@ -358,7 +358,7 @@ production validators do not call the LLM and must not set `llm_profile`.
| merge | `appendorder` | Combines typed artifacts in chunk order. | | merge | `appendorder` | Combines typed artifacts in chunk order. |
| normalize | `noop` | Passes merged typed artifacts through unchanged. | | normalize | `noop` | Passes merged typed artifacts through unchanged. |
| normalize | `dnd/spells` | Deterministically canonicalizes and de-duplicates typed D&D spell-list artifacts. | | normalize | `dnd/spells` | Deterministically canonicalizes and de-duplicates typed D&D spell-list artifacts. |
| normalize | `dnd/npcs` | Deterministically consolidates typed D&D NPC-list artifacts by canonical identity and aliases. | | normalize | `dnd/npcs` | Deterministically consolidates typed D&D NPC-list artifacts by canonical name and unions exact evidence. |
| normalize | `dnd/combat-turns` | Deterministically canonicalizes, orders, and de-duplicates typed D&D combat-turn artifacts. | | normalize | `dnd/combat-turns` | Deterministically canonicalizes, orders, and de-duplicates typed D&D combat-turn artifacts. |
| output | `json` | Produces JSON output files for normalized `application/json` lanes. | | output | `json` | Produces JSON output files for normalized `application/json` lanes. |
@@ -376,8 +376,8 @@ production validators do not call the LLM and must not set `llm_profile`.
| `extract/dnd/spells/source_relatedness` | deterministic | Emits warnings when a spell name is not found near its cited source text. | | `extract/dnd/spells/source_relatedness` | deterministic | Emits warnings when a spell name is not found near its cited source text. |
| `extract/dnd/npcs/shape` | deterministic | Rejects malformed D&D NPC-list artifacts. | | `extract/dnd/npcs/shape` | deterministic | Rejects malformed D&D NPC-list artifacts. |
| `extract/dnd/npcs/source_refs` | deterministic | Rejects missing or invalid D&D NPC source references. | | `extract/dnd/npcs/source_refs` | deterministic | Rejects missing or invalid D&D NPC source references. |
| `extract/dnd/npcs/source_relatedness` | deterministic | Emits warnings when an NPC name or alias is not found near its cited source text. | | `extract/dnd/npcs/source_relatedness` | deterministic | Emits warnings when an NPC name is not found near its cited source text. |
| `normalize/dnd/npcs/identity` | deterministic | Rejects invalid canonical IDs, aliases, and cross-record identity collisions. | | `normalize/dnd/npcs/identity` | deterministic | Rejects invalid canonical IDs and duplicate canonical-name or ID ownership. |
| `extract/dnd/combat-turns/shape` | deterministic | Rejects malformed D&D combat-turn artifacts. | | `extract/dnd/combat-turns/shape` | deterministic | Rejects malformed D&D combat-turn artifacts. |
| `extract/dnd/combat-turns/source_refs` | deterministic | Rejects missing or invalid D&D combat-turn source references. | | `extract/dnd/combat-turns/source_refs` | deterministic | Rejects missing or invalid D&D combat-turn source references. |
| `extract/dnd/combat-turns/source_relatedness` | deterministic | Emits warnings when an actor or declared action is not found near cited source text. | | `extract/dnd/combat-turns/source_relatedness` | deterministic | Emits warnings when an actor or declared action is not found near cited source text. |
@@ -470,13 +470,13 @@ slot accepts exactly one `application/json` artifact no larger than 1 MiB. An
external file is decoded and identity-validated during preparation. A external file is decoded and identity-validated during preparation. A
generated binding is validated at the step handoff and is provided to the generated binding is validated at the step handoff and is provided to the
operation through the same reference contract. In both cases, the model operation through the same reference contract. In both cases, the model
receives canonical JSON for caster-name grounding. Registry source references receives a names-only JSON projection for caster-name grounding. Registry source references
may belong to the NPC-producing session and are provenance only; they are not may belong to the NPC-producing session and are provenance only; they are not
spell evidence. Generated reference identity and bounded producer provenance spell evidence. Generated reference identity and bounded producer provenance
are recorded by the framework; NPC names, aliases, content, and paths are not are recorded by the framework; NPC names, content, and paths are not copied
copied into manifests or checkpoint decisions. When absent, the prompt receives into manifests. Consumer-local checkpoint identity uses the names-only
the exact empty value `{"npcs":[]}` and no registry provenance or fingerprint projection digest. When absent, the prompt receives the exact empty value
is recorded. `{"npcs":[]}` with its projection digest and no registry provenance.
The `dnd/spells` normalizer declares the same optional `spell_catalog` slot. The `dnd/spells` normalizer declares the same optional `spell_catalog` slot.
When an overlay is used, bind it independently under When an overlay is used, bind it independently under
@@ -512,9 +512,9 @@ references:
When bound, the combat extractor and normalizer receive the generated registry When bound, the combat extractor and normalizer receive the generated registry
at operation time. Framework provenance and checkpoint dependencies contain its at operation time. Framework provenance and checkpoint dependencies contain its
kind, schema identity, media type, canonical digest, size, and bounded producer kind, schema identity, media type, canonical digest, size, and bounded producer
identity; names, aliases, content, and paths are not recorded there. When identity; names, content, and paths are not recorded there. When
absent, the combat prompt receives the exact empty registry value absent, the combat prompt receives the exact empty registry value
`{"npcs":[]}` and no registry provenance or fingerprint is recorded. `{"npcs":[]}` with its projection digest and no registry provenance.
## State Surfaces ## State Surfaces

View File

@@ -172,7 +172,9 @@ The selectable lane uses extractor and normalizer key `dnd/combat-turns`,
reference contributes raw-file provenance to the run manifest. A generated reference contributes raw-file provenance to the run manifest. A generated
binding contributes artifact kind, schema identity, media type, canonical binding contributes artifact kind, schema identity, media type, canonical
digest, size, and bounded producer provenance. Consumer metadata and checkpoint digest, size, and bounded producer provenance. Consumer metadata and checkpoint
fingerprints contain no registry names, aliases, content, paths, or NPC source fingerprints contain no registry names, content, paths, or NPC source ranges.
ranges. The normalized lane is emitted as `lanes/<lane-id>.json` by the JSON The component-local registry fingerprint covers only the names projected to the
consumer, while manifest provenance retains the full artifact digest. The
normalized lane is emitted as `lanes/<lane-id>.json` by the JSON
output module, and warnings and rejection summaries remain in their shared output module, and warnings and rejection summaries remain in their shared
companion files. companion files.

View File

@@ -41,13 +41,9 @@ Each NPC contains exactly these required fields:
- `id`: `npc:sha256:` followed by 64 lowercase hexadecimal characters; - `id`: `npc:sha256:` followed by 64 lowercase hexadecimal characters;
- `name`: the canonical display name; - `name`: the canonical display name;
- `aliases`: an array of alternate display names, which may be empty;
- `description`: a concise description;
- `relationships`: an array of target/relationship objects, which may be empty;
- `source_refs`: at least one source reference supporting the NPC record. - `source_refs`: at least one source reference supporting the NPC record.
Each relationship contains required `target` and `relationship` strings. Each Each source reference contains required `source_id`, `start_unit_id`, and
source reference contains required `source_id`, `start_unit_id`, and
`end_unit_id`; unit IDs are positive integers. Source document identity, unit `end_unit_id`; unit IDs are positive integers. Source document identity, unit
existence, and range ordering are validated by the source-reference validator existence, and range ordering are validated by the source-reference validator
when the artifact is used by a pipeline. when the artifact is used by a pipeline.
@@ -76,25 +72,28 @@ The production identities are:
The extractor maps private model records to the current source identity and The extractor maps private model records to the current source identity and
assigns deterministic IDs. Extraction validation checks shape, source assigns deterministic IDs. Extraction validation checks shape, source
references, and source relatedness. The normalizer then consolidates records references, and source relatedness. The normalizer then consolidates records
by canonical identity or canonical-name/alias matches, preserves the first only when their normalized canonical names match, preserves the first record's
record's display and output position, unions relationships and exact evidence, display and output position, unions exact evidence, and validates the retained
rewrites unambiguous relationship targets to canonical names, and validates registry's identity. No LLM is used for consolidation.
the retained registry's identity. No LLM is used for consolidation.
The extraction prompt asks only for individually identifiable NPC names backed
by source evidence. Groups, generic roles, invented labels, and descriptive or
relationship enrichment are outside the contract.
The default extraction chain is `generic/valid_json`, The default extraction chain is `generic/valid_json`,
`generic/valid_json_schema`, `extract/dnd/npcs/shape`, `generic/valid_json_schema`, `extract/dnd/npcs/shape`,
`extract/dnd/npcs/source_refs`, and `extract/dnd/npcs/source_refs`, and
`extract/dnd/npcs/source_relatedness`. The normalize chain adds `extract/dnd/npcs/source_relatedness`. The normalize chain adds
`normalize/dnd/npcs/identity` before the source-reference and relatedness `normalize/dnd/npcs/identity` before the source-reference and relatedness
checks. Relatedness emits bounded warnings when an NPC canonical name or checks. Relatedness emits bounded warnings when an NPC canonical name is not
alias is not present near its cited transcript text; opaque campaign present near its cited transcript text; opaque campaign
references may explain such a warning but do not become evidence. references may explain such a warning but do not become evidence.
## Manifest And Artifact Handoff ## Manifest And Artifact Handoff
The NPC extractor records prompt and response-schema identities. The durable The NPC extractor records prompt and response-schema identities. The durable
codec records only `npc_count`; raw names, aliases, descriptions, source codec records only `npc_count`; raw names, source references, and payload bytes
references, and payload bytes stay in the lane file rather than manifest stay in the lane file rather than manifest
metadata. The normalized lane can be consumed by a later ordered step through metadata. The normalized lane can be consumed by a later ordered step through
the registered canonical codec: the registered canonical codec:
@@ -124,9 +123,16 @@ The framework hands only an accepted normalized artifact across the barrier. It
validates the canonical bytes against each consumer slot and clones the validates the canonical bytes against each consumer slot and clones the
operation-time reference for the spell and combat consumers. Generated operation-time reference for the spell and combat consumers. Generated
provenance records the artifact kind, schema identity, media type, canonical provenance records the artifact kind, schema identity, media type, canonical
digest, size, and producer step/lane/module, but not names, aliases, source digest, size, and producer step/lane/module, but not names, source
ranges, or payload bytes. External normalized files remain supported as ranges, or payload bytes. External normalized files remain supported as
explicit references and retain their file provenance. explicit references and retain their file provenance.
NPC source references are registry provenance and are never accepted as spell NPC source references are registry provenance and are never accepted as spell
or combat evidence. Current transcript units remain the only event evidence. or combat evidence. Current transcript units remain the only event evidence.
Consumers receive a separate names-only projection in normalized registry
order, for example `{"npcs":[{"name":"Mira Thorn"}]}`. The projection omits
IDs and evidence. Its digest covers the exact projected bytes and is used for
consumer-local checkpoint identity, while the full durable artifact digest
remains the manifest and generated-reference provenance identity. The unbound
projection is exactly `{"npcs":[]}` and also has a projection digest.

View File

@@ -102,17 +102,17 @@ The `dnd/spells` extractor accepts an optional `npcs` reference containing one
normalized NPC artifact as `application/json`, up to 1 MiB. An external file is normalized NPC artifact as `application/json`, up to 1 MiB. An external file is
validated during preparation; an ordered generated binding is validated at the validated during preparation; an ordered generated binding is validated at the
step handoff. Both paths use the approved NPC codec and identity policy, step handoff. Both paths use the approved NPC codec and identity policy,
re-encode canonical durable JSON, and supply that JSON as an operation-time re-encode canonical durable JSON for registry provenance, and supply only the
spell prompt input. It helps the model prefer canonical caster names and registry's ordered names as the operation-time spell prompt input. It helps the model prefer canonical
recognize aliases; it does not establish that a spell was cast. caster names; it does not establish that a spell was cast.
NPC source references may identify the run that produced the registry or any NPC source references may identify the run that produced the registry or any
other session. They remain registry provenance and are never copied into a other session. They remain registry provenance and are never copied into a
spell cast's `source_refs`; every spell evidence range must still identify the spell cast's `source_refs`; every spell evidence range must still identify the
current transcript. Generated provenance records producer and canonical current transcript. Generated provenance records producer and canonical
artifact identity without payload content or a path. When the slot is absent, artifact identity without payload content or a path. When the slot is absent,
the prompt receives exactly `{"npcs":[]}` and the run has no NPC reference the prompt receives exactly `{"npcs":[]}` with its projection digest, and the
provenance or NPC checkpoint fingerprint. run has no NPC reference provenance.
## Normalization Behavior ## Normalization Behavior
@@ -196,7 +196,7 @@ provenance; see the [JSON output contract](json-output.md#manifestjson).
The `npc_registry_digest` and `npc_count` fields in the example are present for The `npc_registry_digest` and `npc_count` fields in the example are present for
an external NPC registry when the extractor publishes its prepared module an external NPC registry when the extractor publishes its prepared module
metadata. They contain no NPC names, aliases, source references, paths, or raw metadata. They contain no NPC names, source references, paths, or raw
bytes. A generated registry's identity is instead represented by the framework bytes. A generated registry's identity is instead represented by the framework
handoff provenance and dependency fingerprint, so the consumer module metadata handoff provenance and dependency fingerprint, so the consumer module metadata
does not duplicate it. does not duplicate it.

View File

@@ -170,13 +170,13 @@ The shared D&D prompt assets include a generic NPC grounding fragment directly
after the campaign reference message for spell and combat prompts. When an NPC after the campaign reference message for spell and combat prompts. When an NPC
registry is bound, the registry is bound, the
domain registry boundary strictly decodes and identity-validates one durable domain registry boundary strictly decodes and identity-validates one durable
artifact, re-encodes canonical JSON, and generates a semantic digest over artifact, re-encodes canonical JSON for provenance, and separately generates a
those bytes. The unbound input is exactly `{"npcs":[]}`. Input digests cover names-only prompt projection. The unbound projection is exactly `{"npcs":[]}`.
the generated bytes; manifests record catalog identity and optional NPC Prompt input and component-local checkpoint digests cover the projected bytes;
registry digest/count rather than names, aliases, overlay bytes, registry manifests retain the optional full registry digest/count rather than names,
paths, or source metadata. Combat prompt, response-schema, mapping, overlay bytes, registry paths, or source metadata. Combat prompt,
normalization, identity, and bound-registry fingerprints remain separate response-schema, mapping, normalization, identity, and registry-projection
semantic inputs to checkpoint identity. fingerprints remain separate semantic inputs to checkpoint identity.
## Debug And Redaction Boundaries ## Debug And Redaction Boundaries

View File

@@ -109,8 +109,8 @@ The NPC identity package owns Unicode comparison keys, deterministic
`npc:sha256:` IDs, display normalization, and whole-registry collision issues. `npc:sha256:` IDs, display normalization, and whole-registry collision issues.
The registry package resolves one optional normalized artifact through the The registry package resolves one optional normalized artifact through the
strict codec, validates whole-registry identity, canonicalizes its JSON, and strict codec, validates whole-registry identity, canonicalizes its JSON, and
provides immutable records, prompt input, semantic digest, count, and exact provides immutable records, a names-only prompt projection, distinct durable
canonical-name/alias lookup. External files cross this boundary during and projection digests, count, and exact canonical-name lookup. External files cross this boundary during
preparation; generated artifacts cross it at the ordered step handoff. It owns preparation; generated artifacts cross it at the ordered step handoff. It owns
the `npcs` slot and its bounded, content-safe validation failures. NPC source the `npcs` slot and its bounded, content-safe validation failures. NPC source
references are durable provenance and are not treated as evidence for a references are durable provenance and are not treated as evidence for a
@@ -239,7 +239,8 @@ validated and supplied at operation time. External bindings may add only
`npc_registry_digest` and `npc_count` to module metadata and an `npc_registry_digest` and `npc_count` to module metadata and an
`npc_registry` checkpoint fingerprint. Generated bindings are represented by `npc_registry` checkpoint fingerprint. Generated bindings are represented by
framework handoff provenance and dependency fingerprints. The unbound prompt framework handoff provenance and dependency fingerprints. The unbound prompt
input is exactly `{"npcs":[]}` and has no registry provenance or fingerprint. input is exactly `{"npcs":[]}`, has a projection fingerprint, and has no
registry provenance.
The shared NPC grounding fragment is placed immediately after the common The shared NPC grounding fragment is placed immediately after the common
campaign reference message and is included in the spell prompt fingerprint. campaign reference message and is included in the spell prompt fingerprint.
@@ -253,6 +254,9 @@ assigns source identity and deterministic NPC IDs, and preserves source
references for deterministic validation. It uses the shared campaign references for deterministic validation. It uses the shared campaign
references only for disambiguation and does not consume the optional NPC references only for disambiguation and does not consume the optional NPC
registry slot. Its prompt and private response schema are package-owned. The registry slot. Its prompt and private response schema are package-owned. The
private response contains only a name and model-facing evidence ranges for each
record; anonymous groups, generic roles, invented labels, descriptions,
aliases, and relationships are outside its contract. The
prompt follows the shared D&D extraction ordering and cache policy documented prompt follows the shared D&D extraction ordering and cache policy documented
in [LLM Runtime](llm.md#dd-extraction-prompt-ordering-and-cache-boundaries). in [LLM Runtime](llm.md#dd-extraction-prompt-ordering-and-cache-boundaries).
@@ -286,11 +290,10 @@ for deterministic normalization.
### `internal/modules/dnd/normalize/npcs` ### `internal/modules/dnd/normalize/npcs`
The NPC normalizer performs deterministic identity-aware consolidation in The NPC normalizer performs deterministic identity-aware consolidation in
merged input order. It unions only canonical identity or canonical/alias merged input order. It consolidates only equal canonical-name comparison keys,
matches, retains the first display record, unions exact relationships and retains the first display record, and unions exact source references. It exposes
source references, rewrites unambiguous relationship targets, and leaves the identity policy as its local checkpoint fingerprint and emits bounded
ambiguous collisions for identity validation. It exposes the identity policy normalization warnings.
as its local checkpoint fingerprint and emits bounded normalization warnings.
## Merger And Normalizer ## Merger And Normalizer
@@ -400,15 +403,15 @@ payload rules are defined in the
## D&D NPC Validators ## D&D NPC Validators
NPC shape validation checks required strings, arrays, and source-reference NPC shape validation checks the required ID and name strings, list presence, and source-reference
shape. The source-reference validator defers malformed shapes, checks shape. The source-reference validator defers malformed shapes, checks
current-document identity, unit existence, and range ordering, and reports all current-document identity, unit existence, and range ordering, and reports all
defects through bounded aggregates. Source relatedness uses the shared defects through bounded aggregates. Source relatedness uses the shared
document-order traversal and normalized consecutive-token matching, emitting at document-order traversal and normalized consecutive-token matching, emitting at
most one bounded warning per record when neither the canonical name nor an most one bounded warning per record when the canonical name does not occur near
alias occurs near its cited text. Invalid shape or cited ranges produce no its cited text. Invalid shape or cited ranges produce no relatedness warnings.
relatedness warnings. Normalize identity validation checks deterministic IDs, Normalize identity validation checks deterministic IDs, canonical names, and
canonical names, aliases, and cross-record ownership or canonical collisions. duplicate canonical-name or ID ownership.
All are deterministic and expose the policy fingerprints used by the All are deterministic and expose the policy fingerprints used by the
production chains. production chains.

View File

@@ -87,7 +87,7 @@ Configuration. The implemented module packages are:
| `internal/modules/seriatim/input/transcript` | Parses the supported Seriatim transcript format into the generic source model. | | `internal/modules/seriatim/input/transcript` | Parses the supported Seriatim transcript format into the generic source model. |
| `internal/modules/generic/chunk/units` | Splits ordered source units by unit count and overlap. | | `internal/modules/generic/chunk/units` | Splits ordered source units by unit count and overlap. |
| `internal/modules/dnd/chunk/scenes` | Produces contiguous D&D scene chunks from structured model output. | | `internal/modules/dnd/chunk/scenes` | Produces contiguous D&D scene chunks from structured model output. |
| `internal/modules/dnd` | Owns the canonical D&D spell-list, spell-cast, NPC-list, NPC, relationship, combat-turn-list, combat-turn, and combat-action artifact types. | | `internal/modules/dnd` | Owns the canonical D&D spell-list, spell-cast, NPC-list, NPC, combat-turn-list, combat-turn, and combat-action artifact types. |
| `internal/modules/dnd/codec/spells` | Strictly decodes and stably encodes the durable D&D spell-list representation. | | `internal/modules/dnd/codec/spells` | Strictly decodes and stably encodes the durable D&D spell-list representation. |
| `internal/modules/dnd/codec/npcs` | Strictly decodes and stably encodes the durable D&D NPC-list representation. | | `internal/modules/dnd/codec/npcs` | Strictly decodes and stably encodes the durable D&D NPC-list representation. |
| `internal/modules/dnd/codec/combatturns` | Strictly decodes and stably encodes the durable D&D combat-turn-list representation. | | `internal/modules/dnd/codec/combatturns` | Strictly decodes and stably encodes the durable D&D combat-turn-list representation. |
@@ -102,7 +102,7 @@ Configuration. The implemented module packages are:
| `internal/modules/generic/merge/appendorder` | Combines accepted extraction results in chunk order. | | `internal/modules/generic/merge/appendorder` | Combines accepted extraction results in chunk order. |
| `internal/modules/generic/normalize/noop` | Preserves accepted merged output. | | `internal/modules/generic/normalize/noop` | Preserves accepted merged output. |
| `internal/modules/dnd/normalize/spells` | Canonicalizes catalog-backed spell names and exact source references, conservatively collapses duplicate casts, and reports deterministic warnings and independently scoped catalog checkpoint identity. | | `internal/modules/dnd/normalize/spells` | Canonicalizes catalog-backed spell names and exact source references, conservatively collapses duplicate casts, and reports deterministic warnings and independently scoped catalog checkpoint identity. |
| `internal/modules/dnd/normalize/npcs` | Consolidates NPC records deterministically by identity and aliases, rewrites unambiguous relationship targets, and reports bounded warnings. | | `internal/modules/dnd/normalize/npcs` | Consolidates NPC records deterministically by canonical name, unions exact evidence, and reports bounded warnings. |
| `internal/modules/generic/output/json` | Encodes manifests, lane payloads, warnings, and rejections as logical JSON files. | | `internal/modules/generic/output/json` | Encodes manifests, lane payloads, warnings, and rejections as logical JSON files. |
`internal/modules/dnd/shared` owns reusable D&D prompt fragments, `internal/modules/dnd/shared` owns reusable D&D prompt fragments,

View File

@@ -55,7 +55,7 @@ go run ./cmd/notarius run dnd-npc-grounded \
--output-dir ./npc-grounded-output --output-dir ./npc-grounded-output
``` ```
The NPC artifact grounds canonical names and aliases, not spell or combat The NPC artifact grounds canonical names through a names-only prompt projection, not spell or combat
evidence. Current-transcript source ranges remain the only event evidence. The evidence. Current-transcript source ranges remain the only event evidence. The
manifest records generated-reference identity and bounded producer provenance; manifest records generated-reference identity and bounded producer provenance;
it does not record generated payload content, and no generated content is it does not record generated payload content, and no generated content is

View File

@@ -13,9 +13,6 @@
"required": [ "required": [
"id", "id",
"name", "name",
"aliases",
"description",
"relationships",
"source_refs" "source_refs"
], ],
"properties": { "properties": {
@@ -27,35 +24,6 @@
"type": "string", "type": "string",
"minLength": 1 "minLength": 1
}, },
"aliases": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
},
"description": {
"type": "string",
"minLength": 1
},
"relationships": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["target", "relationship"],
"properties": {
"target": {
"type": "string",
"minLength": 1
},
"relationship": {
"type": "string",
"minLength": 1
}
}
}
},
"source_refs": { "source_refs": {
"type": "array", "type": "array",
"minItems": 1, "minItems": 1,

View File

@@ -106,29 +106,6 @@ func validate(value dnd.NPCList) error {
if strings.TrimSpace(npc.Name) == "" { if strings.TrimSpace(npc.Name) == "" {
return fmt.Errorf("%s.name must not be empty", prefix) return fmt.Errorf("%s.name must not be empty", prefix)
} }
if npc.Aliases == nil {
return fmt.Errorf("%s.aliases must be present", prefix)
}
for aliasIndex, alias := range npc.Aliases {
if strings.TrimSpace(alias) == "" {
return fmt.Errorf("%s.aliases[%d] must not be empty", prefix, aliasIndex)
}
}
if strings.TrimSpace(npc.Description) == "" {
return fmt.Errorf("%s.description must not be empty", prefix)
}
if npc.Relationships == nil {
return fmt.Errorf("%s.relationships must be present", prefix)
}
for relationshipIndex, relationship := range npc.Relationships {
relationshipPrefix := fmt.Sprintf("%s.relationships[%d]", prefix, relationshipIndex)
if strings.TrimSpace(relationship.Target) == "" {
return fmt.Errorf("%s.target must not be empty", relationshipPrefix)
}
if strings.TrimSpace(relationship.Relationship) == "" {
return fmt.Errorf("%s.relationship must not be empty", relationshipPrefix)
}
}
if len(npc.SourceRefs) == 0 { if len(npc.SourceRefs) == 0 {
return fmt.Errorf("%s.source_refs must not be empty", prefix) return fmt.Errorf("%s.source_refs must not be empty", prefix)
} }

View File

@@ -17,13 +17,8 @@ import (
func validList() dnd.NPCList { func validList() dnd.NPCList {
return dnd.NPCList{NPCs: []dnd.NPC{{ return dnd.NPCList{NPCs: []dnd.NPC{{
ID: identity.DeriveID("Mira Thorn"), ID: identity.DeriveID("Mira Thorn"),
Name: "Mira Thorn", Name: "Mira Thorn",
Aliases: []string{"The Greencloak"},
Description: "A guarded ranger who watches the northern road.",
Relationships: []dnd.NPCRelationship{{
Target: "Captain Vale", Relationship: "reports to",
}},
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}}, SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}},
}}} }}}
} }
@@ -86,10 +81,11 @@ func TestCodecStrictlyRejectsMalformedOrUnknownJSON(t *testing.T) {
want string want string
}{ }{
{name: "unknown top-level", raw: `{"npcs":[],"unexpected":true}`, want: "unknown field"}, {name: "unknown top-level", raw: `{"npcs":[],"unexpected":true}`, want: "unknown field"},
{name: "unknown nested", raw: `{"npcs":[{"id":"x","name":"Mira","aliases":[],"description":"desc","relationships":[],"source_refs":[{"source_id":"s","start_unit_id":1,"end_unit_id":1}],"unexpected":true}]}`, want: "unknown field"}, {name: "unknown nested", raw: `{"npcs":[{"id":"x","name":"Mira","source_refs":[{"source_id":"s","start_unit_id":1,"end_unit_id":1}],"unexpected":true}]}`, want: "unknown field"},
{name: "removed enrichment", raw: `{"npcs":[{"id":"x","name":"Mira","aliases":[],"source_refs":[{"source_id":"s","start_unit_id":1,"end_unit_id":1}]}]}`, want: "unknown field"},
{name: "trailing", raw: `{"npcs":[]} {}`, want: "multiple JSON values"}, {name: "trailing", raw: `{"npcs":[]} {}`, want: "multiple JSON values"},
{name: "missing array", raw: `{}`, want: "npcs must be present"}, {name: "missing array", raw: `{}`, want: "npcs must be present"},
{name: "invalid reference", raw: `{"npcs":[{"id":"npc:sha256:0000000000000000000000000000000000000000000000000000000000000000","name":"Mira","aliases":[],"description":"desc","relationships":[],"source_refs":[{"source_id":"s","start_unit_id":0,"end_unit_id":1}]}]}`, want: "start_unit_id"}, {name: "invalid reference", raw: `{"npcs":[{"id":"npc:sha256:0000000000000000000000000000000000000000000000000000000000000000","name":"Mira","source_refs":[{"source_id":"s","start_unit_id":0,"end_unit_id":1}]}]}`, want: "start_unit_id"},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
@@ -103,7 +99,7 @@ func TestCodecStrictlyRejectsMalformedOrUnknownJSON(t *testing.T) {
func TestCodecCandidatePreservesInvalidTypedValues(t *testing.T) { func TestCodecCandidatePreservesInvalidTypedValues(t *testing.T) {
codec := New() codec := New()
candidate := dnd.NPCList{NPCs: []dnd.NPC{{Name: "Mira Thorn", Aliases: []string{}, Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{}}}} candidate := dnd.NPCList{NPCs: []dnd.NPC{{Name: "Mira Thorn", SourceRefs: []source.SourceRef{}}}}
content, err := codec.EncodeCandidate(candidate) content, err := codec.EncodeCandidate(candidate)
if err != nil || !json.Valid(content) { if err != nil || !json.Valid(content) {
t.Fatalf("EncodeCandidate() = %s, %v; want JSON", content, err) t.Fatalf("EncodeCandidate() = %s, %v; want JSON", content, err)
@@ -124,11 +120,8 @@ func TestCodecRejectsEveryRequiredShapeBoundary(t *testing.T) {
value dnd.NPCList value dnd.NPCList
want string want string
}{ }{
{name: "nil aliases", value: dnd.NPCList{NPCs: []dnd.NPC{{ID: base.ID, Name: base.Name, Description: base.Description, Relationships: []dnd.NPCRelationship{}, SourceRefs: base.SourceRefs}}}, want: "aliases must be present"}, {name: "blank name", value: dnd.NPCList{NPCs: []dnd.NPC{{ID: base.ID, Name: " ", SourceRefs: base.SourceRefs}}}, want: "name must not be empty"},
{name: "empty alias", value: dnd.NPCList{NPCs: []dnd.NPC{{ID: base.ID, Name: base.Name, Aliases: []string{" "}, Description: base.Description, Relationships: []dnd.NPCRelationship{}, SourceRefs: base.SourceRefs}}}, want: "aliases[0] must not be empty"}, {name: "empty source refs", value: dnd.NPCList{NPCs: []dnd.NPC{{ID: base.ID, Name: base.Name, SourceRefs: []source.SourceRef{}}}}, want: "source_refs must not be empty"},
{name: "nil relationships", value: dnd.NPCList{NPCs: []dnd.NPC{{ID: base.ID, Name: base.Name, Aliases: []string{}, Description: base.Description, SourceRefs: base.SourceRefs}}}, want: "relationships must be present"},
{name: "empty relationship target", value: dnd.NPCList{NPCs: []dnd.NPC{{ID: base.ID, Name: base.Name, Aliases: []string{}, Description: base.Description, Relationships: []dnd.NPCRelationship{{Relationship: "knows"}}, SourceRefs: base.SourceRefs}}}, want: "target must not be empty"},
{name: "empty source refs", value: dnd.NPCList{NPCs: []dnd.NPC{{ID: base.ID, Name: base.Name, Aliases: []string{}, Description: base.Description, Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{}}}}, want: "source_refs must not be empty"},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {

View File

@@ -3,9 +3,6 @@
{ {
"id": "npc:sha256:99a16589618a04f535a7d21fdcc71a0b1c05d22f752cd492065b1086d97bc3d7", "id": "npc:sha256:99a16589618a04f535a7d21fdcc71a0b1c05d22f752cd492065b1086d97bc3d7",
"name": "Mira Thorn", "name": "Mira Thorn",
"aliases": ["The Greencloak"],
"description": "A guarded ranger who watches the northern road.",
"relationships": [{"target": "Captain Vale", "relationship": "reports to"}],
"source_refs": [{"source_id": "session-alpha", "start_unit_id": 1, "end_unit_id": 2}] "source_refs": [{"source_id": "session-alpha", "start_unit_id": 1, "end_unit_id": 2}]
} }
] ]

View File

@@ -133,9 +133,7 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
{Name: "mapping_policy", Value: mappingPolicy}, {Name: "mapping_policy", Value: mappingPolicy},
} }
seeded := e.npcResolver.Seeded() seeded := e.npcResolver.Seeded()
if seeded.Bound() { fingerprints = append(fingerprints, pipeline.CheckpointFingerprint{Name: "npc_registry", Value: seeded.ProjectionDigest()})
fingerprints = append(fingerprints, pipeline.CheckpointFingerprint{Name: "npc_registry", Value: seeded.Digest()})
}
return fingerprints return fingerprints
} }

View File

@@ -146,17 +146,15 @@ func TestExtractUnboundRegistryUsesExactEmptyPromptAndOmitsIdentity(t *testing.T
t.Fatalf("Extract() error = %v, want nil", err) t.Fatalf("Extract() error = %v, want nil", err)
} }
input := client.requests[0].Inputs[NPCRegistryReferenceSlot] input := client.requests[0].Inputs[NPCRegistryReferenceSlot]
if string(input.Content) != `{"npcs":[]}` || input.Digest != "" || input.OriginURI != "" { if string(input.Content) != `{"npcs":[]}` || input.Digest == "" || input.OriginURI != "" {
t.Fatalf("unbound registry input = %#v, want exact empty prompt without identity", input) t.Fatalf("unbound registry input = %#v, want exact empty prompt without identity", input)
} }
metadata := extractor.ManifestMetadata() metadata := extractor.ManifestMetadata()
if _, ok := metadata["npc_registry_digest"]; ok { if _, ok := metadata["npc_registry_digest"]; ok {
t.Fatalf("unbound metadata has registry digest: %#v", metadata) t.Fatalf("unbound metadata has registry digest: %#v", metadata)
} }
for _, fingerprint := range extractor.CheckpointFingerprints() { if fingerprints := extractor.CheckpointFingerprints(); len(fingerprints) != 4 || fingerprints[3].Name != "npc_registry" || fingerprints[3].Value != input.Digest {
if fingerprint.Name == "npc_registry" { t.Fatalf("unbound fingerprints = %#v, want empty-projection identity", fingerprints)
t.Fatalf("unbound fingerprints include registry identity: %#v", extractor.CheckpointFingerprints())
}
} }
} }
@@ -175,7 +173,7 @@ func TestExtractorResolvesOperationNPCOverrideWithoutSingletonMetadata(t *testin
t.Fatalf("Extract() error = %v", err) t.Fatalf("Extract() error = %v", err)
} }
input := client.requests[0].Inputs[NPCRegistryReferenceSlot] input := client.requests[0].Inputs[NPCRegistryReferenceSlot]
if input.Digest == "" || string(input.Content) != string(content) || input.OriginURI != "" { if input.Digest == "" || string(input.Content) != `{"npcs":[{"name":"Mira Thorn"}]}` || input.OriginURI != "" {
t.Fatalf("operation NPC input = %#v, want generated canonical grounding without provenance", input) t.Fatalf("operation NPC input = %#v, want generated canonical grounding without provenance", input)
} }
if metadata := extractor.ManifestMetadata(); metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil { if metadata := extractor.ManifestMetadata(); metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil {
@@ -252,7 +250,7 @@ func TestExtractorManifestMetadataAndFingerprints(t *testing.T) {
t.Fatalf("metadata[%q] = %#v, want digest", key, metadata[key]) t.Fatalf("metadata[%q] = %#v, want digest", key, metadata[key])
} }
} }
wantNames := map[string]struct{}{"prompt": {}, "response_schema": {}, "mapping_policy": {}} wantNames := map[string]struct{}{"prompt": {}, "response_schema": {}, "mapping_policy": {}, "npc_registry": {}}
for _, fingerprint := range extractor.CheckpointFingerprints() { for _, fingerprint := range extractor.CheckpointFingerprints() {
if _, ok := wantNames[fingerprint.Name]; !ok { if _, ok := wantNames[fingerprint.Name]; !ok {
t.Fatalf("unexpected fingerprint = %#v", fingerprint) t.Fatalf("unexpected fingerprint = %#v", fingerprint)
@@ -340,10 +338,7 @@ func newExtractor(t *testing.T, client contracts.StructuredLLMClient, references
func npcRegistryJSON(t *testing.T) []byte { func npcRegistryJSON(t *testing.T) []byte {
t.Helper() t.Helper()
value := dnd.NPCList{NPCs: []dnd.NPC{{ value := dnd.NPCList{NPCs: []dnd.NPC{{ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "other-session", StartUnitID: 1, EndUnitID: 1}}}}}
ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", Aliases: []string{"The Greencloak"},
Description: "A ranger.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "other-session", StartUnitID: 1, EndUnitID: 1}},
}}}
content, err := npccodec.New().Encode(value) content, err := npccodec.New().Encode(value)
if err != nil { if err != nil {
t.Fatalf("encode NPC registry: %v", err) t.Fatalf("encode NPC registry: %v", err)

View File

@@ -1,11 +1,10 @@
For every NPC record, cite transcript units that support the canonical name, For every NPC record, return only the observed display name and transcript
every alias, the description, and every relationship. units that support that identity.
Descriptions must be short session records, not biographies, statistics, Do not return descriptions, aliases, relationships, biographies, statistics,
alignment, motivations, or lore inferred from general D&D knowledge. Do not alignment, motivations, encounter summaries, or lore inferred from general
summarize every action or follow a participant through unrelated scenes. D&D knowledge. Do not invent a label for an anonymous creature, crowd, or
Relationships must be stated or directly demonstrated by cited transcript generic role.
units, not inferred from game lore.
Return aliases and relationships as arrays, including empty arrays when there Preserve observed display spelling. Return at least one narrow source range for
are none. Preserve observed display spelling. every record.

View File

@@ -1,15 +1,12 @@
Extract a concise Dungeons & Dragons non-player-character registry from the Extract the individually identifiable Dungeons & Dragons non-player characters
provided transcript. established by the provided transcript and cite where each identity appears.
Include an in-world non-PC participant when the transcript establishes that it Include an in-world non-PC participant only when the transcript gives it a
appears, acts, speaks, or is materially discussed and gives it a proper name, proper name or a stable, individually distinguishing title or alias.
a stable alias or title, or an individually useful distinguishing description.
Exclude human players, transcript speakers, and the GM as out-of-world people, Exclude human players, transcript speakers, and the GM as out-of-world people,
player characters identified by the player or party references, incidental or player characters identified by the player or party references, incidental or
hypothetical name drops, corrected transcription mistakes, indistinguishable hypothetical name drops, corrected transcription mistakes, anonymous or
crowds or groups, and temporary summoned creatures or spell effects without a generic roles, indistinguishable crowds or groups, invented descriptive labels,
persistent individual identity. and temporary summoned creatures or spell effects without a persistent
individual identity.
Keep each description concise and limited to facts established by the
transcript. Include only explicitly supported aliases and relationships.

View File

@@ -12,40 +12,12 @@
"additionalProperties": false, "additionalProperties": false,
"required": [ "required": [
"name", "name",
"aliases",
"description",
"relationships",
"source_refs" "source_refs"
], ],
"properties": { "properties": {
"name": { "name": {
"type": "string" "type": "string"
}, },
"aliases": {
"type": "array",
"items": {
"type": "string"
}
},
"description": {
"type": "string"
},
"relationships": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["target", "relationship"],
"properties": {
"target": {
"type": "string"
},
"relationship": {
"type": "string"
}
}
}
},
"source_refs": { "source_refs": {
"type": "array", "type": "array",
"items": { "items": {

View File

@@ -98,35 +98,14 @@ func canonicalNPCList(response extractionResponse, sourceID string) dnd.NPCList
npcs := make([]dnd.NPC, len(response.NPCs)) npcs := make([]dnd.NPC, len(response.NPCs))
for index, npc := range response.NPCs { for index, npc := range response.NPCs {
npcs[index] = dnd.NPC{ npcs[index] = dnd.NPC{
ID: identity.DeriveID(npc.Name), ID: identity.DeriveID(npc.Name),
Name: npc.Name, Name: npc.Name,
Aliases: cloneStrings(npc.Aliases), SourceRefs: canonicalSourceRefs(npc.SourceRefs, sourceID),
Description: npc.Description,
Relationships: cloneRelationships(npc.Relationships),
SourceRefs: canonicalSourceRefs(npc.SourceRefs, sourceID),
} }
} }
return dnd.NPCList{NPCs: npcs} return dnd.NPCList{NPCs: npcs}
} }
func cloneStrings(values []string) []string {
if values == nil {
return nil
}
return append([]string{}, values...)
}
func cloneRelationships(values []npcRelationshipResponse) []dnd.NPCRelationship {
if values == nil {
return nil
}
out := make([]dnd.NPCRelationship, len(values))
for index, value := range values {
out[index] = dnd.NPCRelationship{Target: value.Target, Relationship: value.Relationship}
}
return out
}
func canonicalSourceRefs(values []npcSourceRefResponse, sourceID string) []source.SourceRef { func canonicalSourceRefs(values []npcSourceRefResponse, sourceID string) []source.SourceRef {
if values == nil { if values == nil {
return nil return nil

View File

@@ -16,13 +16,10 @@ import (
func TestExtractReturnsCanonicalNPCListFromPrivateResponse(t *testing.T) { func TestExtractReturnsCanonicalNPCListFromPrivateResponse(t *testing.T) {
client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{ client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{
{ {
Name: "Captain Vale", Aliases: []string{"The Captain"}, Description: "A road captain.", Name: "Captain Vale", SourceRefs: responseSourceRefs(3, 3),
Relationships: []npcRelationshipResponse{{Target: "Mira Thorn", Relationship: "reports to"}},
SourceRefs: responseSourceRefs(3, 3),
}, },
{ {
Name: "Mira Thorn", Aliases: []string{"The Greencloak"}, Description: "A guarded ranger.", Name: "Mira Thorn",
Relationships: []npcRelationshipResponse{{Target: "Captain Vale", Relationship: "commands"}},
SourceRefs: []npcSourceRefResponse{ SourceRefs: []npcSourceRefResponse{
{StartUnitID: 2, EndUnitID: 2}, {StartUnitID: 2, EndUnitID: 2},
{StartUnitID: 1, EndUnitID: 2}, {StartUnitID: 1, EndUnitID: 2},
@@ -36,8 +33,8 @@ func TestExtractReturnsCanonicalNPCListFromPrivateResponse(t *testing.T) {
t.Fatalf("Extract() error = %v, want nil", err) t.Fatalf("Extract() error = %v, want nil", err)
} }
want := dnd.NPCList{NPCs: []dnd.NPC{ want := dnd.NPCList{NPCs: []dnd.NPC{
{ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", Aliases: []string{"The Greencloak"}, Description: "A guarded ranger.", Relationships: []dnd.NPCRelationship{{Target: "Captain Vale", Relationship: "commands"}}, SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}, {SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}}, {ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}, {SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}},
{ID: identity.DeriveID("Captain Vale"), Name: "Captain Vale", Aliases: []string{"The Captain"}, Description: "A road captain.", Relationships: []dnd.NPCRelationship{{Target: "Mira Thorn", Relationship: "reports to"}}, SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}}}, {ID: identity.DeriveID("Captain Vale"), Name: "Captain Vale", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}}},
}} }}
if !reflect.DeepEqual(result.Value, want) { if !reflect.DeepEqual(result.Value, want) {
t.Fatalf("Value = %#v, want %#v", result.Value, want) t.Fatalf("Value = %#v, want %#v", result.Value, want)
@@ -58,11 +55,11 @@ func TestExtractReturnsCanonicalNPCListFromPrivateResponse(t *testing.T) {
func TestExtractOrdersNPCsBySourcePositionRatherThanUnitID(t *testing.T) { func TestExtractOrdersNPCsBySourcePositionRatherThanUnitID(t *testing.T) {
client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{ client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{
{ {
Name: "Later NPC", Aliases: []string{}, Description: "Appears later.", Relationships: []npcRelationshipResponse{}, Name: "Later NPC",
SourceRefs: responseSourceRefs(10, 10), SourceRefs: responseSourceRefs(10, 10),
}, },
{ {
Name: "Earlier NPC", Aliases: []string{}, Description: "Appears first.", Relationships: []npcRelationshipResponse{}, Name: "Earlier NPC",
SourceRefs: []npcSourceRefResponse{ SourceRefs: []npcSourceRefResponse{
{StartUnitID: 50, EndUnitID: 50}, {StartUnitID: 50, EndUnitID: 50},
{StartUnitID: 100, EndUnitID: 100}, {StartUnitID: 100, EndUnitID: 100},
@@ -109,14 +106,14 @@ func TestExtractPassesCampaignReferencesAsPromptInputs(t *testing.T) {
func TestExtractPreservesMalformedCandidatesForValidators(t *testing.T) { func TestExtractPreservesMalformedCandidatesForValidators(t *testing.T) {
client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{{ client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{{
Name: "", Aliases: nil, Description: "", Relationships: nil, Name: "",
SourceRefs: []npcSourceRefResponse{{StartUnitID: 99, EndUnitID: 0}}, SourceRefs: []npcSourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
}}}} }}}}
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest()) result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
if err != nil { if err != nil {
t.Fatalf("Extract() error = %v, want nil", err) t.Fatalf("Extract() error = %v, want nil", err)
} }
if len(result.Value.NPCs) != 1 || result.Value.NPCs[0].ID != "" || result.Value.NPCs[0].Name != "" || result.Value.NPCs[0].Aliases != nil || result.Value.NPCs[0].Relationships != nil { if len(result.Value.NPCs) != 1 || result.Value.NPCs[0].ID != "" || result.Value.NPCs[0].Name != "" {
t.Fatalf("malformed candidate = %#v, want invalid values preserved", result.Value) t.Fatalf("malformed candidate = %#v, want invalid values preserved", result.Value)
} }
if refs := result.Value.NPCs[0].SourceRefs; len(refs) != 1 || refs[0].SourceID != "session-alpha" || refs[0].StartUnitID != 99 || refs[0].EndUnitID != 0 { if refs := result.Value.NPCs[0].SourceRefs; len(refs) != 1 || refs[0].SourceID != "session-alpha" || refs[0].StartUnitID != 99 || refs[0].EndUnitID != 0 {
@@ -125,13 +122,13 @@ func TestExtractPreservesMalformedCandidatesForValidators(t *testing.T) {
} }
func TestExtractMapsRawSemanticCandidatesWithoutRepair(t *testing.T) { func TestExtractMapsRawSemanticCandidatesWithoutRepair(t *testing.T) {
client := &fakeNPCsLLMClient{content: []byte(`{"npcs":[{"name":"","aliases":[],"description":"","relationships":[],"source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`)} client := &fakeNPCsLLMClient{content: []byte(`{"npcs":[{"name":"","source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`)}
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest()) result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
if err != nil { if err != nil {
t.Fatalf("Extract() error = %v, want nil", err) t.Fatalf("Extract() error = %v, want nil", err)
} }
npc := result.Value.NPCs[0] npc := result.Value.NPCs[0]
if npc.ID != "" || npc.Name != "" || npc.Description != "" { if npc.ID != "" || npc.Name != "" {
t.Fatalf("NPC = %#v, want blank semantic values preserved", npc) t.Fatalf("NPC = %#v, want blank semantic values preserved", npc)
} }
if refs := npc.SourceRefs; len(refs) != 1 || refs[0] != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 0, EndUnitID: -1}) { if refs := npc.SourceRefs; len(refs) != 1 || refs[0] != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 0, EndUnitID: -1}) {

View File

@@ -5,16 +5,8 @@ type extractionResponse struct {
} }
type npcResponse struct { type npcResponse struct {
Name string `json:"name"` Name string `json:"name"`
Aliases []string `json:"aliases"` SourceRefs []npcSourceRefResponse `json:"source_refs"`
Description string `json:"description"`
Relationships []npcRelationshipResponse `json:"relationships"`
SourceRefs []npcSourceRefResponse `json:"source_refs"`
}
type npcRelationshipResponse struct {
Target string `json:"target"`
Relationship string `json:"relationship"`
} }
type npcSourceRefResponse struct { type npcSourceRefResponse struct {

View File

@@ -18,7 +18,7 @@ func TestLoadResponseSchemaUsesPrivateNPCSchema(t *testing.T) {
t.Fatalf("schema = %#v, want private NPC schema identity", schema) t.Fatalf("schema = %#v, want private NPC schema identity", schema)
} }
valid := map[string]any{"npcs": []any{map[string]any{ valid := map[string]any{"npcs": []any{map[string]any{
"name": "Mira Thorn", "aliases": []any{}, "description": "A ranger.", "relationships": []any{}, "name": "Mira Thorn",
"source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}}, "source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}},
}}} }}}
validJSON, err := json.Marshal(valid) validJSON, err := json.Marshal(valid)
@@ -36,14 +36,14 @@ func TestLoadResponseSchemaUsesPrivateNPCSchema(t *testing.T) {
{ {
name: "semantic blanks and empty collections", name: "semantic blanks and empty collections",
response: map[string]any{"npcs": []any{map[string]any{ response: map[string]any{"npcs": []any{map[string]any{
"name": "", "aliases": []any{""}, "description": "", "relationships": []any{map[string]any{"target": "", "relationship": ""}}, "source_refs": []any{}, "name": "", "source_refs": []any{},
}}}, }}},
valid: true, valid: true,
}, },
{ {
name: "nonpositive unit candidates", name: "nonpositive unit candidates",
response: map[string]any{"npcs": []any{map[string]any{ response: map[string]any{"npcs": []any{map[string]any{
"name": "Mira Thorn", "aliases": []any{}, "description": "A ranger.", "relationships": []any{}, "name": "Mira Thorn",
"source_refs": []any{map[string]any{"start_unit_id": 0, "end_unit_id": -1}}, "source_refs": []any{map[string]any{"start_unit_id": 0, "end_unit_id": -1}},
}}}, }}},
valid: true, valid: true,
@@ -51,25 +51,25 @@ func TestLoadResponseSchemaUsesPrivateNPCSchema(t *testing.T) {
{ {
name: "missing required field", name: "missing required field",
response: map[string]any{"npcs": []any{map[string]any{ response: map[string]any{"npcs": []any{map[string]any{
"aliases": []any{}, "description": "A ranger.", "relationships": []any{}, "source_refs": []any{}, "source_refs": []any{},
}}}, }}},
}, },
{ {
name: "unknown field", name: "unknown field",
response: map[string]any{"npcs": []any{map[string]any{ response: map[string]any{"npcs": []any{map[string]any{
"name": "Mira Thorn", "aliases": []any{}, "description": "A ranger.", "relationships": []any{}, "source_refs": []any{}, "id": "assigned later", "name": "Mira Thorn", "source_refs": []any{}, "id": "assigned later",
}}}, }}},
}, },
{ {
name: "wrong field type", name: "wrong field type",
response: map[string]any{"npcs": []any{map[string]any{ response: map[string]any{"npcs": []any{map[string]any{
"name": 7, "aliases": []any{}, "description": "A ranger.", "relationships": []any{}, "source_refs": []any{}, "name": 7, "source_refs": []any{},
}}}, }}},
}, },
{ {
name: "noninteger source identifier", name: "noninteger source identifier",
response: map[string]any{"npcs": []any{map[string]any{ response: map[string]any{"npcs": []any{map[string]any{
"name": "Mira Thorn", "aliases": []any{}, "description": "A ranger.", "relationships": []any{}, "name": "Mira Thorn",
"source_refs": []any{map[string]any{"start_unit_id": 1.5, "end_unit_id": 2}}, "source_refs": []any{map[string]any{"start_unit_id": 1.5, "end_unit_id": 2}},
}}}, }}},
}, },
@@ -89,7 +89,7 @@ func TestLoadResponseSchemaUsesPrivateNPCSchema(t *testing.T) {
t.Fatal("validateJSONSchema() error = nil, want malformed JSON rejected") t.Fatal("validateJSONSchema() error = nil, want malformed JSON rejected")
} }
withID := map[string]any{"npcs": []any{map[string]any{ withID := map[string]any{"npcs": []any{map[string]any{
"name": "Mira Thorn", "id": "assigned-later", "aliases": []any{}, "description": "A ranger.", "relationships": []any{}, "name": "Mira Thorn", "id": "assigned-later",
"source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}}, "source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}},
}}} }}}
withIDJSON, err := json.Marshal(withID) withIDJSON, err := json.Marshal(withID)

View File

@@ -151,9 +151,7 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
{Name: "response_schema", Value: e.responseSchemaSHA}, {Name: "response_schema", Value: e.responseSchemaSHA},
} }
seeded := e.npcResolver.Seeded() seeded := e.npcResolver.Seeded()
if seeded.Bound() { fingerprints = append(fingerprints, pipeline.CheckpointFingerprint{Name: "npc_registry", Value: seeded.ProjectionDigest()})
fingerprints = append(fingerprints, pipeline.CheckpointFingerprint{Name: "npc_registry", Value: seeded.Digest()})
}
return fingerprints return fingerprints
} }

View File

@@ -122,9 +122,10 @@ func TestExtractPromptUsesCanonicalOverlayNamesWithoutAliasesOrMetadata(t *testi
"effective_catalog": metadata["catalog_digest"], "effective_catalog": metadata["catalog_digest"],
"prompt": metadata["prompt_sha256"], "prompt": metadata["prompt_sha256"],
"response_schema": metadata["response_schema_sha256"], "response_schema": metadata["response_schema_sha256"],
"npc_registry": checkpointFingerprintMap(newExtractor(t, &fakeSpellsLLMClient{}).CheckpointFingerprints())["npc_registry"],
} }
if len(fingerprints) != len(wantFingerprints) { if len(fingerprints) != len(wantFingerprints) {
t.Fatalf("checkpoint fingerprints = %#v, want prompt, response schema, and catalog identities", fingerprints) t.Fatalf("checkpoint fingerprints = %#v, want prompt, response schema, catalog, and NPC projection identities", fingerprints)
} }
for _, fingerprint := range fingerprints { for _, fingerprint := range fingerprints {
if want, ok := wantFingerprints[fingerprint.Name]; !ok || fingerprint.Value != want { if want, ok := wantFingerprints[fingerprint.Name]; !ok || fingerprint.Value != want {

View File

@@ -22,10 +22,9 @@ func TestSpellExtractorUsesExactUnboundNPCPromptAndOmitsRegistryIdentity(t *test
if metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil { if metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil {
t.Fatalf("unbound extractor metadata = %#v, want no NPC registry fields", metadata) t.Fatalf("unbound extractor metadata = %#v, want no NPC registry fields", metadata)
} }
for _, fingerprint := range extractor.CheckpointFingerprints() { fingerprints := checkpointFingerprintMap(extractor.CheckpointFingerprints())
if fingerprint.Name == "npc_registry" { if fingerprints["npc_registry"] == "" {
t.Fatalf("unbound checkpoint fingerprints = %#v, want no NPC registry fingerprint", extractor.CheckpointFingerprints()) t.Fatalf("unbound checkpoint fingerprints = %#v, want empty-projection identity", fingerprints)
}
} }
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}} client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}}
@@ -34,7 +33,7 @@ func TestSpellExtractorUsesExactUnboundNPCPromptAndOmitsRegistryIdentity(t *test
t.Fatalf("Extract() error = %v, want nil", err) t.Fatalf("Extract() error = %v, want nil", err)
} }
input := client.requests[0].Inputs[NPCRegistryReferenceSlot] input := client.requests[0].Inputs[NPCRegistryReferenceSlot]
if input.Name != NPCRegistryReferenceSlot || input.MediaType != npccodec.MediaType || input.Digest != "" || input.OriginURI != "" || string(input.Content) != `{"npcs":[]}` { if input.Name != NPCRegistryReferenceSlot || input.MediaType != npccodec.MediaType || input.Digest != fingerprints["npc_registry"] || input.OriginURI != "" || string(input.Content) != `{"npcs":[]}` {
t.Fatalf("NPC prompt input = %#v, want exact empty registry material", input) t.Fatalf("NPC prompt input = %#v, want exact empty registry material", input)
} }
} }
@@ -57,7 +56,7 @@ func TestSpellExtractorPreservesSemanticNPCRegistryFingerprintAndPromptWiring(t
t.Fatalf("semantic NPC fingerprints = %#v and %#v, want same npc_registry value", firstFingerprints, secondFingerprints) t.Fatalf("semantic NPC fingerprints = %#v and %#v, want same npc_registry value", firstFingerprints, secondFingerprints)
} }
metadata := first.ManifestMetadata() metadata := first.ManifestMetadata()
if metadata["npc_registry_digest"] != firstFingerprints["npc_registry"] || metadata["npc_count"] != 1 { if metadata["npc_registry_digest"] == "" || metadata["npc_registry_digest"] == firstFingerprints["npc_registry"] || metadata["npc_count"] != 1 {
t.Fatalf("NPC registry metadata = %#v, want digest and count only", metadata) t.Fatalf("NPC registry metadata = %#v, want digest and count only", metadata)
} }
@@ -70,8 +69,8 @@ func TestSpellExtractorPreservesSemanticNPCRegistryFingerprintAndPromptWiring(t
if input.Name != NPCRegistryReferenceSlot || input.MediaType != npccodec.MediaType || input.Digest != firstFingerprints["npc_registry"] || input.OriginURI != "" { if input.Name != NPCRegistryReferenceSlot || input.MediaType != npccodec.MediaType || input.Digest != firstFingerprints["npc_registry"] || input.OriginURI != "" {
t.Fatalf("NPC prompt input metadata = %#v, want semantic metadata without provenance", input) t.Fatalf("NPC prompt input metadata = %#v, want semantic metadata without provenance", input)
} }
if !bytes.Equal(input.Content, canonical) { if !bytes.Equal(input.Content, []byte(`{"npcs":[{"name":"Mira Thorn"}]}`)) {
t.Fatalf("NPC prompt input = %s, want canonical JSON %s", input.Content, canonical) t.Fatalf("NPC prompt input = %s, want names-only projection", input.Content)
} }
encoded, err := json.Marshal(map[string]any{"metadata": metadata, "fingerprints": firstFingerprints}) encoded, err := json.Marshal(map[string]any{"metadata": metadata, "fingerprints": firstFingerprints})
if err != nil { if err != nil {
@@ -97,28 +96,21 @@ func TestSpellExtractorResolvesOperationNPCOverrideWithoutSingletonMetadata(t *t
t.Fatalf("Extract() error = %v", err) t.Fatalf("Extract() error = %v", err)
} }
input := client.requests[0].Inputs[NPCRegistryReferenceSlot] input := client.requests[0].Inputs[NPCRegistryReferenceSlot]
if input.Digest == "" || string(input.Content) != string(canonical) || input.OriginURI != "" { if input.Digest == "" || string(input.Content) != `{"npcs":[{"name":"Mira Thorn"}]}` || input.OriginURI != "" {
t.Fatalf("operation NPC input = %#v, want generated canonical grounding without provenance", input) t.Fatalf("operation NPC input = %#v, want generated canonical grounding without provenance", input)
} }
if metadata := extractor.ManifestMetadata(); metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil { if metadata := extractor.ManifestMetadata(); metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil {
t.Fatalf("singleton metadata = %#v, want no operation-varying NPC identity", metadata) t.Fatalf("singleton metadata = %#v, want no operation-varying NPC identity", metadata)
} }
for _, fingerprint := range extractor.CheckpointFingerprints() { if checkpointFingerprintMap(extractor.CheckpointFingerprints())["npc_registry"] == "" {
if fingerprint.Name == "npc_registry" { t.Fatalf("singleton fingerprints = %#v, want empty-projection identity", extractor.CheckpointFingerprints())
t.Fatalf("singleton fingerprints = %#v, want no operation-varying NPC identity", extractor.CheckpointFingerprints())
}
} }
} }
func registryFixture() dnd.NPCList { func registryFixture() dnd.NPCList {
return dnd.NPCList{NPCs: []dnd.NPC{{ return dnd.NPCList{NPCs: []dnd.NPC{{
ID: identity.DeriveID("Mira Thorn"), ID: identity.DeriveID("Mira Thorn"),
Name: "Mira Thorn", Name: "Mira Thorn",
Aliases: []string{"The Greencloak"},
Description: "A guarded ranger who watches the northern road.",
Relationships: []dnd.NPCRelationship{{
Target: "Captain Vale", Relationship: "reports to",
}},
SourceRefs: []source.SourceRef{{SourceID: "npc-session", StartUnitID: 41, EndUnitID: 43}}, SourceRefs: []source.SourceRef{{SourceID: "npc-session", StartUnitID: 41, EndUnitID: 43}},
}}} }}}
} }

View File

@@ -92,9 +92,7 @@ func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
{Name: "identity_policy", Value: identity.Policy}, {Name: "identity_policy", Value: identity.Policy},
} }
seeded := n.npcResolver.Seeded() seeded := n.npcResolver.Seeded()
if seeded.Bound() { fingerprints = append(fingerprints, pipeline.CheckpointFingerprint{Name: "npc_registry", Value: seeded.ProjectionDigest()})
fingerprints = append(fingerprints, pipeline.CheckpointFingerprint{Name: "npc_registry", Value: seeded.Digest()})
}
return fingerprints return fingerprints
} }

View File

@@ -26,12 +26,12 @@ func TestNormalizeCanonicalizesFieldsAndRegistryIdentities(t *testing.T) {
} }
resolution := " the target is hit " resolution := " the target is hit "
input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: " storm ", Actor: " aria ",
TurnKind: dnd.CombatTurnKindTurn, TurnKind: dnd.CombatTurnKindTurn,
Actions: []dnd.CombatAction{{ Actions: []dnd.CombatAction{{
Category: dnd.CombatActionCategoryAttack, Category: dnd.CombatActionCategoryAttack,
Declaration: " attacks\n with a sword ", Declaration: " attacks\n with a sword ",
Targets: []string{" minion ", "goblin", " unknown combatant "}, Targets: []string{" goblin ", "goblin", " unknown combatant "},
Resolution: &resolution, Resolution: &resolution,
}}, }},
Summary: " Aria\n attacks ", Summary: " Aria\n attacks ",
@@ -77,10 +77,10 @@ func TestNormalizeResolvesOperationNPCOverrideWithoutSingletonMetadata(t *testin
t.Fatalf("New() error = %v", err) t.Fatalf("New() error = %v", err)
} }
input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: "Storm", Actor: "aria",
TurnKind: dnd.CombatTurnKindTurn, TurnKind: dnd.CombatTurnKindTurn,
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "attacks", Targets: []string{"Minion"}}}, Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "attacks", Targets: []string{"goblin"}}},
Summary: "Storm attacks", Summary: "Aria attacks",
SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}},
}}} }}}
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{ result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{
@@ -222,7 +222,7 @@ func TestNormalizerPreparationMetadataFingerprintsAndModuleContract(t *testing.T
if metadata := unbound.ManifestMetadata(); metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil || metadata["normalization_policy"] != normalizationPolicy || metadata["identity_policy"] != identity.Policy { if metadata := unbound.ManifestMetadata(); metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil || metadata["normalization_policy"] != normalizationPolicy || metadata["identity_policy"] != identity.Policy {
t.Fatalf("unbound metadata = %#v", metadata) t.Fatalf("unbound metadata = %#v", metadata)
} }
if got := unbound.CheckpointFingerprints(); len(got) != 2 || got[0].Name != "normalization_policy" || got[1].Name != "identity_policy" { if got := unbound.CheckpointFingerprints(); len(got) != 3 || got[0].Name != "normalization_policy" || got[1].Name != "identity_policy" || got[2].Name != "npc_registry" || got[2].Value == "" {
t.Fatalf("unbound fingerprints = %#v", got) t.Fatalf("unbound fingerprints = %#v", got)
} }
@@ -305,8 +305,8 @@ func testDocument() *source.SourceDocument {
func npcReferences(t *testing.T) contracts.ReferenceSet { func npcReferences(t *testing.T) contracts.ReferenceSet {
t.Helper() t.Helper()
npcs := dnd.NPCList{NPCs: []dnd.NPC{ npcs := dnd.NPCList{NPCs: []dnd.NPC{
{ID: identity.DeriveID("Aria"), Name: "Aria", Aliases: []string{"Storm"}, Description: "a fighter", Relationships: []dnd.NPCRelationship{{Target: "Goblin", Relationship: "fights"}}, SourceRefs: []source.SourceRef{{SourceID: "npc-run", StartUnitID: 1, EndUnitID: 1}}}, {ID: identity.DeriveID("Aria"), Name: "Aria", SourceRefs: []source.SourceRef{{SourceID: "npc-run", StartUnitID: 1, EndUnitID: 1}}},
{ID: identity.DeriveID("Goblin"), Name: "Goblin", Aliases: []string{"Minion"}, Description: "a goblin", Relationships: []dnd.NPCRelationship{{Target: "Aria", Relationship: "fights"}}, SourceRefs: []source.SourceRef{{SourceID: "npc-run", StartUnitID: 1, EndUnitID: 1}}}, {ID: identity.DeriveID("Goblin"), Name: "Goblin", SourceRefs: []source.SourceRef{{SourceID: "npc-run", StartUnitID: 1, EndUnitID: 1}}},
}} }}
content, err := npccodec.New().Encode(npcs) content, err := npccodec.New().Encode(npcs)
if err != nil { if err != nil {

View File

@@ -22,11 +22,10 @@ const (
normalizationPolicy = "dnd.npcs.normalize.v1" normalizationPolicy = "dnd.npcs.normalize.v1"
NormalizationPolicy = normalizationPolicy NormalizationPolicy = normalizationPolicy
ReasonCodeNPCFieldsNormalized = "npc_fields_normalized" ReasonCodeNPCFieldsNormalized = "npc_fields_normalized"
ReasonCodeNPCIDRecomputed = "npc_id_recomputed" ReasonCodeNPCIDRecomputed = "npc_id_recomputed"
ReasonCodeSourceReferencesNormalized = "source_references_normalized" ReasonCodeSourceReferencesNormalized = "source_references_normalized"
ReasonCodeDuplicateNPCCollapsed = "duplicate_npc_collapsed" ReasonCodeDuplicateNPCCollapsed = "duplicate_npc_collapsed"
ReasonCodeRelationshipTargetCanonicalized = "relationship_target_canonicalized"
) )
var requiredCapabilities = []string{"merged"} var requiredCapabilities = []string{"merged"}
@@ -37,23 +36,17 @@ var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
type Options struct{} type Options struct{}
type Normalizer struct{} type Normalizer struct{}
func New(Options) *Normalizer { return &Normalizer{} } func New(Options) *Normalizer { return &Normalizer{} }
func (n *Normalizer) Key() string { return Key }
func (n *Normalizer) Key() string { return Key }
func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil } func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (n *Normalizer) ManifestMetadata() map[string]any { func (n *Normalizer) ManifestMetadata() map[string]any {
if n == nil { if n == nil {
return nil return nil
} }
return map[string]any{ return map[string]any{"identity_policy": identity.Policy, "normalization_policy": normalizationPolicy}
"identity_policy": identity.Policy,
"normalization_policy": normalizationPolicy,
}
} }
func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint { func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
@@ -76,193 +69,106 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("context error before normalize: %w", err) return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("context error before normalize: %w", err)
} }
value, warnings := normalizeList(req.MergeOutput.Value) value, warnings := normalizeList(req.MergeOutput.Value)
return contracts.TypedNormalizeResult[dnd.NPCList]{Value: value, Warnings: warnings}, nil return contracts.TypedNormalizeResult[dnd.NPCList]{Value: value, Warnings: warnings}, nil
} }
type normalizedRecord struct { type normalizedRecord struct {
npc dnd.NPC npc dnd.NPC
fieldsChanged bool
referencesChanged bool
} }
func normalizeList(input dnd.NPCList) (dnd.NPCList, []contracts.Warning) { func normalizeList(input dnd.NPCList) (dnd.NPCList, []contracts.Warning) {
if input.NPCs == nil { if input.NPCs == nil {
return dnd.NPCList{}, nil return dnd.NPCList{}, nil
} }
records := make([]normalizedRecord, len(input.NPCs)) records := make([]normalizedRecord, len(input.NPCs))
warnings := make([]contracts.Warning, 0) warnings := make([]contracts.Warning, 0)
for index, inputNPC := range input.NPCs { for index, inputNPC := range input.NPCs {
npc, fieldsChanged, referencesChanged := normalizeRecord(inputNPC) npc, fieldsChanged, referencesChanged := normalizeRecord(inputNPC)
records[index] = normalizedRecord{ records[index] = normalizedRecord{npc: npc}
npc: npc,
fieldsChanged: fieldsChanged,
referencesChanged: referencesChanged,
}
if fieldsChanged { if fieldsChanged {
warnings = append(warnings, contracts.Warning{ warnings = append(warnings, contracts.Warning{Scope: npcScope(index), ReasonCode: ReasonCodeNPCFieldsNormalized, Message: fmt.Sprintf("input index %d: NPC name normalized for %s", index, diagnostics.Quote(inputNPC.Name))})
Scope: npcScope(index),
ReasonCode: ReasonCodeNPCFieldsNormalized,
Message: fmt.Sprintf("input index %d: NPC fields normalized for %s",
index, diagnostics.Quote(inputNPC.Name)),
})
} }
if referencesChanged { if referencesChanged {
warnings = append(warnings, contracts.Warning{ warnings = append(warnings, contracts.Warning{Scope: npcScope(index), ReasonCode: ReasonCodeSourceReferencesNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", index, len(inputNPC.SourceRefs), len(npc.SourceRefs))})
Scope: npcScope(index),
ReasonCode: ReasonCodeSourceReferencesNormalized,
Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)",
index, len(inputNPC.SourceRefs), len(npc.SourceRefs)),
})
} }
if inputNPC.ID != npc.ID { if inputNPC.ID != npc.ID {
warnings = append(warnings, contracts.Warning{ warnings = append(warnings, contracts.Warning{Scope: npcScope(index), ReasonCode: ReasonCodeNPCIDRecomputed, Message: fmt.Sprintf("input index %d: NPC ID recomputed from %s", index, diagnostics.Quote(npc.Name))})
Scope: npcScope(index),
ReasonCode: ReasonCodeNPCIDRecomputed,
Message: fmt.Sprintf("input index %d: NPC ID recomputed from %s",
index, diagnostics.Quote(npc.Name)),
})
} }
} }
components := identityComponents(records) groups := canonicalNameGroups(records)
output := dnd.NPCList{NPCs: make([]dnd.NPC, 0, len(components))} output := dnd.NPCList{NPCs: make([]dnd.NPC, 0, len(groups))}
retainedIndexes := make([]int, 0, len(components)) for _, members := range groups {
for _, members := range components { consolidated, referencesChanged := consolidate(records, members)
consolidated, sourceChanged := consolidate(records, members)
retainedIndex := members[0] retainedIndex := members[0]
output.NPCs = append(output.NPCs, consolidated) output.NPCs = append(output.NPCs, consolidated)
retainedIndexes = append(retainedIndexes, retainedIndex) if referencesChanged {
warnings = append(warnings, contracts.Warning{Scope: npcScope(retainedIndex), ReasonCode: ReasonCodeSourceReferencesNormalized, Message: fmt.Sprintf("input index %d: source references normalized during identity consolidation (final count %d)", retainedIndex, len(consolidated.SourceRefs))})
if sourceChanged {
warnings = append(warnings, contracts.Warning{
Scope: npcScope(retainedIndex),
ReasonCode: ReasonCodeSourceReferencesNormalized,
Message: fmt.Sprintf("input index %d: source references normalized during identity consolidation (final count %d)",
retainedIndex, len(consolidated.SourceRefs)),
})
} }
if len(members) > 1 { if len(members) > 1 {
warnings = append(warnings, duplicateWarning(retainedIndex, members[1:])) warnings = append(warnings, duplicateWarning(retainedIndex, members[1:]))
} }
} }
warnings = append(warnings, canonicalizeRelationshipTargets(&output, retainedIndexes)...)
return output, warnings return output, warnings
} }
func normalizeRecord(input dnd.NPC) (dnd.NPC, bool, bool) { func normalizeRecord(input dnd.NPC) (dnd.NPC, bool, bool) {
output := cloneNPC(input) output := cloneNPC(input)
output.Name = identity.NormalizeDisplay(input.Name) output.Name = identity.NormalizeDisplay(input.Name)
output.Aliases = normalizeAliases(input.Aliases, output.Name)
output.Description = strings.TrimSpace(input.Description)
output.Relationships = normalizeRelationships(input.Relationships)
output.SourceRefs, _, _ = canonicalizeSourceRefs(input.SourceRefs) output.SourceRefs, _, _ = canonicalizeSourceRefs(input.SourceRefs)
output.ID = identity.DeriveID(output.Name) output.ID = identity.DeriveID(output.Name)
return output, input.Name != output.Name, !reflect.DeepEqual(input.SourceRefs, output.SourceRefs)
fieldsChanged := input.Name != output.Name ||
!reflect.DeepEqual(input.Aliases, output.Aliases) ||
input.Description != output.Description ||
!reflect.DeepEqual(input.Relationships, output.Relationships)
referencesChanged := !reflect.DeepEqual(input.SourceRefs, output.SourceRefs)
return output, fieldsChanged, referencesChanged
} }
func cloneNPC(input dnd.NPC) dnd.NPC { func cloneNPC(input dnd.NPC) dnd.NPC {
output := input input.SourceRefs = cloneSourceRefs(input.SourceRefs)
if input.Aliases != nil { return input
output.Aliases = make([]string, len(input.Aliases))
copy(output.Aliases, input.Aliases)
}
if input.Relationships != nil {
output.Relationships = make([]dnd.NPCRelationship, len(input.Relationships))
copy(output.Relationships, input.Relationships)
}
if input.SourceRefs != nil {
output.SourceRefs = make([]source.SourceRef, len(input.SourceRefs))
copy(output.SourceRefs, input.SourceRefs)
}
return output
} }
func normalizeAliases(input []string, canonicalName string) []string { func canonicalNameGroups(records []normalizedRecord) [][]int {
if input == nil { groups := make([][]int, 0, len(records))
return nil ownerByKey := make(map[string]int, len(records))
} for index, record := range records {
key := identity.ComparisonKey(record.npc.Name)
canonicalKey := identity.ComparisonKey(canonicalName) if key != "" {
output := make([]string, 0, len(input)) if groupIndex, ok := ownerByKey[key]; ok {
seen := make(map[string]struct{}, len(input)) groups[groupIndex] = append(groups[groupIndex], index)
for _, alias := range input { continue
normalized := identity.NormalizeDisplay(alias) }
key := identity.ComparisonKey(normalized) ownerByKey[key] = len(groups)
if canonicalKey != "" && key == canonicalKey {
continue
} }
if _, exists := seen[key]; exists { groups = append(groups, []int{index})
continue
}
seen[key] = struct{}{}
output = append(output, normalized)
} }
return output return groups
} }
func normalizeRelationships(input []dnd.NPCRelationship) []dnd.NPCRelationship { func consolidate(records []normalizedRecord, members []int) (dnd.NPC, bool) {
if input == nil { output := cloneNPC(records[members[0]].npc)
return nil originalRefs := cloneSourceRefs(output.SourceRefs)
} for _, member := range members[1:] {
output.SourceRefs = append(output.SourceRefs, records[member].npc.SourceRefs...)
output := make([]dnd.NPCRelationship, 0, len(input))
seen := make(map[relationshipIdentity]struct{}, len(input))
for _, relationship := range input {
normalized := dnd.NPCRelationship{
Target: identity.NormalizeDisplay(relationship.Target),
Relationship: strings.TrimSpace(relationship.Relationship),
}
key := relationshipKey(normalized)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
output = append(output, normalized)
}
return output
}
type relationshipIdentity struct {
target string
relationship string
}
func relationshipKey(relationship dnd.NPCRelationship) relationshipIdentity {
return relationshipIdentity{
target: identity.ComparisonKey(relationship.Target),
relationship: identity.ComparisonKey(relationship.Relationship),
} }
output.SourceRefs, _, _ = canonicalizeSourceRefs(output.SourceRefs)
output.ID = identity.DeriveID(output.Name)
return output, !reflect.DeepEqual(originalRefs, output.SourceRefs)
} }
func canonicalizeSourceRefs(input []source.SourceRef) ([]source.SourceRef, bool, int) { func canonicalizeSourceRefs(input []source.SourceRef) ([]source.SourceRef, bool, int) {
if input == nil { if input == nil {
return nil, false, 0 return nil, false, 0
} }
canonical := cloneSourceRefs(input)
canonical := make([]source.SourceRef, len(input))
copy(canonical, input)
sort.SliceStable(canonical, func(left, right int) bool { sort.SliceStable(canonical, func(left, right int) bool {
return sourceRefLess(canonical[left], canonical[right]) if canonical[left].SourceID != canonical[right].SourceID {
}) return canonical[left].SourceID < canonical[right].SourceID
orderChanged := false
for index := range input {
if input[index] != canonical[index] {
orderChanged = true
break
} }
} if canonical[left].StartUnitID != canonical[right].StartUnitID {
return canonical[left].StartUnitID < canonical[right].StartUnitID
}
return canonical[left].EndUnitID < canonical[right].EndUnitID
})
orderChanged := !reflect.DeepEqual(input, canonical)
unique := make([]source.SourceRef, 0, len(canonical)) unique := make([]source.SourceRef, 0, len(canonical))
for _, ref := range canonical { for _, ref := range canonical {
if len(unique) == 0 || unique[len(unique)-1] != ref { if len(unique) == 0 || unique[len(unique)-1] != ref {
@@ -272,219 +178,11 @@ func canonicalizeSourceRefs(input []source.SourceRef) ([]source.SourceRef, bool,
return unique, orderChanged, len(input) - len(unique) return unique, orderChanged, len(input) - len(unique)
} }
func sourceRefLess(left, right source.SourceRef) bool {
if left.SourceID != right.SourceID {
return left.SourceID < right.SourceID
}
if left.StartUnitID != right.StartUnitID {
return left.StartUnitID < right.StartUnitID
}
return left.EndUnitID < right.EndUnitID
}
func identityComponents(records []normalizedRecord) [][]int {
parent := make([]int, len(records))
for index := range parent {
parent[index] = index
}
for left := 0; left < len(records); left++ {
for right := left + 1; right < len(records); right++ {
if recordsCanMerge(records[left].npc, records[right].npc) {
union(parent, left, right)
}
}
}
byRoot := make(map[int][]int, len(records))
for index := range records {
root := find(parent, index)
byRoot[root] = append(byRoot[root], index)
}
roots := make([]int, 0, len(byRoot))
for root := range byRoot {
roots = append(roots, root)
}
sort.Slice(roots, func(left, right int) bool {
return byRoot[roots[left]][0] < byRoot[roots[right]][0]
})
components := make([][]int, 0, len(roots))
for _, root := range roots {
components = append(components, byRoot[root])
}
return components
}
func recordsCanMerge(left, right dnd.NPC) bool {
leftCanonical := identity.ComparisonKey(left.Name)
rightCanonical := identity.ComparisonKey(right.Name)
return leftCanonical == rightCanonical ||
containsAliasKey(right.Aliases, leftCanonical) ||
containsAliasKey(left.Aliases, rightCanonical)
}
func containsAliasKey(aliases []string, wanted string) bool {
for _, alias := range aliases {
if identity.ComparisonKey(alias) == wanted {
return true
}
}
return false
}
func find(parent []int, index int) int {
for parent[index] != index {
parent[index] = parent[parent[index]]
index = parent[index]
}
return index
}
func union(parent []int, left, right int) {
leftRoot := find(parent, left)
rightRoot := find(parent, right)
if leftRoot == rightRoot {
return
}
if leftRoot < rightRoot {
parent[rightRoot] = leftRoot
} else {
parent[leftRoot] = rightRoot
}
}
func consolidate(records []normalizedRecord, members []int) (dnd.NPC, bool) {
output := cloneNPC(records[members[0]].npc)
originalRefs := cloneSourceRefs(output.SourceRefs)
canonicalKey := identity.ComparisonKey(output.Name)
for _, member := range members[1:] {
candidate := records[member].npc
appendAlias(&output.Aliases, candidate.Name, canonicalKey)
for _, alias := range candidate.Aliases {
appendAlias(&output.Aliases, alias, canonicalKey)
}
for _, relationship := range candidate.Relationships {
appendRelationship(&output.Relationships, relationship)
}
output.SourceRefs = append(output.SourceRefs, candidate.SourceRefs...)
}
output.SourceRefs, _, _ = canonicalizeSourceRefs(output.SourceRefs)
output.ID = identity.DeriveID(output.Name)
return output, !reflect.DeepEqual(originalRefs, output.SourceRefs)
}
func cloneSourceRefs(input []source.SourceRef) []source.SourceRef { func cloneSourceRefs(input []source.SourceRef) []source.SourceRef {
if input == nil { if input == nil {
return nil return nil
} }
output := make([]source.SourceRef, len(input)) return append([]source.SourceRef(nil), input...)
copy(output, input)
return output
}
func appendAlias(aliases *[]string, value, canonicalKey string) {
key := identity.ComparisonKey(value)
if canonicalKey != "" && key == canonicalKey {
return
}
for _, existing := range *aliases {
if identity.ComparisonKey(existing) == key {
return
}
}
*aliases = append(*aliases, value)
}
func appendRelationship(relationships *[]dnd.NPCRelationship, relationship dnd.NPCRelationship) {
key := relationshipKey(relationship)
for _, existing := range *relationships {
if relationshipKey(existing) == key {
return
}
}
*relationships = append(*relationships, relationship)
}
func canonicalizeRelationshipTargets(list *dnd.NPCList, retainedIndexes []int) []contracts.Warning {
if list == nil || len(list.NPCs) == 0 {
return nil
}
owners := make(map[string][]int)
for index, npc := range list.NPCs {
if key := identity.ComparisonKey(npc.Name); key != "" {
owners[key] = append(owners[key], index)
}
for _, alias := range npc.Aliases {
if key := identity.ComparisonKey(alias); key != "" {
owners[key] = appendUniqueIndex(owners[key], index)
}
}
}
warnings := make([]contracts.Warning, 0)
for outputIndex := range list.NPCs {
npc := &list.NPCs[outputIndex]
originalRelationshipCount := len(npc.Relationships)
for relationshipIndex := range npc.Relationships {
relationship := &npc.Relationships[relationshipIndex]
key := identity.ComparisonKey(relationship.Target)
if key == "" || len(owners[key]) != 1 {
continue
}
target := list.NPCs[owners[key][0]].Name
if relationship.Target == target {
continue
}
oldTarget := relationship.Target
relationship.Target = target
warnings = append(warnings, contracts.Warning{
Scope: npcScope(retainedIndexes[outputIndex]),
ReasonCode: ReasonCodeRelationshipTargetCanonicalized,
Message: fmt.Sprintf("input index %d: relationship target canonicalized from %s to %s",
retainedIndexes[outputIndex], diagnostics.Quote(oldTarget), diagnostics.Quote(target)),
})
}
npc.Relationships = deduplicateRelationships(npc.Relationships)
if len(npc.Relationships) != originalRelationshipCount {
warnings = append(warnings, contracts.Warning{
Scope: npcScope(retainedIndexes[outputIndex]),
ReasonCode: ReasonCodeNPCFieldsNormalized,
Message: fmt.Sprintf("input index %d: duplicate relationships removed after target canonicalization",
retainedIndexes[outputIndex]),
})
}
}
return warnings
}
func deduplicateRelationships(input []dnd.NPCRelationship) []dnd.NPCRelationship {
if input == nil {
return nil
}
output := make([]dnd.NPCRelationship, 0, len(input))
seen := make(map[relationshipIdentity]struct{}, len(input))
for _, relationship := range input {
key := relationshipKey(relationship)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
output = append(output, relationship)
}
return output
}
func appendUniqueIndex(values []int, wanted int) []int {
for _, value := range values {
if value == wanted {
return values
}
}
return append(values, wanted)
} }
func duplicateWarning(retainedIndex int, removed []int) contracts.Warning { func duplicateWarning(retainedIndex int, removed []int) contracts.Warning {
@@ -497,28 +195,17 @@ func duplicateWarning(retainedIndex int, removed []int) contracts.Warning {
for index, removedIndex := range displayed { for index, removedIndex := range displayed {
indices[index] = strconv.Itoa(removedIndex) indices[index] = strconv.Itoa(removedIndex)
} }
message := fmt.Sprintf("retained input index %d; removed input indices [%s]", retainedIndex, strings.Join(indices, ", ")) message := fmt.Sprintf("retained input index %d; removed input indices [%s]", retainedIndex, strings.Join(indices, ", "))
if omitted := len(removed) - len(displayed); omitted > 0 { if omitted := len(removed) - len(displayed); omitted > 0 {
message += fmt.Sprintf("; %d additional removed input indices omitted", omitted) message += fmt.Sprintf("; %d additional removed input indices omitted", omitted)
} }
return contracts.Warning{ return contracts.Warning{Scope: npcScope(retainedIndex), ReasonCode: ReasonCodeDuplicateNPCCollapsed, Message: message}
Scope: npcScope(retainedIndex),
ReasonCode: ReasonCodeDuplicateNPCCollapsed,
Message: message,
}
} }
func npcScope(index int) string { return fmt.Sprintf("npcs[%d]", index) } func npcScope(index int) string { return fmt.Sprintf("npcs[%d]", index) }
func ModuleSpec() pipeline.ModuleSpec { func ModuleSpec() pipeline.ModuleSpec {
return pipeline.ModuleSpec{ return pipeline.ModuleSpec{Key: Key, Stage: pipeline.StageNormalize, Requires: append([]string(nil), requiredCapabilities...), Provides: append([]string(nil), providedCapabilities...), ArtifactKind: dnd.NPCListKind}
Key: Key,
Stage: pipeline.StageNormalize,
Requires: append([]string(nil), requiredCapabilities...),
Provides: append([]string(nil), providedCapabilities...),
ArtifactKind: dnd.NPCListKind,
}
} }
func Register(registry *pipeline.NormalizerRegistry) error { func Register(registry *pipeline.NormalizerRegistry) error {
@@ -531,10 +218,7 @@ func Register(registry *pipeline.NormalizerRegistry) error {
}) })
} }
func validateOptions(options map[string]any) error { func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
_, err := DecodeOptions(options)
return err
}
func DecodeOptions(options map[string]any) (Options, error) { func DecodeOptions(options map[string]any) (Options, error) {
if err := pipeline.RejectUnknownOptions(options); err != nil { if err := pipeline.RejectUnknownOptions(options); err != nil {

View File

@@ -10,223 +10,91 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
identity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
) )
func TestModuleContractAndIdentity(t *testing.T) { func TestModuleContractAndIdentity(t *testing.T) {
if _, err := DecodeOptions(nil); err != nil { if _, err := DecodeOptions(nil); err != nil {
t.Fatalf("DecodeOptions(nil) error = %v, want nil", err) t.Fatalf("DecodeOptions(nil) error = %v, want nil", err)
} }
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") { if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
t.Fatalf("DecodeOptions() error = %v, want unknown option error", err) t.Fatal("DecodeOptions() accepted unknown option")
}
want := pipeline.ModuleSpec{
Key: Key,
Stage: pipeline.StageNormalize,
Requires: []string{"merged"},
Provides: []string{"normalized"},
ArtifactKind: dnd.NPCListKind,
} }
want := pipeline.ModuleSpec{Key: Key, Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}, ArtifactKind: dnd.NPCListKind}
if got := ModuleSpec(); !reflect.DeepEqual(got, want) { if got := ModuleSpec(); !reflect.DeepEqual(got, want) {
t.Fatalf("ModuleSpec() = %#v, want %#v", got, want) t.Fatalf("ModuleSpec() = %#v, want %#v", got, want)
} }
if slots := New(Options{}).ReferenceSlots(); slots != nil {
t.Fatalf("ReferenceSlots() = %#v, want nil", slots)
}
registry := pipeline.NewNormalizerRegistry() registry := pipeline.NewNormalizerRegistry()
if err := Register(registry); err != nil { if err := Register(registry); err != nil {
t.Fatalf("Register() error = %v, want nil", err) t.Fatalf("Register() error = %v", err)
} }
if registered, ok := registry.SpecForArtifact(Key, dnd.NPCListKind); !ok || !reflect.DeepEqual(registered, want) {
t.Fatalf("registered spec = %#v, ok = %t, want %#v", registered, ok, want)
}
if err := Register(nil); err == nil || !strings.Contains(err.Error(), "normalizer registry") {
t.Fatalf("Register(nil) error = %v, want registry error", err)
}
normalizer := New(Options{}) normalizer := New(Options{})
metadata := normalizer.ManifestMetadata() if metadata := normalizer.ManifestMetadata(); metadata["identity_policy"] != identity.Policy || metadata["normalization_policy"] != normalizationPolicy {
if metadata["identity_policy"] != identity.Policy || metadata["normalization_policy"] != normalizationPolicy { t.Fatalf("metadata = %#v", metadata)
t.Fatalf("metadata = %#v, want identity and normalization policies", metadata)
} }
fingerprints := normalizer.CheckpointFingerprints() wantFingerprints := []pipeline.CheckpointFingerprint{{Name: "identity_policy", Value: identity.Policy}, {Name: "normalization_policy", Value: normalizationPolicy}}
wantFingerprints := []pipeline.CheckpointFingerprint{
{Name: "identity_policy", Value: identity.Policy},
{Name: "normalization_policy", Value: normalizationPolicy},
}
if !reflect.DeepEqual(fingerprints, wantFingerprints) {
t.Fatalf("fingerprints = %#v, want %#v", fingerprints, wantFingerprints)
}
fingerprints[0].Value = "changed"
if got := normalizer.CheckpointFingerprints(); !reflect.DeepEqual(got, wantFingerprints) { if got := normalizer.CheckpointFingerprints(); !reflect.DeepEqual(got, wantFingerprints) {
t.Fatalf("fingerprints were not defensive: %#v", got) t.Fatalf("fingerprints = %#v, want %#v", got, wantFingerprints)
} }
} }
func TestNormalizePerRecordFieldsAndEvidence(t *testing.T) { func TestNormalizeNamesEvidenceAndIDs(t *testing.T) {
input := dnd.NPCList{NPCs: []dnd.NPC{{ input := dnd.NPCList{NPCs: []dnd.NPC{{
ID: "wrong", ID: "wrong", Name: " Lady\tAsh ", SourceRefs: []source.SourceRef{
Name: " Lady\tAsh ",
Aliases: []string{" Ash ", " L.A. ", "l.a.", " Lady Ash "},
Description: " first description\n",
Relationships: []dnd.NPCRelationship{
{Target: " Lord\nOak ", Relationship: " friend "},
{Target: "lord oak", Relationship: "friend"},
},
SourceRefs: []source.SourceRef{
{SourceID: "b", StartUnitID: 2, EndUnitID: 3}, {SourceID: "b", StartUnitID: 2, EndUnitID: 3},
{SourceID: "a", StartUnitID: 4, EndUnitID: 4}, {SourceID: "a", StartUnitID: 4, EndUnitID: 4},
{SourceID: "b", StartUnitID: 2, EndUnitID: 3}, {SourceID: "b", StartUnitID: 2, EndUnitID: 3},
}, },
}}} }}}
result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(input)) result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(input))
if err != nil { if err != nil {
t.Fatalf("Normalize() error = %v, want nil", err) t.Fatalf("Normalize() error = %v", err)
} }
got := result.Value.NPCs[0] want := dnd.NPC{ID: identity.DeriveID("Lady Ash"), Name: "Lady Ash", SourceRefs: []source.SourceRef{{SourceID: "a", StartUnitID: 4, EndUnitID: 4}, {SourceID: "b", StartUnitID: 2, EndUnitID: 3}}}
if got.Name != "Lady Ash" || got.Description != "first description" { if !reflect.DeepEqual(result.Value.NPCs[0], want) {
t.Fatalf("normalized fields = %#v, want normalized display and description", got) t.Fatalf("NPC = %#v, want %#v", result.Value.NPCs[0], want)
} }
if !reflect.DeepEqual(got.Aliases, []string{"Ash", "L.A."}) { for _, reason := range []string{ReasonCodeNPCFieldsNormalized, ReasonCodeSourceReferencesNormalized, ReasonCodeNPCIDRecomputed} {
t.Fatalf("aliases = %#v, want canonical alias removal and deduplication", got.Aliases) if !hasWarning(result.Warnings, reason, "npcs[0]") {
} t.Fatalf("warnings = %#v, want %s", result.Warnings, reason)
wantRelationships := []dnd.NPCRelationship{{Target: "Lord Oak", Relationship: "friend"}} }
if !reflect.DeepEqual(got.Relationships, wantRelationships) {
t.Fatalf("relationships = %#v, want %#v", got.Relationships, wantRelationships)
}
wantRefs := []source.SourceRef{
{SourceID: "a", StartUnitID: 4, EndUnitID: 4},
{SourceID: "b", StartUnitID: 2, EndUnitID: 3},
}
if !reflect.DeepEqual(got.SourceRefs, wantRefs) {
t.Fatalf("source refs = %#v, want %#v", got.SourceRefs, wantRefs)
}
if got.ID != identity.DeriveID("Lady Ash") {
t.Fatalf("ID = %q, want derived ID", got.ID)
}
if !hasWarning(result.Warnings, ReasonCodeNPCFieldsNormalized, "npcs[0]") ||
!hasWarning(result.Warnings, ReasonCodeSourceReferencesNormalized, "npcs[0]") ||
!hasWarning(result.Warnings, ReasonCodeNPCIDRecomputed, "npcs[0]") {
t.Fatalf("warnings = %#v, want field, source, and ID warnings", result.Warnings)
} }
} }
func TestNormalizeConsolidatesIdentityComponentsInStableOrder(t *testing.T) { func TestNormalizeConsolidatesCanonicalNamesOnlyAndUnionsEvidence(t *testing.T) {
input := dnd.NPCList{NPCs: []dnd.NPC{ input := dnd.NPCList{NPCs: []dnd.NPC{
{ {Name: " Captain Vale ", SourceRefs: []source.SourceRef{{SourceID: "a", StartUnitID: 1, EndUnitID: 1}}},
Name: " Captain Vale ", {Name: "captain vale", SourceRefs: []source.SourceRef{{SourceID: "b", StartUnitID: 2, EndUnitID: 2}}},
Description: "first description", {Name: "The Captain", SourceRefs: []source.SourceRef{{SourceID: "c", StartUnitID: 3, EndUnitID: 3}}},
Aliases: []string{"Vale"},
Relationships: []dnd.NPCRelationship{{Target: "Archivist", Relationship: "knows"}},
SourceRefs: []source.SourceRef{{SourceID: "a", StartUnitID: 1, EndUnitID: 1}},
},
{
Name: "Captain Vale",
Description: "second description",
Aliases: []string{"CV"},
SourceRefs: []source.SourceRef{{SourceID: "b", StartUnitID: 1, EndUnitID: 1}},
},
{
Name: "The Sage",
Description: "sage description",
Aliases: []string{"Archivist"},
SourceRefs: []source.SourceRef{{SourceID: "c", StartUnitID: 1, EndUnitID: 1}},
},
{
Name: "Archivist",
Description: "later sage description",
Aliases: []string{"Chronicler"},
SourceRefs: []source.SourceRef{{SourceID: "d", StartUnitID: 1, EndUnitID: 1}},
},
{
Name: "North",
Description: "north description",
SourceRefs: []source.SourceRef{{SourceID: "e", StartUnitID: 1, EndUnitID: 1}},
},
{
Name: "North Old",
Description: "old north description",
Aliases: []string{"North"},
SourceRefs: []source.SourceRef{{SourceID: "f", StartUnitID: 1, EndUnitID: 1}},
},
{
Name: "North Renamed",
Description: "renamed north description",
Aliases: []string{"North Old"},
SourceRefs: []source.SourceRef{{SourceID: "g", StartUnitID: 1, EndUnitID: 1}},
},
{Name: "Red", Description: "red", Aliases: []string{"Shared"}, SourceRefs: []source.SourceRef{{SourceID: "h", StartUnitID: 1, EndUnitID: 1}}},
{Name: "Blue", Description: "blue", Aliases: []string{"Shared"}, SourceRefs: []source.SourceRef{{SourceID: "i", StartUnitID: 1, EndUnitID: 1}}},
}} }}
result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(input)) result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(input))
if err != nil { if err != nil {
t.Fatalf("Normalize() error = %v, want nil", err) t.Fatalf("Normalize() error = %v", err)
} }
if got := len(result.Value.NPCs); got != 5 { if len(result.Value.NPCs) != 2 || result.Value.NPCs[0].Name != "Captain Vale" || result.Value.NPCs[1].Name != "The Captain" {
t.Fatalf("normalized NPC count = %d, want five components", got) t.Fatalf("NPCs = %#v, want name-only stable consolidation", result.Value.NPCs)
} }
if refs := result.Value.NPCs[0].SourceRefs; len(refs) != 2 || refs[0].SourceID != "a" || refs[1].SourceID != "b" {
first := result.Value.NPCs[0] t.Fatalf("source refs = %#v, want evidence union", refs)
if first.Name != "Captain Vale" || first.Description != "first description" || !reflect.DeepEqual(first.Aliases, []string{"Vale", "CV"}) {
t.Fatalf("first component = %#v, want first description and ordered aliases", first)
} }
if !reflect.DeepEqual(first.SourceRefs, []source.SourceRef{ if !hasWarning(result.Warnings, ReasonCodeDuplicateNPCCollapsed, "npcs[0]") {
{SourceID: "a", StartUnitID: 1, EndUnitID: 1}, t.Fatalf("warnings = %#v, want duplicate collapse", result.Warnings)
{SourceID: "b", StartUnitID: 1, EndUnitID: 1},
}) {
t.Fatalf("first provenance = %#v, want unioned refs", first.SourceRefs)
}
second := result.Value.NPCs[1]
if second.Name != "The Sage" || !reflect.DeepEqual(second.Aliases, []string{"Archivist", "Chronicler"}) || second.Description != "sage description" {
t.Fatalf("canonical-to-alias component = %#v, want consolidated sage", second)
}
if first.Relationships[0].Target != "The Sage" {
t.Fatalf("relationship target = %q, want The Sage", first.Relationships[0].Target)
}
if !hasWarning(result.Warnings, ReasonCodeRelationshipTargetCanonicalized, "npcs[0]") ||
!hasWarning(result.Warnings, ReasonCodeDuplicateNPCCollapsed, "npcs[0]") ||
!hasWarning(result.Warnings, ReasonCodeDuplicateNPCCollapsed, "npcs[2]") {
t.Fatalf("warnings = %#v, want collapse and target warnings", result.Warnings)
}
if got := result.Value.NPCs[2].Aliases; !reflect.DeepEqual(got, []string{"North Old", "North Renamed"}) {
t.Fatalf("transitive aliases = %#v, want ordered canonical members", got)
}
if result.Value.NPCs[3].Name != "Red" || result.Value.NPCs[4].Name != "Blue" {
t.Fatalf("shared-alias ordering = %#v, want Red then Blue", result.Value.NPCs[3:])
} }
} }
func TestNormalizePreservesInvalidEvidenceAndDoesNotAliasInput(t *testing.T) { func TestNormalizePreservesInvalidCandidatesAndDoesNotAliasInput(t *testing.T) {
invalid := dnd.NPCList{NPCs: []dnd.NPC{{ input := dnd.NPCList{NPCs: []dnd.NPC{{Name: " ", SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 0, EndUnitID: -1}}}}}
Name: " ", before := dnd.NPCList{NPCs: []dnd.NPC{{Name: " ", SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 0, EndUnitID: -1}}}}}
Aliases: []string{""}, result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(input))
Description: " ", if err != nil || !reflect.DeepEqual(input, before) {
Relationships: []dnd.NPCRelationship{{Target: " ", Relationship: " "}}, t.Fatalf("Normalize() = %#v, %v; input mutated to %#v", result, err, input)
SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 9, EndUnitID: 9}},
}}}
original := cloneNPCListForTest(invalid)
result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(invalid))
if err != nil {
t.Fatalf("Normalize() error = %v, want nil", err)
} }
if !reflect.DeepEqual(invalid, original) { if result.Value.NPCs[0].Name != "" || result.Value.NPCs[0].SourceRefs[0].EndUnitID != -1 {
t.Fatalf("normalizer mutated input: got %#v, want %#v", invalid, original) t.Fatalf("candidate = %#v, want invalid semantics preserved", result.Value.NPCs[0])
} }
if result.Value.NPCs[0].Name != "" || result.Value.NPCs[0].Aliases[0] != "" || result.Value.NPCs[0].Relationships[0].Target != "" {
t.Fatalf("invalid evidence was unexpectedly removed: %#v", result.Value.NPCs[0])
}
result.Value.NPCs[0].Aliases[0] = "changed"
result.Value.NPCs[0].Relationships[0].Target = "changed"
result.Value.NPCs[0].SourceRefs[0].SourceID = "changed" result.Value.NPCs[0].SourceRefs[0].SourceID = "changed"
if invalid.NPCs[0].Aliases[0] != "" || invalid.NPCs[0].Relationships[0].Target != " " || invalid.NPCs[0].SourceRefs[0].SourceID != "source" { if input.NPCs[0].SourceRefs[0].SourceID != "source" {
t.Fatalf("output aliases input storage: input = %#v", invalid) t.Fatal("output aliases input evidence")
} }
} }
@@ -234,52 +102,17 @@ func TestNormalizeHandlesNilAndCanceledCalls(t *testing.T) {
normalizer := New(Options{}) normalizer := New(Options{})
result, err := normalizer.Normalize(context.Background(), normalizeRequest(dnd.NPCList{NPCs: nil})) result, err := normalizer.Normalize(context.Background(), normalizeRequest(dnd.NPCList{NPCs: nil}))
if err != nil || result.Value.NPCs != nil { if err != nil || result.Value.NPCs != nil {
t.Fatalf("nil list result = %#v, error = %v, want nil NPC slice", result.Value, err) t.Fatalf("nil list result = %#v, error = %v", result.Value, err)
} }
canceled, cancel := context.WithCancel(context.Background()) canceled, cancel := context.WithCancel(context.Background())
cancel() cancel()
if _, err := normalizer.Normalize(canceled, normalizeRequest(dnd.NPCList{})); err == nil || !strings.Contains(err.Error(), "context error") { if _, err := normalizer.Normalize(canceled, normalizeRequest(dnd.NPCList{})); err == nil || !strings.Contains(err.Error(), "context error") {
t.Fatalf("canceled Normalize() error = %v, want context error", err) t.Fatalf("canceled Normalize() error = %v", err)
}
if _, err := normalizer.Normalize(nil, normalizeRequest(dnd.NPCList{})); err == nil || !strings.Contains(err.Error(), "context must not be nil") {
t.Fatalf("nil context Normalize() error = %v, want context error", err)
}
var nilNormalizer *Normalizer
if _, err := nilNormalizer.Normalize(context.Background(), normalizeRequest(dnd.NPCList{})); err == nil {
t.Fatal("nil normalizer Normalize() error = nil, want error")
}
}
func TestNormalizePreservesPresentEmptyAliases(t *testing.T) {
result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(dnd.NPCList{NPCs: []dnd.NPC{{
Name: "Hooded Guard",
Aliases: []string{},
Description: "A distinguishable sentry.",
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}}))
if err != nil {
t.Fatalf("Normalize() error = %v, want nil", err)
}
if result.Value.NPCs[0].Aliases == nil || len(result.Value.NPCs[0].Aliases) != 0 {
t.Fatalf("aliases = %#v, want present empty array", result.Value.NPCs[0].Aliases)
} }
} }
func normalizeRequest(value dnd.NPCList) contracts.TypedNormalizeRequest[dnd.NPCList] { func normalizeRequest(value dnd.NPCList) contracts.TypedNormalizeRequest[dnd.NPCList] {
return contracts.TypedNormalizeRequest[dnd.NPCList]{ return contracts.TypedNormalizeRequest[dnd.NPCList]{MergeOutput: contracts.MergeArtifact[dnd.NPCList]{Value: value}}
MergeOutput: contracts.MergeArtifact[dnd.NPCList]{Value: value},
}
}
func cloneNPCListForTest(input dnd.NPCList) dnd.NPCList {
output := dnd.NPCList{}
if input.NPCs != nil {
output.NPCs = make([]dnd.NPC, len(input.NPCs))
for index, npc := range input.NPCs {
output.NPCs[index] = cloneNPC(npc)
}
}
return output
} }
func hasWarning(warnings []contracts.Warning, reason, scope string) bool { func hasWarning(warnings []contracts.Warning, reason, scope string) bool {

View File

@@ -29,24 +29,17 @@ const idPrefix = "npc:sha256:"
type IssueCode string type IssueCode string
const ( const (
IssueEmptyCanonicalName IssueCode = "empty_canonical_name" IssueEmptyCanonicalName IssueCode = "empty_canonical_name"
IssueEmptyAlias IssueCode = "empty_alias" IssueInvalidID IssueCode = "invalid_id"
IssueInvalidID IssueCode = "invalid_id" IssueIDMismatch IssueCode = "id_mismatch"
IssueIDMismatch IssueCode = "id_mismatch" IssueDuplicateCanonical IssueCode = "duplicate_canonical_identity"
IssueDuplicateCanonical IssueCode = "duplicate_canonical_identity" IssueDuplicateID IssueCode = "duplicate_id"
IssueDuplicateID IssueCode = "duplicate_id"
IssueDuplicateAlias IssueCode = "duplicate_alias"
IssueOwnCanonicalAlias IssueCode = "alias_matches_canonical_name"
IssueAliasCanonicalCollision IssueCode = "alias_canonical_collision"
IssueAliasOwnershipCollision IssueCode = "alias_owned_by_multiple_records"
) )
// Issue is an inspectable identity validation problem. AliasIndex is -1 when // Issue is an inspectable identity validation problem.
// the issue applies to an NPC as a whole rather than a particular alias.
type Issue struct { type Issue struct {
Code IssueCode Code IssueCode
RecordIndex int RecordIndex int
AliasIndex int
Value string Value string
} }
@@ -107,88 +100,35 @@ func ValidID(value string) bool { return IsValidID(value) }
// It accepts the NPC slice used by typed pipeline artifacts. Use ValidateList // It accepts the NPC slice used by typed pipeline artifacts. Use ValidateList
// when the enclosing NPCList is more convenient at the call site. // when the enclosing NPCList is more convenient at the call site.
func ValidateRegistry(npcs []dnd.NPC) []Issue { func ValidateRegistry(npcs []dnd.NPC) []Issue {
type record struct {
canonical string
aliases []string
}
records := make([]record, len(npcs))
issues := make([]Issue, 0) issues := make([]Issue, 0)
canonicalOwners := make(map[string][]int) canonicalOwners := make(map[string][]int)
idOwners := make(map[string][]int) idOwners := make(map[string][]int)
aliasOwners := make(map[string][]int)
for recordIndex, npc := range npcs { for recordIndex, npc := range npcs {
canonical := ComparisonKey(npc.Name) canonical := ComparisonKey(npc.Name)
records[recordIndex].canonical = canonical
if canonical == "" { if canonical == "" {
issues = append(issues, Issue{Code: IssueEmptyCanonicalName, RecordIndex: recordIndex, AliasIndex: -1, Value: npc.Name}) issues = append(issues, Issue{Code: IssueEmptyCanonicalName, RecordIndex: recordIndex, Value: npc.Name})
} else { } else {
canonicalOwners[canonical] = append(canonicalOwners[canonical], recordIndex) canonicalOwners[canonical] = append(canonicalOwners[canonical], recordIndex)
} }
if !IsValidID(npc.ID) { if !IsValidID(npc.ID) {
issues = append(issues, Issue{Code: IssueInvalidID, RecordIndex: recordIndex, AliasIndex: -1, Value: npc.ID}) issues = append(issues, Issue{Code: IssueInvalidID, RecordIndex: recordIndex, Value: npc.ID})
} else if expected := DeriveID(npc.Name); npc.ID != expected { } else if expected := DeriveID(npc.Name); npc.ID != expected {
issues = append(issues, Issue{Code: IssueIDMismatch, RecordIndex: recordIndex, AliasIndex: -1, Value: npc.ID}) issues = append(issues, Issue{Code: IssueIDMismatch, RecordIndex: recordIndex, Value: npc.ID})
} }
if npc.ID != "" { if npc.ID != "" {
idOwners[npc.ID] = append(idOwners[npc.ID], recordIndex) idOwners[npc.ID] = append(idOwners[npc.ID], recordIndex)
} }
seenAliases := make(map[string]int, len(npc.Aliases))
for aliasIndex, alias := range npc.Aliases {
key := ComparisonKey(alias)
records[recordIndex].aliases = append(records[recordIndex].aliases, key)
if key == "" {
issues = append(issues, Issue{Code: IssueEmptyAlias, RecordIndex: recordIndex, AliasIndex: aliasIndex, Value: alias})
continue
}
if _, ok := seenAliases[key]; ok {
issues = append(issues, Issue{Code: IssueDuplicateAlias, RecordIndex: recordIndex, AliasIndex: aliasIndex, Value: alias})
} else {
seenAliases[key] = aliasIndex
}
if key == canonical {
issues = append(issues, Issue{Code: IssueOwnCanonicalAlias, RecordIndex: recordIndex, AliasIndex: aliasIndex, Value: alias})
}
aliasOwners[key] = append(aliasOwners[key], recordIndex)
}
} }
for recordIndex, record := range records { for recordIndex, npc := range npcs {
if record.canonical != "" && len(canonicalOwners[record.canonical]) > 1 && canonicalOwners[record.canonical][0] != recordIndex { canonical := ComparisonKey(npc.Name)
issues = append(issues, Issue{Code: IssueDuplicateCanonical, RecordIndex: recordIndex, AliasIndex: -1, Value: npcs[recordIndex].Name}) if canonical != "" && len(canonicalOwners[canonical]) > 1 && canonicalOwners[canonical][0] != recordIndex {
issues = append(issues, Issue{Code: IssueDuplicateCanonical, RecordIndex: recordIndex, Value: npc.Name})
} }
if id := npcs[recordIndex].ID; id != "" && len(idOwners[id]) > 1 && idOwners[id][0] != recordIndex { if npc.ID != "" && len(idOwners[npc.ID]) > 1 && idOwners[npc.ID][0] != recordIndex {
issues = append(issues, Issue{Code: IssueDuplicateID, RecordIndex: recordIndex, AliasIndex: -1, Value: id}) issues = append(issues, Issue{Code: IssueDuplicateID, RecordIndex: recordIndex, Value: npc.ID})
}
}
seenAliasKeys := make(map[string]struct{})
for _, record := range records {
for _, alias := range record.aliases {
if alias == "" {
continue
}
if _, alreadyProcessed := seenAliasKeys[alias]; alreadyProcessed {
continue
}
seenAliasKeys[alias] = struct{}{}
owners := uniqueIndexes(aliasOwners[alias])
if len(owners) > 1 {
for _, recordIndex := range owners {
issues = append(issues, Issue{Code: IssueAliasOwnershipCollision, RecordIndex: recordIndex, AliasIndex: aliasIndexFor(records[recordIndex].aliases, alias), Value: alias})
}
}
for _, recordIndex := range owners {
for _, canonicalOwner := range canonicalOwners[alias] {
if canonicalOwner != recordIndex {
issues = append(issues, Issue{Code: IssueAliasCanonicalCollision, RecordIndex: recordIndex, AliasIndex: aliasIndexFor(records[recordIndex].aliases, alias), Value: alias})
break
}
}
}
} }
} }
@@ -201,28 +141,6 @@ func ValidateList(list dnd.NPCList) []Issue { return ValidateRegistry(list.NPCs)
// Validate is a convenience alias for ValidateList. // Validate is a convenience alias for ValidateList.
func Validate(list dnd.NPCList) []Issue { return ValidateList(list) } func Validate(list dnd.NPCList) []Issue { return ValidateList(list) }
func uniqueIndexes(values []int) []int {
seen := make(map[int]struct{}, len(values))
unique := make([]int, 0, len(values))
for _, value := range values {
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
unique = append(unique, value)
}
return unique
}
func aliasIndexFor(aliases []string, key string) int {
for index, alias := range aliases {
if alias == key {
return index
}
}
return -1
}
// Error makes an issue useful in simple callers while preserving its // Error makes an issue useful in simple callers while preserving its
// structured fields for aggregate diagnostics. // structured fields for aggregate diagnostics.
func (i Issue) Error() string { func (i Issue) Error() string {

View File

@@ -77,18 +77,13 @@ func TestIdentityFunctionsAreSafeForConcurrentUse(t *testing.T) {
func TestValidateRegistryReportsIdentityCollisionCategories(t *testing.T) { func TestValidateRegistryReportsIdentityCollisionCategories(t *testing.T) {
validID := DeriveID("Mira Thorn") validID := DeriveID("Mira Thorn")
npcs := []dnd.NPC{ npcs := []dnd.NPC{
{ID: validID, Name: "Mira Thorn", Aliases: []string{"The Greencloak", "the greencloak", "Mira Thorn"}}, {ID: validID, Name: "Mira Thorn"},
{ID: validID, Name: "Mira Thorn", Aliases: []string{"The Greencloak"}}, {ID: validID, Name: "Mira Thorn"},
{ID: DeriveID("Captain Vale"), Name: "Captain Vale", Aliases: []string{"Mira Thorn"}},
} }
issues := ValidateRegistry(npcs) issues := ValidateRegistry(npcs)
want := map[IssueCode]bool{ want := map[IssueCode]bool{
IssueDuplicateAlias: false, IssueDuplicateCanonical: false,
IssueOwnCanonicalAlias: false, IssueDuplicateID: false,
IssueDuplicateCanonical: false,
IssueDuplicateID: false,
IssueAliasOwnershipCollision: false,
IssueAliasCanonicalCollision: false,
} }
for _, issue := range issues { for _, issue := range issues {
if _, ok := want[issue.Code]; ok { if _, ok := want[issue.Code]; ok {

View File

@@ -5,6 +5,7 @@ package registry
import ( import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json"
"fmt" "fmt"
"mime" "mime"
"strings" "strings"
@@ -27,12 +28,13 @@ const (
// Registry is an immutable, validated NPC registry prepared for prompt // Registry is an immutable, validated NPC registry prepared for prompt
// grounding. All accessors return defensive copies. // grounding. All accessors return defensive copies.
type Registry struct { type Registry struct {
bound bool bound bool
list dnd.NPCList list dnd.NPCList
canonical []byte canonical []byte
digest string digest string
promptInput contracts.LLMInputMaterial projectionDigest string
lookupByKey map[string]int promptInput contracts.LLMInputMaterial
lookupByKey map[string]int
} }
// Resolver retains only the validated construction-time registry and immutable // Resolver retains only the validated construction-time registry and immutable
@@ -141,11 +143,13 @@ func Resolve(references contracts.ReferenceSet) (*Registry, error) {
slot, ok := references.Slots[ReferenceSlot] slot, ok := references.Slots[ReferenceSlot]
if !ok { if !ok {
content := []byte(emptyPrompt) content := []byte(emptyPrompt)
projectionDigest := semanticDigest(content)
return &Registry{ return &Registry{
list: dnd.NPCList{NPCs: []dnd.NPC{}}, list: dnd.NPCList{NPCs: []dnd.NPC{}},
canonical: append([]byte(nil), content...), canonical: append([]byte(nil), content...),
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, "", ""), projectionDigest: projectionDigest,
lookupByKey: map[string]int{}, promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, projectionDigest, ""),
lookupByKey: map[string]int{},
}, nil }, nil
} }
if len(slot.Items) != 1 { if len(slot.Items) != 1 {
@@ -178,21 +182,24 @@ func Resolve(references contracts.ReferenceSet) (*Registry, error) {
} }
list := cloneNPCList(value) list := cloneNPCList(value)
lookupByKey := make(map[string]int, len(list.NPCs)*2) lookupByKey := make(map[string]int, len(list.NPCs))
for index, npc := range list.NPCs { for index, npc := range list.NPCs {
lookupByKey[identity.ComparisonKey(npc.Name)] = index lookupByKey[identity.ComparisonKey(npc.Name)] = index
for _, alias := range npc.Aliases {
lookupByKey[identity.ComparisonKey(alias)] = index
}
} }
digest := semanticDigest(content) digest := semanticDigest(content)
projection, err := nameProjection(list)
if err != nil {
return nil, fmt.Errorf("encode NPC name projection: %w", err)
}
projectionDigest := semanticDigest(projection)
return &Registry{ return &Registry{
bound: true, bound: true,
list: list, list: list,
canonical: append([]byte(nil), content...), canonical: append([]byte(nil), content...),
digest: digest, digest: digest,
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, digest, ""), projectionDigest: projectionDigest,
lookupByKey: lookupByKey, promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, projection, projectionDigest, ""),
lookupByKey: lookupByKey,
}, nil }, nil
} }
@@ -235,6 +242,15 @@ func (r *Registry) Digest() string {
return r.digest return r.digest
} }
// 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 {
return ""
}
return r.projectionDigest
}
// Count returns the number of validated NPC records. // Count returns the number of validated NPC records.
func (r *Registry) Count() int { func (r *Registry) Count() int {
if r == nil { if r == nil {
@@ -243,8 +259,8 @@ func (r *Registry) Count() int {
return len(r.list.NPCs) return len(r.list.NPCs)
} }
// PromptInput returns the canonical registry as a content-safe prompt input. // PromptInput returns the names-only registry projection as a content-safe
// Reference provenance is deliberately omitted. // prompt input. Durable IDs, evidence, and reference provenance are omitted.
func (r *Registry) PromptInput() contracts.LLMInputMaterial { func (r *Registry) PromptInput() contracts.LLMInputMaterial {
if r == nil { if r == nil {
return contracts.LLMInputMaterial{} return contracts.LLMInputMaterial{}
@@ -252,8 +268,8 @@ func (r *Registry) PromptInput() contracts.LLMInputMaterial {
return r.promptInput.Clone() return r.promptInput.Clone()
} }
// Lookup returns the canonical NPC for an exact canonical-name or alias match // Lookup returns the canonical NPC for an exact canonical-name match under the
// under the NPC identity comparison policy. // NPC identity comparison policy.
func (r *Registry) Lookup(value string) (dnd.NPC, bool) { func (r *Registry) Lookup(value string) (dnd.NPC, bool) {
if r == nil { if r == nil {
return dnd.NPC{}, false return dnd.NPC{}, false
@@ -270,13 +286,26 @@ func semanticDigest(content []byte) string {
return "sha256:" + hex.EncodeToString(sum[:]) return "sha256:" + hex.EncodeToString(sum[:])
} }
type projectedNPC struct {
Name string `json:"name"`
}
type projectedNPCList struct {
NPCs []projectedNPC `json:"npcs"`
}
func nameProjection(list dnd.NPCList) ([]byte, error) {
projection := projectedNPCList{NPCs: make([]projectedNPC, len(list.NPCs))}
for index, npc := range list.NPCs {
projection.NPCs[index] = projectedNPC{Name: npc.Name}
}
return json.Marshal(projection)
}
func formatIdentityIssues(issues []identity.Issue) string { func formatIdentityIssues(issues []identity.Issue) string {
parts := make([]string, len(issues)) parts := make([]string, len(issues))
for index, issue := range issues { for index, issue := range issues {
location := fmt.Sprintf("record %d", issue.RecordIndex) location := fmt.Sprintf("record %d", issue.RecordIndex)
if issue.AliasIndex >= 0 {
location += fmt.Sprintf(" alias %d", issue.AliasIndex)
}
parts[index] = fmt.Sprintf("%s at %s", issue.Code, location) parts[index] = fmt.Sprintf("%s at %s", issue.Code, location)
} }
return diagnostics.Aggregate("validate NPC registry identity", parts) return diagnostics.Aggregate("validate NPC registry identity", parts)
@@ -298,8 +327,6 @@ func cloneNPCs(values []dnd.NPC) []dnd.NPC {
} }
func cloneNPC(value dnd.NPC) dnd.NPC { func cloneNPC(value dnd.NPC) dnd.NPC {
value.Aliases = append([]string(nil), value.Aliases...)
value.Relationships = append([]dnd.NPCRelationship(nil), value.Relationships...)
value.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...) value.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...)
return value return value
} }

View File

@@ -2,313 +2,162 @@ package registry
import ( import (
"bytes" "bytes"
"encoding/json" "reflect"
"fmt"
"strings" "strings"
"sync"
"testing" "testing"
"unicode/utf8"
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs" npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
) )
func TestResolveAbsentRegistryUsesExactEmptyPrompt(t *testing.T) { func TestResolveUnboundRegistryHasExactEmptyProjection(t *testing.T) {
resolved, err := Resolve(contracts.ReferenceSet{}) registry, err := Resolve(contracts.ReferenceSet{})
if err != nil { if err != nil {
t.Fatalf("Resolve() error = %v, want nil", err) t.Fatalf("Resolve() error = %v", err)
} }
if resolved.Bound() || resolved.Digest() != "" || resolved.Count() != 0 { input := registry.PromptInput()
t.Fatalf("resolved unbound registry = %#v, want no semantic metadata", resolved) if registry.Bound() || registry.Digest() != "" || registry.Count() != 0 || string(input.Content) != emptyPrompt {
t.Fatalf("registry = %#v input = %#v, want unbound empty registry", registry, input)
} }
input := resolved.PromptInput() if registry.ProjectionDigest() == "" || input.Digest != registry.ProjectionDigest() || input.OriginURI != "" {
if input.Name != ReferenceSlot || input.MediaType != npccodec.MediaType || input.Digest != "" || input.OriginURI != "" { t.Fatalf("projection digest/input = %q/%#v", registry.ProjectionDigest(), input)
t.Fatalf("unbound prompt input metadata = %#v, want name/media type only", input)
}
if got := string(input.Content); got != emptyPrompt {
t.Fatalf("unbound prompt input = %q, want exact empty registry", got)
}
if got := string(resolved.CanonicalBytes()); got != emptyPrompt {
t.Fatalf("unbound canonical bytes = %q, want exact empty registry", got)
} }
} }
func TestResolveCanonicalizesAndProvidesSemanticIdentity(t *testing.T) { func TestResolveKeepsDurableProvenanceAndProjectsOnlyOrderedNames(t *testing.T) {
value := validRegistryList() list := registryFixture()
canonical := encodeRegistry(t, value) registry := resolveList(t, list)
raw := append([]byte(" \n"), canonical...) if !registry.Bound() || registry.Digest() == "" || registry.Count() != 2 {
raw = append(raw, []byte("\n ")...) t.Fatalf("registry identity = bound %t digest %q count %d", registry.Bound(), registry.Digest(), registry.Count())
resolved, err := Resolve(registryReference(raw, "file:///another-session/npcs.json"))
if err != nil {
t.Fatalf("Resolve() error = %v, want nil", err)
} }
if !resolved.Bound() || resolved.Count() != len(value.NPCs) { if got := string(registry.PromptInput().Content); got != `{"npcs":[{"name":"Mira Thorn"},{"name":"Captain Vale"}]}` {
t.Fatalf("resolved registry = %#v, want bound registry with %d NPC", resolved, len(value.NPCs)) t.Fatalf("prompt projection = %s", got)
} }
if !bytes.Equal(resolved.CanonicalBytes(), canonical) || !bytes.Equal(resolved.PromptInput().Content, canonical) { for _, forbidden := range []string{"npc:sha256:", "source_refs", "source_id", "session-alpha"} {
t.Fatalf("canonical content = %s, want %s", resolved.CanonicalBytes(), canonical) if strings.Contains(string(registry.PromptInput().Content), forbidden) {
} t.Fatalf("projection leaked %q: %s", forbidden, registry.PromptInput().Content)
if resolved.PromptInput().Digest != resolved.Digest() || !strings.HasPrefix(resolved.Digest(), "sha256:") {
t.Fatalf("semantic digest = %q, want SHA-256 digest", resolved.Digest())
}
if resolved.PromptInput().OriginURI != "" {
t.Fatalf("prompt input origin = %q, want no provenance path", resolved.PromptInput().OriginURI)
}
}
func TestResolveRejectsInvalidBoundaryValuesWithoutContent(t *testing.T) {
valid := validRegistryList()
second := valid.NPCs[0]
second.ID = identity.DeriveID("Captain Vale")
second.Name = "Captain Vale"
second.Aliases = []string{"The Greencloak"}
valueWithAliasCollision := dnd.NPCList{NPCs: []dnd.NPC{valid.NPCs[0], second}}
invalidID := valid
invalidID.NPCs[0].ID = "not-an-npc-id"
tests := []struct {
name string
reference contracts.ReferenceSet
wantError string
forbidden []string
}{
{name: "zero items", reference: contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ReferenceSlot: {Items: []contracts.ReferenceItem{}}}}, wantError: "exactly one"},
{name: "multiple", reference: contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ReferenceSlot: {Items: []contracts.ReferenceItem{{Content: []byte(emptyPrompt)}, {Content: []byte(emptyPrompt)}}}}}, wantError: "exactly one"},
{name: "wrong media type", reference: registryReferenceWithMedia([]byte(emptyPrompt), "text/plain"), wantError: "must be application/json"},
{name: "malformed JSON", reference: registryReference([]byte(`{"npcs":[],"MALFORMED_REGISTRY_SECRET":`), "file:///private.json"), wantError: "invalid approved NPC JSON", forbidden: []string{"MALFORMED_REGISTRY_SECRET"}},
{name: "unknown field", reference: registryReference([]byte(`{"npcs":[],"UNKNOWN_FIELD_SECRET":true}`), "file:///private.json"), wantError: "invalid approved NPC JSON", forbidden: []string{"UNKNOWN_FIELD_SECRET"}},
{name: "invalid ID", reference: registryReference(marshalRegistry(t, invalidID), "file:///private.json"), wantError: "decode NPC registry"},
{name: "alias collision", reference: registryReference(encodeRegistry(t, valueWithAliasCollision), "file:///private.json"), wantError: string(identity.IssueAliasOwnershipCollision)},
{name: "byte limit", reference: registryReference(bytes.Repeat([]byte("x"), MaxBytes+1), "file:///private.json"), wantError: "limit"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := Resolve(test.reference)
if err == nil || !strings.Contains(err.Error(), test.wantError) {
t.Fatalf("Resolve() error = %v, want %q", err, test.wantError)
}
for _, forbidden := range append(test.forbidden, "Mira Thorn", "The Greencloak", "private.json") {
if strings.Contains(err.Error(), forbidden) {
t.Fatalf("error leaked registry content or provenance %q: %v", forbidden, err)
}
}
})
}
}
func TestResolveBoundsIdentityDiagnosticsWithoutContent(t *testing.T) {
const recordCount = 30
value := dnd.NPCList{NPCs: make([]dnd.NPC, recordCount)}
for index := range value.NPCs {
value.NPCs[index] = dnd.NPC{
ID: "npc:sha256:0000000000000000000000000000000000000000000000000000000000000000",
Name: fmt.Sprintf("PRIVATE NPC %d", index),
Aliases: []string{"PRIVATE SHARED ALIAS"},
Description: "PRIVATE DESCRIPTION",
Relationships: []dnd.NPCRelationship{},
SourceRefs: []source.SourceRef{{SourceID: "private-source", StartUnitID: 1, EndUnitID: 1}},
} }
} }
issues := identity.ValidateList(value) if registry.PromptInput().Digest != registry.ProjectionDigest() || registry.Digest() == registry.ProjectionDigest() {
if len(issues) <= diagnostics.MaxIssues { t.Fatalf("full/projection digests = %q/%q", registry.Digest(), registry.ProjectionDigest())
t.Fatalf("identity issues = %d, want more than display limit", len(issues))
}
_, err := Resolve(registryReference(marshalRegistry(t, value), "file:///private-registry.json"))
if err == nil {
t.Fatal("Resolve() error = nil, want bounded identity rejection")
}
message := err.Error()
if !utf8.ValidString(message) || len([]byte(message)) > diagnostics.MaxMessageBytes {
t.Fatalf("identity error has invalid encoding or size: bytes=%d message=%q", len([]byte(message)), message)
}
wantOmitted := fmt.Sprintf("%d additional issue(s) omitted", len(issues)-diagnostics.MaxIssues)
if !strings.Contains(message, wantOmitted) {
t.Fatalf("identity error = %q, want %q", message, wantOmitted)
}
for _, forbidden := range []string{"PRIVATE NPC", "PRIVATE SHARED ALIAS", "PRIVATE DESCRIPTION", "private-source", "private-registry.json"} {
if strings.Contains(message, forbidden) {
t.Fatalf("identity error leaked %q: %s", forbidden, message)
}
} }
} }
func TestRegistryAccessorsAndLookupAreDefensive(t *testing.T) { func TestNameProjectionDigestTracksOnlyNamesAndOrder(t *testing.T) {
resolved, err := Resolve(registryReference(encodeRegistry(t, validRegistryList()), "file:///npc-registry.json")) base := registryFixture()
evidenceChanged := registryFixture()
evidenceChanged.NPCs[0].ID = "different application id"
evidenceChanged.NPCs[0].SourceRefs = []source.SourceRef{{SourceID: "other", StartUnitID: 40, EndUnitID: 41}}
baseBytes, err := nameProjection(base)
if err != nil { if err != nil {
t.Fatalf("Resolve() error = %v, want nil", err) t.Fatal(err)
}
changedBytes, err := nameProjection(evidenceChanged)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(baseBytes, changedBytes) || semanticDigest(baseBytes) != semanticDigest(changedBytes) {
t.Fatalf("equivalent name projections differ: %s / %s", baseBytes, changedBytes)
} }
npcs := resolved.NPCs() nameChanged := registryFixture()
nameChanged.NPCs[0].Name = "The Greencloak"
nameBytes, _ := nameProjection(nameChanged)
orderChanged := registryFixture()
orderChanged.NPCs[0], orderChanged.NPCs[1] = orderChanged.NPCs[1], orderChanged.NPCs[0]
orderBytes, _ := nameProjection(orderChanged)
if bytes.Equal(baseBytes, nameBytes) || bytes.Equal(baseBytes, orderBytes) {
t.Fatalf("name/order changes did not change projection: %s %s %s", baseBytes, nameBytes, orderBytes)
}
}
func TestRegistryLookupAndAccessorsAreImmutable(t *testing.T) {
registry := resolveList(t, registryFixture())
if npc, ok := registry.Lookup(" mIRA\u2003thorn "); !ok || npc.Name != "Mira Thorn" {
t.Fatalf("Lookup() = %#v, %t", npc, ok)
}
if _, ok := registry.Lookup("The Greencloak"); ok {
t.Fatal("Lookup() accepted a non-canonical name")
}
npcs := registry.NPCs()
npcs[0].Name = "changed" npcs[0].Name = "changed"
npcs[0].Aliases[0] = "changed alias" npcs[0].SourceRefs[0].SourceID = "changed"
npcs[0].Relationships[0].Target = "changed target" content := registry.CanonicalBytes()
npcs[0].SourceRefs[0].SourceID = "changed source" content[0] = '['
if got, ok := resolved.Lookup("The Greencloak"); !ok || got.Name != "Mira Thorn" { input := registry.PromptInput()
t.Fatalf("Lookup() after NPC mutation = %#v, %v, want original NPC", got, ok) input.Content[0] = '['
if next := registry.NPCs()[0]; next.Name != "Mira Thorn" || next.SourceRefs[0].SourceID != "session-alpha" {
t.Fatalf("registry mutated through accessor: %#v", next)
} }
if registry.CanonicalBytes()[0] != '{' || registry.PromptInput().Content[0] != '{' {
wantCanonical := string(resolved.CanonicalBytes()) t.Fatal("registry bytes mutated through accessor")
content := resolved.CanonicalBytes()
content[0] = 'X'
input := resolved.PromptInput()
input.Content[0] = 'X'
if string(resolved.CanonicalBytes()) != wantCanonical || string(resolved.PromptInput().Content) != wantCanonical {
t.Fatal("registry content accessors share mutable state")
}
if got, ok := resolved.Lookup(" MIRA\u00a0THORN "); !ok || got.Name != "Mira Thorn" {
t.Fatalf("Lookup() canonical identity = %#v, %v, want Mira Thorn", got, ok)
}
if _, ok := resolved.Lookup("unknown NPC"); ok {
t.Fatal("Lookup() found unknown NPC")
} }
} }
func TestResolverUsesSeededFallbackAndCachesGeneratedCanonicalRegistry(t *testing.T) { func TestResolveRejectsRichOrInvalidRegistryJSON(t *testing.T) {
staticContent := encodeRegistry(t, validRegistryList()) rich := []byte(`{"npcs":[{"id":"npc:sha256:99a16589618a04f535a7d21fdcc71a0b1c05d22f752cd492065b1086d97bc3d7","name":"Mira Thorn","aliases":[],"source_refs":[{"source_id":"session-alpha","start_unit_id":1,"end_unit_id":1}]}]}`)
resolver, err := NewResolver(registryReference(staticContent, "file:///static.json")) for _, item := range []contracts.ReferenceItem{
if err != nil { {MediaType: "application/json", Content: rich},
t.Fatalf("NewResolver() error = %v", err) {MediaType: "text/plain", Content: []byte(`{"npcs":[]}`)},
} } {
if got, err := resolver.Resolve(contracts.ReferenceSet{}); err != nil || got != resolver.Seeded() { _, err := Resolve(referenceSet(item))
t.Fatalf("Resolve(absent) = %p, %v, want seeded %p", got, err, resolver.Seeded()) if err == nil {
} t.Fatalf("Resolve(%s) error = nil", item.Content)
generated := registryReference(append([]byte("\n"), staticContent...), "file:///generated.json")
first, err := resolver.Resolve(generated)
if err != nil {
t.Fatalf("Resolve(generated) error = %v", err)
}
second, err := resolver.Resolve(generated)
if err != nil {
t.Fatalf("Resolve(generated second) error = %v", err)
}
if first != resolver.Seeded() || second != first {
t.Fatalf("resolved registries = %p, %p, seeded %p; want seeded reuse", first, second, resolver.Seeded())
}
changed := validRegistryList()
changed.NPCs[0].Description = "A changed generated description."
changedContent := encodeRegistry(t, changed)
changedReferences := registryReference(changedContent, "file:///changed.json")
resolved, err := resolver.Resolve(changedReferences)
if err != nil {
t.Fatalf("Resolve(changed) error = %v", err)
}
changedReferences.Slots[ReferenceSlot].Items[0].Content[0] = 'X'
if resolved == first || string(resolved.CanonicalBytes()) != string(changedContent) {
t.Fatalf("changed registry = %p/%s, want independent canonical cache entry", resolved, resolved.CanonicalBytes())
}
}
func TestResolverSharesOneCachedRegistryAcrossConcurrentOperations(t *testing.T) {
content := encodeRegistry(t, validRegistryList())
resolver, err := NewResolver(contracts.ReferenceSet{})
if err != nil {
t.Fatalf("NewResolver() error = %v", err)
}
references := registryReference(content, "file:///generated.json")
const callers = 32
results := make(chan *Registry, callers)
errors := make(chan error, callers)
var wait sync.WaitGroup
for index := 0; index < callers; index++ {
wait.Add(1)
go func() {
defer wait.Done()
resolved, resolveErr := resolver.Resolve(references)
if resolveErr != nil {
errors <- resolveErr
return
}
results <- resolved
}()
}
wait.Wait()
close(results)
close(errors)
for err := range errors {
t.Fatalf("concurrent Resolve() error = %v", err)
}
var first *Registry
for resolved := range results {
if first == nil {
first = resolved
} else if resolved != first {
t.Fatalf("concurrent resolved registry %p differs from cached %p", resolved, first)
} }
} }
} }
func TestNewResolverAllowsGeneratedDeclarationButRejectsMalformedStaticItem(t *testing.T) { func TestResolverReusesEquivalentCanonicalRegistries(t *testing.T) {
placeholder := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ set := listReferenceSet(t, registryFixture())
ReferenceSlot: {Slot: contracts.ReferenceSlot{Name: ReferenceSlot, AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind}}}, resolver, err := NewResolver(set)
}}
if _, err := NewResolver(placeholder); err != nil {
t.Fatalf("NewResolver(generated declaration) error = %v, want nil", err)
}
malformed := registryReference([]byte(`{"npcs":[`), "file:///runtime.json")
if _, err := NewResolver(malformed); err == nil || !strings.Contains(err.Error(), "invalid approved NPC JSON") {
t.Fatalf("NewResolver(malformed) error = %v, want bounded decode failure", err)
}
}
func validRegistryList() dnd.NPCList {
return dnd.NPCList{NPCs: []dnd.NPC{{
ID: identity.DeriveID("Mira Thorn"),
Name: "Mira Thorn",
Aliases: []string{"The Greencloak"},
Description: "A guarded ranger who watches the northern road.",
Relationships: []dnd.NPCRelationship{{
Target: "Captain Vale", Relationship: "reports to",
}},
SourceRefs: []source.SourceRef{{SourceID: "npc-session", StartUnitID: 41, EndUnitID: 43}},
}}}
}
func encodeRegistry(t *testing.T, value dnd.NPCList) []byte {
t.Helper()
content, err := npccodec.New().Encode(value)
if err != nil { if err != nil {
t.Fatalf("encode NPC registry: %v", err) t.Fatal(err)
} }
return content resolved, err := resolver.Resolve(set)
} if err != nil || resolved != resolver.Seeded() {
t.Fatalf("Resolve() = %p, %v; seeded %p", resolved, err, resolver.Seeded())
func marshalRegistry(t *testing.T, value dnd.NPCList) []byte {
t.Helper()
content, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal NPC registry: %v", err)
} }
return content
} }
func registryReference(content []byte, origin string) contracts.ReferenceSet { func registryFixture() dnd.NPCList {
references := registryReferenceWithMedia(content, "application/json; charset=utf-8") return dnd.NPCList{NPCs: []dnd.NPC{
item := references.Slots[ReferenceSlot].Items[0] {ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}}},
item.Origin.URI = origin {ID: identity.DeriveID("Captain Vale"), Name: "Captain Vale", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}}},
slot := references.Slots[ReferenceSlot]
slot.Items[0] = item
references.Slots[ReferenceSlot] = slot
return references
}
func registryReferenceWithMedia(content []byte, mediaType string) contracts.ReferenceSet {
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
ReferenceSlot: {
Slot: contracts.ReferenceSlot{Name: ReferenceSlot, AcceptedMediaTypes: []string{npccodec.MediaType}, MaxBytes: MaxBytes},
Items: []contracts.ReferenceItem{{
SlotName: ReferenceSlot,
MediaType: mediaType,
Content: append([]byte(nil), content...),
Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///npc-registry.json"},
}},
},
}} }}
} }
func resolveList(t *testing.T, list dnd.NPCList) *Registry {
t.Helper()
registry, err := Resolve(listReferenceSet(t, list))
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
return registry
}
func listReferenceSet(t *testing.T, list dnd.NPCList) contracts.ReferenceSet {
t.Helper()
content, err := npccodec.New().Encode(list)
if err != nil {
t.Fatalf("Encode() error = %v", err)
}
return referenceSet(contracts.ReferenceItem{SlotName: ReferenceSlot, MediaType: npccodec.MediaType, Content: content})
}
func referenceSet(item contracts.ReferenceItem) contracts.ReferenceSet {
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ReferenceSlot: {Items: []contracts.ReferenceItem{item}}}}
}
func TestProjectionIsStableForEquivalentNormalizedRegistries(t *testing.T) {
first := resolveList(t, registryFixture())
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() {
t.Fatalf("projection/full identity mismatch: %#v %#v", first, second)
}
}

View File

@@ -29,17 +29,9 @@ type NPCList struct {
} }
type NPC struct { type NPC struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Aliases []string `json:"aliases"` SourceRefs []source.SourceRef `json:"source_refs"`
Description string `json:"description"`
Relationships []NPCRelationship `json:"relationships"`
SourceRefs []source.SourceRef `json:"source_refs"`
}
type NPCRelationship struct {
Target string `json:"target"`
Relationship string `json:"relationship"`
} }
type CombatTurnList struct { type CombatTurnList struct {

View File

@@ -48,9 +48,6 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
issues := make([]string, len(identityIssues)) issues := make([]string, len(identityIssues))
for index, issue := range identityIssues { for index, issue := range identityIssues {
location := fmt.Sprintf("npcs[%d]", issue.RecordIndex) location := fmt.Sprintf("npcs[%d]", issue.RecordIndex)
if issue.AliasIndex >= 0 {
location += fmt.Sprintf(".aliases[%d]", issue.AliasIndex)
}
issues[index] = fmt.Sprintf("%s %s: %s", location, issue.Code, diagnostics.Quote(issue.Value)) issues[index] = fmt.Sprintf("%s %s: %s", location, issue.Code, diagnostics.Quote(issue.Value))
} }
return contracts.ValidationResult{ return contracts.ValidationResult{

View File

@@ -43,7 +43,7 @@ func TestValidatorContractAndRegistration(t *testing.T) {
func TestValidatorDefersShapeAndRejectsIdentityIssues(t *testing.T) { func TestValidatorDefersShapeAndRejectsIdentityIssues(t *testing.T) {
validator := New(Options{}) validator := New(Options{})
shapeInvalid := dnd.NPCList{NPCs: []dnd.NPC{{Name: "missing description"}}} shapeInvalid := dnd.NPCList{NPCs: []dnd.NPC{{Name: "missing evidence"}}}
result, err := validator.Validate(context.Background(), validationRequest(shapeInvalid)) result, err := validator.Validate(context.Background(), validationRequest(shapeInvalid))
if err != nil || !result.Approved { if err != nil || !result.Approved {
t.Fatalf("shape-invalid result = %#v, error = %v, want deferred approval", result, err) t.Fatalf("shape-invalid result = %#v, error = %v, want deferred approval", result, err)
@@ -51,13 +51,13 @@ func TestValidatorDefersShapeAndRejectsIdentityIssues(t *testing.T) {
value := validNPCList(2) value := validNPCList(2)
value.NPCs[0].ID = "not-an-id" value.NPCs[0].ID = "not-an-id"
value.NPCs[1].Aliases = []string{"Shared Alias"} value.NPCs[1].Name = value.NPCs[0].Name
value.NPCs[0].Aliases = []string{"Shared Alias"} value.NPCs[1].ID = value.NPCs[0].ID
result, err = validator.Validate(context.Background(), validationRequest(value)) result, err = validator.Validate(context.Background(), validationRequest(value))
if err != nil || result.Approved || result.ReasonCode != ReasonCode { if err != nil || result.Approved || result.ReasonCode != ReasonCode {
t.Fatalf("identity result = %#v, error = %v, want rejection", result, err) t.Fatalf("identity result = %#v, error = %v, want rejection", result, err)
} }
for _, want := range []string{"invalid_id", "alias_owned_by_multiple_records", "npcs[0]", "npcs[1]"} { for _, want := range []string{"invalid_id", "duplicate_canonical_identity", "duplicate_id", "npcs[0]", "npcs[1]"} {
if !strings.Contains(result.Message, want) { if !strings.Contains(result.Message, want) {
t.Fatalf("identity message %q missing %q", result.Message, want) t.Fatalf("identity message %q missing %q", result.Message, want)
} }
@@ -90,14 +90,7 @@ func validNPCList(count int) dnd.NPCList {
value := dnd.NPCList{NPCs: make([]dnd.NPC, count)} value := dnd.NPCList{NPCs: make([]dnd.NPC, count)}
for index := range value.NPCs { for index := range value.NPCs {
name := fmt.Sprintf("NPC %d", index) name := fmt.Sprintf("NPC %d", index)
value.NPCs[index] = dnd.NPC{ value.NPCs[index] = dnd.NPC{ID: "npc:sha256:0000000000000000000000000000000000000000000000000000000000000000", Name: name, SourceRefs: []source.SourceRef{sourceRefForTest()}}
ID: "npc:sha256:0000000000000000000000000000000000000000000000000000000000000000",
Name: name,
Aliases: []string{},
Description: "description",
Relationships: []dnd.NPCRelationship{},
SourceRefs: []source.SourceRef{sourceRefForTest()},
}
} }
return value return value
} }

View File

@@ -61,31 +61,6 @@ func issuesFor(value dnd.NPCList) []string {
if strings.TrimSpace(npc.Name) == "" { if strings.TrimSpace(npc.Name) == "" {
issues = append(issues, prefix+".name must not be empty") issues = append(issues, prefix+".name must not be empty")
} }
if npc.Aliases == nil {
issues = append(issues, prefix+".aliases must be present")
} else {
for aliasIndex, alias := range npc.Aliases {
if strings.TrimSpace(alias) == "" {
issues = append(issues, fmt.Sprintf("%s.aliases[%d] must not be empty: %s", prefix, aliasIndex, diagnostics.Quote(alias)))
}
}
}
if strings.TrimSpace(npc.Description) == "" {
issues = append(issues, prefix+".description must not be empty")
}
if npc.Relationships == nil {
issues = append(issues, prefix+".relationships must be present")
} else {
for relationshipIndex, relationship := range npc.Relationships {
relationshipPrefix := fmt.Sprintf("%s.relationships[%d]", prefix, relationshipIndex)
if strings.TrimSpace(relationship.Target) == "" {
issues = append(issues, relationshipPrefix+".target must not be empty: "+diagnostics.Quote(relationship.Target))
}
if strings.TrimSpace(relationship.Relationship) == "" {
issues = append(issues, relationshipPrefix+".relationship must not be empty: "+diagnostics.Quote(relationship.Relationship))
}
}
}
if len(npc.SourceRefs) == 0 { if len(npc.SourceRefs) == 0 {
issues = append(issues, prefix+".source_refs must not be empty") issues = append(issues, prefix+".source_refs must not be empty")
} }

View File

@@ -21,9 +21,9 @@ func TestValidatorApprovesWellFormedNPCPayload(t *testing.T) {
func TestValidatorRejectsRequiredShapeValues(t *testing.T) { func TestValidatorRejectsRequiredShapeValues(t *testing.T) {
value := validNPCList() value := validNPCList()
value.NPCs[0].Aliases = nil value.NPCs[0].Name = ""
result, err := New(Options{}).Validate(context.Background(), requestWithValue(value)) result, err := New(Options{}).Validate(context.Background(), requestWithValue(value))
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "aliases must be present") { if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "name must not be empty") {
t.Fatalf("Validate() = %#v, %v; want bounded shape rejection", result, err) t.Fatalf("Validate() = %#v, %v; want bounded shape rejection", result, err)
} }
@@ -38,13 +38,13 @@ func TestValidatorBoundsDiagnosticsAndQuotesUnicode(t *testing.T) {
value := dnd.NPCList{NPCs: make([]dnd.NPC, 24)} value := dnd.NPCList{NPCs: make([]dnd.NPC, 24)}
long := strings.Repeat("火", 220) + "\n\t" long := strings.Repeat("火", 220) + "\n\t"
for index := range value.NPCs { for index := range value.NPCs {
value.NPCs[index] = dnd.NPC{ID: "candidate", Name: long, Aliases: []string{"\n\t"}, Description: "", Relationships: []dnd.NPCRelationship{{Target: " ", Relationship: " "}}, SourceRefs: []source.SourceRef{}} value.NPCs[index] = dnd.NPC{ID: "", Name: long, SourceRefs: []source.SourceRef{}}
} }
result, err := New(Options{}).Validate(context.Background(), requestWithValue(value)) result, err := New(Options{}).Validate(context.Background(), requestWithValue(value))
if err != nil || result.Approved || len([]byte(result.Message)) > diagnosticsMaxMessageBytes || !utf8.ValidString(result.Message) { if err != nil || result.Approved || len([]byte(result.Message)) > diagnosticsMaxMessageBytes || !utf8.ValidString(result.Message) {
t.Fatalf("Validate() = %#v, %v; want bounded valid UTF-8 rejection", result, err) t.Fatalf("Validate() = %#v, %v; want bounded valid UTF-8 rejection", result, err)
} }
if strings.Count(result.Message, "npcs[") > diagnosticsMaxIssues || !strings.Contains(result.Message, "additional issue(s) omitted") || !strings.Contains(result.Message, `\n\t`) { if strings.Count(result.Message, "npcs[") > diagnosticsMaxIssues || !strings.Contains(result.Message, "additional issue(s) omitted") {
t.Fatalf("message = %q, want bounded quoted diagnostics", result.Message) t.Fatalf("message = %q, want bounded quoted diagnostics", result.Message)
} }
} }
@@ -69,7 +69,7 @@ func TestValidatorDoesNotMutateValue(t *testing.T) {
value := validNPCList() value := validNPCList()
before := value before := value
_, err := New(Options{}).Validate(context.Background(), requestWithValue(value)) _, err := New(Options{}).Validate(context.Background(), requestWithValue(value))
if err != nil || value.NPCs[0].Aliases[0] != before.NPCs[0].Aliases[0] { if err != nil || value.NPCs[0].Name != before.NPCs[0].Name {
t.Fatalf("Validate() mutated value: %#v", value) t.Fatalf("Validate() mutated value: %#v", value)
} }
} }
@@ -80,9 +80,8 @@ func requestWithValue(value dnd.NPCList) contracts.TypedValidationRequest[dnd.NP
func validNPCList() dnd.NPCList { func validNPCList() dnd.NPCList {
return dnd.NPCList{NPCs: []dnd.NPC{{ return dnd.NPCList{NPCs: []dnd.NPC{{
ID: "candidate", Name: "Mira Thorn", Aliases: []string{"The Greencloak"}, Description: "A guarded ranger.", ID: "candidate", Name: "Mira Thorn",
Relationships: []dnd.NPCRelationship{{Target: "Captain Vale", Relationship: "reports to"}}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}} }}}
} }

View File

@@ -84,5 +84,5 @@ func validDocument() *source.SourceDocument {
} }
func validNPCList() dnd.NPCList { func validNPCList() dnd.NPCList {
return dnd.NPCList{NPCs: []dnd.NPC{{ID: "candidate", Name: "Mira Thorn", Aliases: []string{"The Greencloak"}, Description: "A ranger.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 2}}}}} return dnd.NPCList{NPCs: []dnd.NPC{{ID: "candidate", Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 2}}}}}
} }

View File

@@ -60,15 +60,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
} }
func npcAppearsInCitedText(citedText string, npc dnd.NPC) bool { func npcAppearsInCitedText(citedText string, npc dnd.NPC) bool {
if shared.ContainsTokenSequence(citedText, npc.Name) { return shared.ContainsTokenSequence(citedText, npc.Name)
return true
}
for _, alias := range npc.Aliases {
if shared.ContainsTokenSequence(citedText, alias) {
return true
}
}
return false
} }
func Spec() pipeline.ValidatorSpec { func Spec() pipeline.ValidatorSpec {

View File

@@ -12,14 +12,14 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
) )
func TestValidatorMatchesCanonicalNamesAndAliasesWithUnicodeVariants(t *testing.T) { func TestValidatorMatchesCanonicalNamesWithUnicodeVariants(t *testing.T) {
value := dnd.NPCList{NPCs: []dnd.NPC{ value := dnd.NPCList{NPCs: []dnd.NPC{
{ID: "one", Name: "O'Rin Thorn", Aliases: []string{}, Description: "A ranger.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{ {ID: "one", Name: "O'Rin Thorn", SourceRefs: []source.SourceRef{
{SourceID: "session", StartUnitID: 2, EndUnitID: 2}, {SourceID: "session", StartUnitID: 2, EndUnitID: 2},
{SourceID: "session", StartUnitID: 1, EndUnitID: 2}, {SourceID: "session", StartUnitID: 1, EndUnitID: 2},
{SourceID: "session", StartUnitID: 1, EndUnitID: 2}, {SourceID: "session", StartUnitID: 1, EndUnitID: 2},
}}, }},
{ID: "two", Name: "Missing Name", Aliases: []string{"The Greencloak"}, Description: "A guard.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}}, {ID: "two", Name: "The Greencloak", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}},
}} }}
doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{ doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{
{ID: 1, Kind: "message", Text: " orin\u2003thorn appears."}, {ID: 1, Kind: "message", Text: " orin\u2003thorn appears."},
@@ -27,13 +27,13 @@ func TestValidatorMatchesCanonicalNamesAndAliasesWithUnicodeVariants(t *testing.
}} }}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: doc, Value: value}) result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: doc, Value: value})
if err != nil || !result.Approved || len(result.Warnings) != 0 { if err != nil || !result.Approved || len(result.Warnings) != 0 {
t.Fatalf("Validate() = %#v, %v; want alias/canonical relatedness approval", result, err) t.Fatalf("Validate() = %#v, %v; want canonical-name relatedness approval", result, err)
} }
} }
func TestValidatorWarnsAtMostOncePerNPCForUnrelatedCitations(t *testing.T) { func TestValidatorWarnsAtMostOncePerNPCForUnrelatedCitations(t *testing.T) {
value := dnd.NPCList{NPCs: []dnd.NPC{ value := dnd.NPCList{NPCs: []dnd.NPC{
{ID: "one", Name: "Missing\nName", Aliases: []string{"Also Missing"}, Description: "A guard.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}, {SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}, {ID: "one", Name: "Missing\nName", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}, {SourceID: "session", StartUnitID: 1, EndUnitID: 1}}},
}} }}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: relatednessDocument(), Value: value}) result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: relatednessDocument(), Value: value})
if err != nil || !result.Approved || len(result.Warnings) != 1 { if err != nil || !result.Approved || len(result.Warnings) != 1 {
@@ -47,7 +47,7 @@ func TestValidatorWarnsAtMostOncePerNPCForUnrelatedCitations(t *testing.T) {
func TestValidatorDoesNotMatchShortNameSubstring(t *testing.T) { func TestValidatorDoesNotMatchShortNameSubstring(t *testing.T) {
value := dnd.NPCList{NPCs: []dnd.NPC{{ value := dnd.NPCList{NPCs: []dnd.NPC{{
ID: "one", Name: "Art", Aliases: []string{}, Description: "A guard.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}, ID: "one", Name: "Art", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}} }}}
doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{{ID: 1, Kind: "message", Text: "A cart rolls past."}}} doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{{ID: 1, Kind: "message", Text: "A cart rolls past."}}}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: doc, Value: value}) result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: doc, Value: value})
@@ -62,7 +62,7 @@ func TestValidatorDefersMalformedShapeAndInvalidRangesDoNotPanic(t *testing.T) {
if err != nil || !result.Approved || len(result.Warnings) != 0 { if err != nil || !result.Approved || len(result.Warnings) != 0 {
t.Fatalf("shape deferral = %#v, %v; want approval without warning", result, err) t.Fatalf("shape deferral = %#v, %v; want approval without warning", result, err)
} }
invalidRange := dnd.NPCList{NPCs: []dnd.NPC{{ID: "one", Name: "Mira Thorn", Aliases: []string{}, Description: "A ranger.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}} invalidRange := dnd.NPCList{NPCs: []dnd.NPC{{ID: "one", Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}}
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: relatednessDocument(), Value: invalidRange}) result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: relatednessDocument(), Value: invalidRange})
if err != nil || !result.Approved || len(result.Warnings) != 0 { if err != nil || !result.Approved || len(result.Warnings) != 0 {
t.Fatalf("invalid-range relatedness = %#v, %v; want approval without warning", result, err) t.Fatalf("invalid-range relatedness = %#v, %v; want approval without warning", result, err)
@@ -70,7 +70,7 @@ func TestValidatorDefersMalformedShapeAndInvalidRangesDoNotPanic(t *testing.T) {
} }
func TestValidatorUsesOnlyTranscriptEvidenceAndRegistersPolicy(t *testing.T) { func TestValidatorUsesOnlyTranscriptEvidenceAndRegistersPolicy(t *testing.T) {
value := dnd.NPCList{NPCs: []dnd.NPC{{ID: "one", Name: "Opaque NPC", Aliases: []string{}, Description: "A guard.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}} value := dnd.NPCList{NPCs: []dnd.NPC{{ID: "one", Name: "Opaque NPC", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}}
references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{"glossary": {Items: []contracts.ReferenceItem{{Content: []byte("Opaque NPC")}}}}} references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{"glossary": {Items: []contracts.ReferenceItem{{Content: []byte("Opaque NPC")}}}}}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: relatednessDocument(), References: references, Value: value}) result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: relatednessDocument(), References: references, Value: value})
if err != nil || len(result.Warnings) != 1 { if err != nil || len(result.Warnings) != 1 {

View File

@@ -49,9 +49,9 @@ func TestProductionCombatPipelineRetriesMergesNormalizesAndWritesJSON(t *testing
client := &fakeCombatLLMClient{responses: []string{ client := &fakeCombatLLMClient{responses: []string{
combatTestInvalidEnumResponse("unsupported", "attack"), combatTestInvalidEnumResponse("unsupported", "attack"),
combatTestTurnResponse("The Greencloak", "turn", "watches", "The Greencloak", 1, 1), combatTestTurnResponse("mira thorn", "turn", "watches", "mira thorn", 1, 1),
combatTestTurnResponse("Mira Thorn", "reaction", "asks", "Hooded Guard", 2, 2), combatTestTurnResponse("Mira Thorn", "reaction", "asks", "Hooded Guard", 2, 2),
combatTestTurnResponse("Hooded Guard", "turn", "attacks", "The Greencloak", 3, 3), combatTestTurnResponse("Hooded Guard", "turn", "attacks", "mira thorn", 3, 3),
}} }}
prepared, err := pipeline.Prepare(materialized, registries, pipeline.ModuleDependencies{LLM: client}) prepared, err := pipeline.Prepare(materialized, registries, pipeline.ModuleDependencies{LLM: client})
if err != nil { if err != nil {
@@ -250,8 +250,8 @@ type combatNPCPayload struct {
func combatTestNPCPayload(t *testing.T) combatNPCPayload { func combatTestNPCPayload(t *testing.T) combatNPCPayload {
t.Helper() t.Helper()
value := dnd.NPCList{NPCs: []dnd.NPC{ value := dnd.NPCList{NPCs: []dnd.NPC{
{ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", Aliases: []string{"The Greencloak", "Mira"}, Description: "A ranger.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "prior-npc-session", StartUnitID: 1, EndUnitID: 1}}}, {ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "prior-npc-session", StartUnitID: 1, EndUnitID: 1}}},
{ID: identity.DeriveID("Hooded Guard"), Name: "Hooded Guard", Aliases: []string{}, Description: "A sentry.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "prior-npc-session", StartUnitID: 3, EndUnitID: 3}}}, {ID: identity.DeriveID("Hooded Guard"), Name: "Hooded Guard", SourceRefs: []source.SourceRef{{SourceID: "prior-npc-session", StartUnitID: 3, EndUnitID: 3}}},
}} }}
content, err := npccodec.New().Encode(value) content, err := npccodec.New().Encode(value)
if err != nil { if err != nil {

View File

@@ -65,14 +65,16 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T
t.Fatalf("encode canonical NPC producer payload: %v", err) t.Fatalf("encode canonical NPC producer payload: %v", err)
} }
canonicalDigest := digestBytes(npcPayload) canonicalDigest := digestBytes(npcPayload)
projection := []byte(`{"npcs":[{"name":"Mira Thorn"},{"name":"Hooded Guard"}]}`)
projectionDigest := digestBytes(projection)
seenConsumers := map[string]bool{} seenConsumers := map[string]bool{}
for _, request := range client.requestsSnapshot() { for _, request := range client.requestsSnapshot() {
if request.PromptID != spells.PromptID && request.PromptID != combatextract.PromptID { if request.PromptID != spells.PromptID && request.PromptID != combatextract.PromptID {
continue continue
} }
input := request.Inputs["npcs"] input := request.Inputs["npcs"]
if input.MediaType != npccodec.MediaType || input.Digest != canonicalDigest || string(input.Content) != string(npcPayload) || input.OriginURI != "" { if input.MediaType != npccodec.MediaType || input.Digest != projectionDigest || string(input.Content) != string(projection) || input.OriginURI != "" {
t.Fatalf("%s NPC prompt input = %#v, want canonical generated registry without provenance", request.PromptID, input) t.Fatalf("%s NPC prompt input = %#v, want names-only generated registry", request.PromptID, input)
} }
seenConsumers[request.PromptID] = true seenConsumers[request.PromptID] = true
} }
@@ -107,7 +109,7 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T
switch serialized.LaneID { switch serialized.LaneID {
case "spells": case "spells":
spellValue := decodeRunnerSpellResponse(t, serialized.Artifact.Content) spellValue := decodeRunnerSpellResponse(t, serialized.Artifact.Content)
if len(spellValue.SpellCasts) != 1 || spellValue.SpellCasts[0].Caster != "The Greencloak" { if len(spellValue.SpellCasts) != 1 || spellValue.SpellCasts[0].Caster != "Mira Thorn" {
t.Fatalf("spell output = %#v, want one registry-grounded-context spell", spellValue) t.Fatalf("spell output = %#v, want one registry-grounded-context spell", spellValue)
} }
assertSpellEvidence(t, spellValue.SpellCasts[0].SourceRefs) assertSpellEvidence(t, spellValue.SpellCasts[0].SourceRefs)
@@ -174,25 +176,23 @@ func (client *groundedDNDLLMClient) CompleteStructured(ctx context.Context, requ
case npcs.PromptID: case npcs.PromptID:
payload = map[string]any{"npcs": []any{ payload = map[string]any{"npcs": []any{
map[string]any{ map[string]any{
"name": "Mira Thorn", "aliases": []string{"The Greencloak"}, "description": "A guarded ranger.", "name": "Mira Thorn", "source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}},
"relationships": []any{}, "source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}},
}, },
map[string]any{ map[string]any{
"name": "Hooded Guard", "aliases": []string{}, "description": "A sentry.", "name": "Hooded Guard", "source_refs": []any{map[string]int{"start_unit_id": 3, "end_unit_id": 3}},
"relationships": []any{}, "source_refs": []any{map[string]int{"start_unit_id": 3, "end_unit_id": 3}},
}, },
}} }}
case spells.PromptID: case spells.PromptID:
payload = map[string]any{"spell_casts": []any{map[string]any{ payload = map[string]any{"spell_casts": []any{map[string]any{
"caster": "The Greencloak", "spell": "Cure Wounds", "effect": "Restores an ally.", "caster": "Mira Thorn", "spell": "Cure Wounds", "effect": "Restores an ally.",
"narrative_description": "The Greencloak restores an ally.", "narrative_description": "Mira Thorn restores an ally.",
"source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}}, "source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}},
}}} }}}
case combatextract.PromptID: case combatextract.PromptID:
payload = map[string]any{"combat_turns": []any{map[string]any{ payload = map[string]any{"combat_turns": []any{map[string]any{
"actor": "The Greencloak", "turn_kind": "turn", "round": 1, "actor": "Mira Thorn", "turn_kind": "turn", "round": 1,
"actions": []any{map[string]any{"category": "attack", "declaration": "watches", "targets": []string{"Hooded Guard"}, "resolution": "observed"}}, "actions": []any{map[string]any{"category": "attack", "declaration": "watches", "targets": []string{"Hooded Guard"}, "resolution": "observed"}},
"summary": "The Greencloak watches the gate.", "summary": "Mira Thorn watches the gate.",
"source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}}, "source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}},
}}} }}}
default: default:

View File

@@ -5,7 +5,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"reflect"
"strings" "strings"
"testing" "testing"
@@ -40,30 +39,15 @@ func TestRunnerProcessesSeriatimInputWithProductionDNDNPCPipeline(t *testing.T)
client := &fakeNPCProductionLLMClient{response: npcProductionResponse{ client := &fakeNPCProductionLLMClient{response: npcProductionResponse{
NPCs: []npcProductionRecord{ NPCs: []npcProductionRecord{
{ {
Name: "Mira Thorn", Name: "Mira Thorn",
Aliases: []string{"The Greencloak"},
Description: "The first named NPC encountered.",
Relationships: []npcProductionRelationship{
{Target: "Hooded Guard", Relationship: "works with"},
},
SourceRefs: []npcProductionSourceRef{{StartUnitID: 1, EndUnitID: 1}}, SourceRefs: []npcProductionSourceRef{{StartUnitID: 1, EndUnitID: 1}},
}, },
{ {
Name: "The Greencloak", Name: " mira thorn ",
Aliases: []string{"Mira"},
Description: "A later description that must not replace the first.",
Relationships: []npcProductionRelationship{
{Target: "Hooded Guard", Relationship: "trusts"},
},
SourceRefs: []npcProductionSourceRef{{StartUnitID: 2, EndUnitID: 2}}, SourceRefs: []npcProductionSourceRef{{StartUnitID: 2, EndUnitID: 2}},
}, },
{ {
Name: "Hooded Guard", Name: "Hooded Guard",
Aliases: []string{},
Description: "An unnamed but distinguishable sentry.",
Relationships: []npcProductionRelationship{
{Target: "The Greencloak", Relationship: "reports to"},
},
SourceRefs: []npcProductionSourceRef{{StartUnitID: 3, EndUnitID: 3}}, SourceRefs: []npcProductionSourceRef{{StartUnitID: 3, EndUnitID: 3}},
}, },
}}, }},
@@ -87,15 +71,12 @@ func TestRunnerProcessesSeriatimInputWithProductionDNDNPCPipeline(t *testing.T)
t.Fatalf("NPC output = %#v, want repeated name consolidated and group/PC omitted", value.NPCs) t.Fatalf("NPC output = %#v, want repeated name consolidated and group/PC omitted", value.NPCs)
} }
first, second := value.NPCs[0], value.NPCs[1] first, second := value.NPCs[0], value.NPCs[1]
if first.Name != "Mira Thorn" || first.Description != "The first named NPC encountered." || !reflect.DeepEqual(first.Aliases, []string{"The Greencloak", "Mira"}) { if first.Name != "Mira Thorn" || len(first.SourceRefs) != 2 {
t.Fatalf("first NPC = %#v, want consolidated Mira identity", first) t.Fatalf("first NPC = %#v, want consolidated Mira identity", first)
} }
if first.ID != identity.DeriveID(first.Name) || second.Name != "Hooded Guard" || second.ID != identity.DeriveID(second.Name) { if first.ID != identity.DeriveID(first.Name) || second.Name != "Hooded Guard" || second.ID != identity.DeriveID(second.Name) {
t.Fatalf("NPC IDs = %q/%q, want derived IDs", first.ID, second.ID) t.Fatalf("NPC IDs = %q/%q, want derived IDs", first.ID, second.ID)
} }
if first.Relationships[0].Target != "Hooded Guard" || second.Relationships[0].Target != "Mira Thorn" {
t.Fatalf("relationship targets = %q/%q, want canonical target rewrite", first.Relationships[0].Target, second.Relationships[0].Target)
}
for _, npc := range value.NPCs { for _, npc := range value.NPCs {
for _, ref := range npc.SourceRefs { for _, ref := range npc.SourceRefs {
if ref.SourceID != doc.ID { if ref.SourceID != doc.ID {
@@ -103,8 +84,8 @@ func TestRunnerProcessesSeriatimInputWithProductionDNDNPCPipeline(t *testing.T)
} }
} }
} }
if !hasNPCWarning(output.Warnings, "duplicate_npc_collapsed") || !hasNPCWarning(output.Warnings, "relationship_target_canonicalized") { if !hasNPCWarning(output.Warnings, "duplicate_npc_collapsed") {
t.Fatalf("warnings = %#v, want consolidation and target warnings", output.Warnings) t.Fatalf("warnings = %#v, want name-only consolidation warning", output.Warnings)
} }
if output.Manifest.ValidationStatus != "approved" || len(output.Manifest.ArtifactLanes) != 1 { if output.Manifest.ValidationStatus != "approved" || len(output.Manifest.ArtifactLanes) != 1 {
t.Fatalf("manifest = %#v, want approved NPC lane", output.Manifest) t.Fatalf("manifest = %#v, want approved NPC lane", output.Manifest)
@@ -149,25 +130,25 @@ func TestProductionNPCPipelineRoutesSemanticCandidatesToDeterministicValidators(
}{ }{
{ {
name: "blank string", name: "blank string",
response: []byte(`{"npcs":[{"name":"","aliases":[],"description":"A participant.","relationships":[],"source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`), response: []byte(`{"npcs":[{"name":"","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`),
reasonCode: npcshape.ReasonCode, reasonCode: npcshape.ReasonCode,
validatorName: npcshape.Key, validatorName: npcshape.Key,
}, },
{ {
name: "empty evidence", name: "empty evidence",
response: []byte(`{"npcs":[{"name":"Mira Thorn","aliases":[],"description":"A participant.","relationships":[],"source_refs":[]}]}`), response: []byte(`{"npcs":[{"name":"Mira Thorn","source_refs":[]}]}`),
reasonCode: npcshape.ReasonCode, reasonCode: npcshape.ReasonCode,
validatorName: npcshape.Key, validatorName: npcshape.Key,
}, },
{ {
name: "nonpositive unit candidate", name: "nonpositive unit candidate",
response: []byte(`{"npcs":[{"name":"Mira Thorn","aliases":[],"description":"A participant.","relationships":[],"source_refs":[{"start_unit_id":0,"end_unit_id":1}]}]}`), response: []byte(`{"npcs":[{"name":"Mira Thorn","source_refs":[{"start_unit_id":0,"end_unit_id":1}]}]}`),
reasonCode: npcsourcerefs.ReasonCode, reasonCode: npcsourcerefs.ReasonCode,
validatorName: npcsourcerefs.Key, validatorName: npcsourcerefs.Key,
}, },
{ {
name: "unknown unit candidate", name: "unknown unit candidate",
response: []byte(`{"npcs":[{"name":"Mira Thorn","aliases":[],"description":"A participant.","relationships":[],"source_refs":[{"start_unit_id":99,"end_unit_id":99}]}]}`), response: []byte(`{"npcs":[{"name":"Mira Thorn","source_refs":[{"start_unit_id":99,"end_unit_id":99}]}]}`),
reasonCode: npcsourcerefs.ReasonCode, reasonCode: npcsourcerefs.ReasonCode,
validatorName: npcsourcerefs.Key, validatorName: npcsourcerefs.Key,
}, },
@@ -194,16 +175,8 @@ type npcProductionResponse struct {
} }
type npcProductionRecord struct { type npcProductionRecord struct {
Name string `json:"name"` Name string `json:"name"`
Aliases []string `json:"aliases"` SourceRefs []npcProductionSourceRef `json:"source_refs"`
Description string `json:"description"`
Relationships []npcProductionRelationship `json:"relationships"`
SourceRefs []npcProductionSourceRef `json:"source_refs"`
}
type npcProductionRelationship struct {
Target string `json:"target"`
Relationship string `json:"relationship"`
} }
type npcProductionSourceRef struct { type npcProductionSourceRef struct {