Add D&D NPC interaction artifact codec

This commit is contained in:
2026-07-23 13:18:38 +00:00
parent b2c076946b
commit 61016671ab
8 changed files with 1185 additions and 22 deletions

View File

@@ -0,0 +1,197 @@
# D&D NPC Interactions
Status: Accepted
## Purpose
The normalized NPC registry intentionally answers only who was identified in a
session. It does not answer whether an NPC was merely mentioned, participated
in dialogue, or fought alongside or against the party. Add a separate,
ordered NPC-interaction artifact for that occurrence-level information rather
than expanding the identity registry.
This feature preserves the minimal-extractor policy: the model identifies one
bounded kind of interaction and its supporting transcript evidence. It does
not summarize the interaction, infer relationships, or maintain NPC state.
## Desired End State
A D&D pipeline can run NPC extraction first and supply its accepted normalized
registry to a later `dnd/npc-interactions` lane. The later lane emits an ordered
list of evidenced interaction occurrences involving registry NPCs.
The production identities should be:
- extractor and normalizer key: `dnd/npc-interactions`;
- artifact kind: `dnd/npc-interaction-list`;
- durable schema ID: `notarius.dnd.npc_interactions`;
- durable schema name: `notarius_dnd_npc_interactions_v1`;
- durable schema version: `v1`; and
- media type: `application/json`.
The lane must use the existing D&D module organization, shared prompt assets,
typed artifact pipeline, codec boundary, registration pattern, and default
validator composition.
## Artifact Contract
The durable payload is an object containing an `interactions` array. The array
may be empty. Each interaction contains exactly:
- `name`: the canonical NPC name from the supplied registry;
- `kind`: one value from the bounded interaction vocabulary; and
- `source_refs`: one or more current-transcript ranges supporting both the NPC
identity and the classified interaction.
Every object rejects unknown fields. The model-facing response should contain
only the corresponding name, kind, and source-unit range candidates. Notarius
attaches the current source identity deterministically; the model must not
reproduce it.
Do not add a separate interaction ID in the durable contract. Stable ordering,
the canonical NPC name, the bounded kind, and exact evidence ranges are enough
to identify and audit an occurrence for the present use cases. Revisit durable
cross-artifact identity only with a concrete consumer requirement.
## Interaction Vocabulary
Use this closed vocabulary:
| Kind | Meaning |
| --- | --- |
| `mentioned` | The NPC is referred to, but is not established as present or communicating in the evidenced passage. |
| `noncombat_presence` | The NPC is present and relevant to the passage but does not meaningfully participate in dialogue or combat. |
| `dialogue` | The NPC speaks, responds, or is directly engaged in a meaningful non-combat exchange. |
| `combat_ally` | The NPC actively participates in combat on the party's side. |
| `combat_opponent` | The NPC actively participates in combat against the party. |
| `other` | The transcript clearly establishes a direct NPC occurrence that fits none of the preceding kinds. |
`other` is a residual category for positively evidenced activity, not an
escape hatch for uncertain classification. Omit a candidate when the
transcript does not support one category.
When activities overlap within one occurrence, apply this precedence:
1. active combat participation outranks dialogue, presence, and mention;
2. dialogue outranks non-combat presence and mention;
3. non-combat presence outranks mention; and
4. `other` applies only when none of the defined categories describes the
evidenced activity.
Combat alignment is not resolved by precedence. An NPC cannot be both a combat
ally and combat opponent in one occurrence; split the record when its alignment
meaningfully changes.
## Occurrence Boundaries And Ordering
An occurrence represents one NPC, one interaction kind, and one locally
coherent transcript passage. Combine repeated evidence only while it supports
the same uninterrupted activity. Create separate occurrences when:
- the interaction kind changes;
- combat alignment changes;
- a scene or meaningful absence separates repeated activity; or
- the NPC is first mentioned and later becomes present.
Extraction remains chunk-scoped, so an occurrence must not span accepted chunk
boundaries. Merge and normalization must not semantically combine occurrences
from different chunks.
Order the durable list by the earliest valid source position. Use canonical
name, interaction kind, and canonical source ranges as deterministic
tie-breakers. Remove only exact duplicate records; do not use model judgment to
collapse nearby occurrences.
## NPC Grounding And Evidence
The extractor declares the existing `npcs` reference slot and requires a bound,
accepted NPC registry. The normal same-run configuration binds the normalized
output of an earlier NPC lane through an ordered generated reference. Existing
framework support for a compatible external registry may remain available, but
must not weaken the artifact contract.
Present the registry's names-only projection to the model. An emitted name must
match one canonical registry name exactly after the registry's established
lookup rules are applied, and the durable artifact retains that canonical
display name. Do not copy NPC IDs or registry source references into an
interaction.
The registry establishes available identity, not occurrence. Every interaction
must cite current transcript ranges that independently support both the NPC and
the selected kind. Campaign references and generated artifacts may disambiguate
a name, but they never become interaction evidence.
If no registry NPC has an evidenced interaction, the correct artifact is an
empty list. A missing, rejected, or incompatible required registry handoff is a
pipeline dependency failure rather than a request to extract ungrounded names.
## Prompt And Model Boundary
Follow the established D&D prompt ordering and cache-boundary policy. Stable
shared instructions, lane instructions, campaign references, and the NPC
names-only projection precede the variable transcript. Factor wording shared
with the spell and combat lanes into the existing shared asset pattern rather
than creating nearly identical package-local messages.
The prompt must:
- define the closed vocabulary and precedence rules;
- ask for occurrences involving only supplied registry NPCs;
- distinguish mention from presence and dialogue;
- require current-transcript evidence for identity and kind;
- forbid summaries, relationship inference, sentiment, aliases, and invented
names; and
- permit an empty result.
Keep the private JSON Schema structural. Deterministic code owns canonical-name
resolution, enum enforcement at the durable boundary, source-range validation,
ordering, exact deduplication, and evidence invariants.
## Validation And Quality
Provide production validators and default chains at extraction and
normalization boundaries consistent with the existing D&D artifacts. The
append-only merge does not require a separate default validator chain.
Deterministic validation must reject:
- missing or extra fields;
- empty or unrecognized NPC names;
- names absent from the supplied registry;
- unknown interaction kinds;
- empty, malformed, out-of-source, or reversed evidence ranges; and
- records whose evidence comes from a reference rather than the current
transcript.
Any relatedness validator should remain warning-only unless evaluation
demonstrates a reliable deterministic rejection rule. Diagnostics must be
bounded and must not leak reference contents.
Evaluate the lane on a small human-reviewed transcript set that includes every
category, transitions between categories, multiple occurrences for one NPC,
mentions followed by appearances, alignment changes, repeated evidence across
chunks, and empty output. Review category agreement, evidence sufficiency,
ordering, duplicate behavior, and reliability on the smaller models the
application is intended to support. Treat model-output evaluation as a human
development aid, not a brittle deterministic test oracle.
## Documentation Outcomes
When implemented, document the durable artifact in `docs/integrations/`, add
the selectable module and validator contracts to configuration documentation,
update the current module and pipeline internals, and provide a maintained
ordered-pipeline example showing NPC extraction followed by interaction
extraction. Future behavior must remain in this roadmap until it exists.
## Non-Goals
This scope does not:
- add occurrence fields to the normalized NPC registry;
- summarize dialogue, combat, or NPC behavior;
- infer disposition, relationships, factions, motives, or persistent state;
- identify player characters or anonymous groups as NPCs;
- add scene participants or duplicate scene-description responsibilities;
- reconcile NPC aliases or perform LLM-assisted semantic deduplication;
- derive interaction records from registry evidence; or
- introduce a DAG, concurrent cross-lane reconciliation, or a new reference
mechanism.

View File

@@ -18,28 +18,10 @@ not as committed release dates.
### Extract NPC Interactions
- Add an ordered NPC-interaction artifact that records how an identifiable NPC
participates in the session without adding occurrence-level state to the
normalized NPC registry. Use `npc-interactions` as the working lane and
product name; the exact artifact kind may be finalized with its contract.
- Keep each record minimal: canonical NPC `name`, one bounded interaction
`kind`, and transcript `source_refs` supporting both the identity and
classification.
- Start with the mutually exclusive vocabulary `mentioned`,
`noncombat_presence`, `dialogue`, `combat_ally`, `combat_opponent`, and
`other`. Define narrow inclusion rules and category precedence before
implementation so overlapping activity does not produce arbitrary labels.
- Model interactions as ordered occurrences rather than one scalar NPC
category. The same NPC may therefore have separate records when the
transcript establishes distinct interactions, such as dialogue followed by
hostile combat.
- Run NPC identity extraction first and provide its accepted names-only
projection to the interaction extractor for grounding. Registry names may
disambiguate identity but never establish that an interaction occurred, and
registry source references must not be copied into interaction evidence.
- Evaluate category agreement, evidence sufficiency, duplicate behavior, and
smaller-model reliability on human-reviewed transcripts before expanding the
enum or adding additional fields.
Add a minimal, ordered `dnd/npc-interactions` artifact after NPC identity
extraction. The proposed contract, occurrence semantics, category policy,
grounding rules, evidence requirements, and non-goals are defined in
[D&D NPC Interactions](dnd-npc-interactions.md).
### Export Accepted Chunk Maps

View File

@@ -0,0 +1,570 @@
# D&D NPC Interactions Implementation Plan
Status: Ready for implementation
## Objective
Implement the accepted [D&D NPC Interactions](dnd-npc-interactions.md)
roadmap as a production D&D artifact lane. The finished lane must extract an
ordered list of minimal, evidence-grounded NPC interaction occurrences from
each accepted transcript chunk, ground every NPC against a required accepted
NPC registry, and preserve the existing fixed pipeline and generated-reference
architecture.
This plan is the implementation authority for sequencing and file-level work.
The feature roadmap remains authoritative for product intent, category
semantics, occurrence boundaries, evidence policy, and non-goals. Follow
[ADR-0009](../adr/0009-minimal-evidence-grounded-extraction-artifacts.md)
throughout: do not add descriptive, analytical, relationship, or state fields.
## Fixed Decisions
The implementation must use these identities:
| Concern | Identity |
| --- | --- |
| Extractor key | `dnd/npc-interactions` |
| Normalizer key | `dnd/npc-interactions` |
| Artifact kind | `dnd/npc-interaction-list` |
| Durable schema ID | `notarius.dnd.npc_interactions` |
| Durable schema name | `notarius_dnd_npc_interactions_v1` |
| Durable schema version | `v1` |
| Durable media type | `application/json` |
| Private prompt ID | `dnd.npc_interactions` |
| Private response schema ID | `notarius.dnd.npc_interactions.llm` |
| Reference slot | `npcs` |
Use a top-level `interactions` array. Each durable record has exactly `name`,
`kind`, and `source_refs`. Do not add an interaction ID. The closed kind
vocabulary is `mentioned`, `noncombat_presence`, `dialogue`, `combat_ally`,
`combat_opponent`, and `other`.
The `npcs` slot is required for both extraction and normalization. It accepts
only the existing `dnd/npc-list` JSON artifact within the existing NPC registry
size limit. A bound registry with an empty `npcs` array is valid and requires an
empty interaction artifact. An absent binding, malformed registry, failed
generated handoff, or unknown interaction name is not permission to perform
ungrounded extraction.
Use the existing `internal/modules/dnd/npcs/registry.Resolver` for construction-
time external references and operation-time generated references. The
extractor presents only `Registry.PromptInput()` to the model. Name lookup uses
the registry's established comparison-key policy before normalization; the
normalizer replaces a recognized variant with the exact canonical registry
display name. Registry source references and campaign references never become
interaction evidence.
An occurrence is one NPC, one kind, and one coherent passage within one
accepted chunk. The model must split a category or combat-alignment transition.
No occurrence spans chunks. Merge preserves chunk order. Normalization:
1. replaces recognized names with canonical registry display names;
2. sorts and exact-deduplicates each record's source references;
3. orders records by earliest valid source position, then canonical name,
interaction kind, and the canonical source-reference sequence;
4. removes records only when canonical name, kind, and the complete canonical
source-reference sequence are all identical; and
5. never semantically combines nearby, overlapping, or cross-chunk records.
The private model response remains structural and contains name, kind, and
integer start/end unit candidates. Mapping attaches the current source ID.
Invalid strings, kinds, and ranges must survive mapping far enough for the
deterministic validator that owns the rule to reject them.
For the final record comparator, use source-document unit position for the
earliest valid reference. Compare canonical names first by the existing NPC
identity comparison key and then by exact display string; compare kinds by
their string values; and compare canonical reference sequences
lexicographically by `source_id`, start-unit document position, and end-unit
document position. Records with valid evidence precede records without it;
normalized output validation rejects the latter before durable encoding.
No framework change, new identity subsystem, DAG behavior, LLM-assisted
deduplication, or scene dependency is part of this plan.
## Testing And Documentation Rules
Apply [Testing Policy](../policy/testing.md) at every stage:
- Protect artifact round trips, required fields, enum values, source
provenance, registry grounding, deterministic ordering, exact
deduplication, reference immutability, and generated handoff behavior.
- Use the real codec, registry, resolver, normalizer, and pipeline machinery
when they are fast and deterministic. Stub only the structured LLM boundary.
- Keep provider calls offline and deterministic.
- Test each policy at its narrowest stable owner. Do not repeat every codec,
validator, and normalizer case in an end-to-end test.
- Do not add a prompt-cache “change detector” based on exact message count,
shared-prefix length, token count, or prompt hash. Reuse the shared prompt
assets, test rendered behavior and manifest composition, and document the
ordering policy instead.
- Do not use broad golden snapshots for prompts or diagnostics. A maintained
durable JSON fixture is appropriate because the complete serialized artifact
is an intentional integration contract.
Until the final stage, keep unimplemented behavior only in roadmap
documentation. Update current-behavior documentation and examples in the same
stage that makes the production lane selectable.
## Stage 1 — Add The Typed Artifact And Durable Codec
### Goal
Establish the domain type and strict serialized boundary without changing the
production module catalog.
### Changes
1. Extend `internal/modules/dnd/types.go` with:
- `NPCInteractionListKind`;
- a string-backed `NPCInteractionKind`;
- exported constants for all six accepted values;
- `NPCInteractionList` with `Interactions []NPCInteraction`; and
- `NPCInteraction` with `Name string`, `Kind NPCInteractionKind`, and
`SourceRefs []source.SourceRef`.
2. Add `internal/modules/dnd/codec/npcinteractions` following the existing NPC
and combat-turn codec boundary:
- expose the fixed schema constants and `MediaType`;
- implement the typed artifact codec for `dnd.NPCInteractionList`;
- keep `EncodeCandidate` and `DecodeCandidate` strict about one JSON value
and unknown fields while preserving typed semantic candidates;
- make approved `Encode` and `Decode` enforce non-empty names, the closed
kind enum, at least one structurally valid source reference, and the
durable schema shape; and
- report only `interaction_count` in codec metadata.
3. Add
`internal/modules/dnd/codec/npcinteractions/assets/schemas/dnd_npc_interactions.v1.json`.
It must require the top-level array and all three record fields, reject
unknown fields at every object level, encode the six-value enum, require at
least one source reference, and use the established durable source-reference
shape.
4. Add a small maintained fixture under
`internal/modules/dnd/codec/npcinteractions/testdata/` containing at least
two ordered records with distinct kinds and source ranges.
### Tests
At the codec package boundary, cover:
- fixture decode/encode round trip and canonical compact JSON;
- exact kind, schema identity, version, media type, and Go type registration;
- empty-list support and nil-versus-present-empty behavior where the existing
codecs distinguish it;
- strict rejection of malformed JSON, trailing values, and unknown fields;
- approved-boundary rejection of each meaningful required-field, enum, and
source-reference violation;
- candidate-boundary preservation of semantic values for later validators; and
- defensive copies for schema bytes and metadata.
Do not duplicate source-document existence checks in the codec; those belong to
the source-reference validator.
### Completion Criteria
- The new types and codec compile and pass focused tests.
- The codec can be registered into an isolated artifact codec registry with
exact type `dnd.NPCInteractionList`.
- No production registrar or configuration catalog exposes the new kind yet.
## Stage 2 — Implement The Chunk-Scoped Extractor And Prompt
### Goal
Add an independently testable LLM-backed extractor that requires NPC grounding,
maps only minimal private output, and follows the established D&D prompt-cache
layout.
### Changes
1. Add `internal/modules/dnd/extract/npcinteractions` using the current D&D
extractor organization:
- `assets.go`;
- `canonicalize.go`;
- `extractor.go`;
- `model.go`;
- `schema.go`;
- `scriptorium_assets.go`;
- corresponding focused tests; and
- package-local embedded prompt and schema assets.
2. Give the extractor strict empty options, required capabilities `chunks` and
`source.transcript`, provided capability `dnd.npc_interactions`, and artifact
kind `dnd.NPCInteractionListKind`.
3. Build its reference slots from `shared.ReferenceSlots(...)`, then append the
`npcs` slot with:
- `Required: true`;
- accepted media type `application/json`;
- accepted artifact kind `dnd.NPCListKind`;
- the existing registry maximum byte count; and
- a description stating that the accepted registry is required identity
grounding, not evidence.
Return defensive, consistently sorted slot declarations from both
`ModuleSpec()` and the constructed extractor.
4. Construct an `npcregistry.Resolver` from the preparation-time reference set.
Resolve operation references inside `Extract`, require `Registry.Bound()`,
and fail before the LLM call if the required registry is absent or invalid.
Preserve the resolver's content-safe error behavior.
5. Use `shared.ChunkPromptMaterial` and `shared.PromptInputs`. Replace the
ordinary `npcs` input with the resolved names-only `Registry.PromptInput()`.
Do not place full registry bytes, NPC IDs, registry evidence, or provenance
in prompt inputs.
6. Add these assets:
- `assets/prompts/dnd.npc_interactions.yaml`;
- `assets/prompts/task.md`;
- `assets/prompts/instructions.md`; and
- `assets/schemas/dnd_npc_interactions_llm.v1.json`.
7. Use the existing shared prompt assets in this exact semantic order:
common system; common extraction evidence; common identity; common campaign
references; common NPC grounding; lane task; lane instructions; variable
transcript. Preserve the established cache-control placement used by the
spell and combat-turn manifests.
8. Revise the shared `common-dnd-npcs.md` wording once so it truthfully applies
to all three consumers: a normalized registry is always presented to the
prompt and may be empty. Keep its identity, context-only rule, and
prohibition on treating registry provenance as event evidence. Do not fork a
nearly identical interaction-specific grounding asset.
9. The lane prompt must state the category definitions, precedence, occurrence
splitting rules, registry-only name restriction, empty-output behavior, and
exclusions from the feature roadmap. Keep the response schema structural:
it requires the envelope and fields but leaves enum membership, non-empty
values, and source semantics to deterministic validators.
10. Map private records to `dnd.NPCInteractionList`, attach the current source
ID, sort and exact-deduplicate ranges within each candidate, and stable-sort
candidates with valid evidence by earliest source-document position.
Preserve invalid candidate fields rather than repairing or dropping them.
11. Expose prompt, response-schema, and mapping-policy identities through
manifest metadata and checkpoint fingerprints, following the current D&D
extractors. Include the NPC names-only projection digest in local checkpoint
identity. For a preparation-time external registry, expose only bounded
digest/count metadata; do not place names or source content in metadata.
Generated-reference provenance remains framework-owned.
### Tests
At stable package boundaries, cover:
- nil/cancelled/invalid extraction requests and provider failures;
- required-slot declaration and rejection of an unbound or malformed registry
before any LLM call;
- operation-time generated registry resolution by an extractor prepared
without generated bytes;
- exact names-only registry prompt input and separation from transcript
evidence;
- empty registry plus empty response;
- mapping of every category, current-source attachment, evidence
canonicalization, and source-position ordering;
- preservation of invalid names, kinds, and ranges for validators;
- strict private JSON shape and unknown-field rejection;
- prompt rendering with required inputs, category policy, shared assets, and
transcript-last ordering;
- prompt/schema registration and content-safe metadata/fingerprints; and
- defensive module specifications and strict rejection of unknown options.
Run the existing spell and combat-turn prompt/asset tests after changing the
shared NPC prompt fragment. Do not assert an exact shared-prefix length.
### Completion Criteria
- The extractor is constructible and testable through the typed extractor
contract.
- A valid request makes one scheduled structured-completion call and returns a
typed candidate with current-source provenance.
- Missing required NPC grounding cannot reach the model.
- The extractor remains unregistered in the production D&D family until later
stages provide the rest of the lane.
## Stage 3 — Add Deterministic Artifact Validators
### Goal
Give each semantic invariant one clear validation owner and make invalid model
output a rejection rather than a normalization repair or framework error.
### Changes
Add these typed validator packages, each with strict empty options, bounded
diagnostics, typed registration, and the fixed artifact kind:
1. `internal/modules/dnd/validate/npcinteractions/shape`
(`extract/dnd/npc-interactions/shape`):
- require a non-empty trimmed name without mutating it;
- require one of the six kinds; and
- require at least one source-reference candidate with structurally required
values.
2. `internal/modules/dnd/validate/npcinteractions/registry`
(`extract/dnd/npc-interactions/registry`):
- use `npcregistry.Resolver` at construction and operation time;
- require a bound registry;
- approve a name when `Registry.Lookup` recognizes it under the established
comparison policy;
- reject unknown names in stable record order; and
- never use registry evidence as transcript evidence.
3. `internal/modules/dnd/validate/npcinteractions/source_refs`
(`extract/dnd/npc-interactions/source_refs`):
- require the current source identity;
- require referenced units to exist; and
- require valid inclusive start/end order through the current document.
Follow the established D&D citation helpers and rejection aggregation
limits rather than introducing a generic framework dependency on the
interaction type.
4. `internal/modules/dnd/validate/npcinteractions/source_relatedness`
(`extract/dnd/npc-interactions/source_relatedness`):
- inspect only current transcript text covered by the cited ranges;
- emit at most one bounded warning per record when the NPC name cannot be
related to that evidence;
- do not claim to validate category agreement; and
- ignore campaign and registry content for relatedness.
5. `internal/modules/dnd/validate/npcinteractions/invariants`
(`normalize/dnd/npc-interactions/invariants`):
- require the exact canonical display name returned by registry lookup;
- require canonical source-reference order with no duplicate ranges;
- require the complete list order defined in Fixed Decisions; and
- reject exact duplicate normalized records.
The registry and invariants validators must support operation-time generated
references. Construction-time metadata and fingerprints follow the resolver
pattern used by registry-aware modules and must not include NPC names or
content.
### Tests
Give each policy one primary test owner:
- shape tests own empty names, enum membership, and missing evidence;
- registry tests own required binding, comparison-key recognition, unknown
names, operation-time generated overrides, empty registries, and content-safe
failures;
- source-reference tests own wrong source IDs, missing units, reversed ranges,
and valid multi-range records;
- relatedness tests own current-transcript-only warnings and bounded
diagnostics; and
- invariant tests own exact canonical names, final ordering, canonical evidence
sequences, and duplicate rejection.
Also verify that validators do not mutate artifacts, source documents, or
registries and that their specs register only for
`dnd.NPCInteractionListKind`.
### Completion Criteria
- Valid typed candidates are approved and malformed or ungrounded candidates
are rejected at the intended boundary.
- No validator silently canonicalizes, drops, or merges records.
- References can explain identity but cannot satisfy source evidence checks.
## Stage 4 — Add Deterministic Merge And Normalization
### Goal
Preserve chunk-scoped occurrences through merge, then produce the canonical
ordered artifact without semantic inference.
### Changes
1. Add `internal/modules/dnd/normalize/npcinteractions` with strict empty
options and the fixed module key and artifact kind.
2. Declare the same required `npcs` reference slot as the extractor. Construct
and resolve `npcregistry.Resolver` at the same preparation/operation
boundaries, and require a bound registry.
3. Implement the normalization algorithm in Fixed Decisions:
- clone all nested values;
- normalize display whitespace only as needed for lookup;
- replace every recognized name with the registry's exact canonical display
name;
- canonicalize each reference list without merging overlapping ranges;
- compute order from source-document positions rather than assuming numeric
unit IDs are contiguous;
- apply every specified tie-breaker; and
- collapse only exact records after canonicalization.
4. Preserve nil versus present-empty list behavior consistently with the other
D&D normalizers.
5. Emit bounded warnings for visible canonical-name changes, reference
canonicalization, record reordering, and exact duplicate removal. Use stable
reason codes and the shared D&D diagnostics helpers. Never include reference
content in a warning.
6. Add an append-only merger function for `dnd.NPCInteractionList` beside the
other D&D typed append functions in
`internal/modules/dnd/register/merge.go`. It must preserve accepted
chunk/lane order, preserve present-empty semantics, and deep-clone source
reference slices so outputs do not alias inputs. Do not register it until
Stage 5.
7. Add normalization-policy and NPC projection identities to checkpoint
fingerprints. Manifest metadata may include the policy identity and bounded
external-registry digest/count, but not names or source content.
### Tests
Cover:
- canonical registry name replacement, including Unicode/case/spacing lookup;
- required and operation-time generated registry behavior;
- source-reference ordering and exact range deduplication;
- ordering by real document position with all deterministic tie-breakers;
- separation of category transitions, alignment transitions, nearby records,
overlapping-but-nonidentical evidence, and nonidentical records from
different chunks;
- collapse of exact duplicates only;
- stable, bounded warnings;
- nil/present-empty behavior;
- input ownership and nested-slice cloning; and
- append-order merger behavior independently of normalization.
Use table-driven pure normalization cases for the dense ordering and duplicate
rules, and package-level normalizer tests for resolver and warning behavior.
### Completion Criteria
- Normalization is deterministic and idempotent.
- Running normalization twice does not change the value or emit new
transformation warnings on the second pass.
- Semantically distinct occurrences remain distinct.
- The module and merger compile but are not yet selectable through production
composition.
## Stage 5 — Compose The Complete Production Lane
### Goal
Register the complete typed lane atomically so configuration cannot select a
partial implementation.
### Changes
Update the D&D registrar:
1. In `internal/modules/dnd/register/modules.go`, register:
- the NPC-interaction codec;
- extractor and prompt assets;
- the append-order merger variant for
`dnd.NPCInteractionListKind`;
- the `dnd/npc-interactions` normalizer; and
- the generic no-op normalizer variant for the new exact Go type.
2. In `internal/modules/dnd/register/validators.go`, register all five domain
validators plus generic always-accept and always-reject typed variants for
the new artifact type.
3. In `internal/modules/dnd/register/chains.go`, register:
- extraction chain:
`generic/valid_json`,
`extract/dnd/npc-interactions/shape`,
`extract/dnd/npc-interactions/registry`,
`extract/dnd/npc-interactions/source_refs`,
`generic/valid_json_schema`,
`extract/dnd/npc-interactions/source_relatedness`;
- normalization chain:
`generic/valid_json`,
`extract/dnd/npc-interactions/shape`,
`extract/dnd/npc-interactions/registry`,
`normalize/dnd/npc-interactions/invariants`,
`extract/dnd/npc-interactions/source_refs`,
`generic/valid_json_schema`,
`extract/dnd/npc-interactions/source_relatedness`.
4. Do not add a default merge validator chain. The append merger performs no
semantic mutation, and extraction plus normalized boundaries own the
consequential policies.
5. Extend registrar contract tests to verify exact typed coverage, keys,
artifact kinds, prompt/schema assets, reference-slot agreement, and default
validator order.
### Tests
- Extend the existing D&D family registration test rather than creating
parallel catalog snapshots.
- Verify that the extractor and normalizer both expose required compatible
`npcs` slots and that generated `dnd/npc-list` bindings resolve only from an
earlier step.
- Verify production resolution rejects a missing required binding, a later or
same-step producer, a wrong artifact kind, and incompatible media/schema
metadata through existing resolver behavior.
- Avoid retesting the framework's general ordered-step failure matrix; add only
interaction-specific composition cases not already covered generically.
### Completion Criteria
- Production registration exposes one complete type-consistent lane.
- A valid two-step NPC-to-interactions pipeline resolves and prepares.
- Invalid or missing NPC dependencies fail before source parsing or LLM work
whenever statically discoverable.
## Stage 6 — Prove The Workflow And Publish Current Contracts
### Goal
Exercise the assembled generated-reference workflow and move all implemented
behavior into its canonical current documentation.
### Changes
1. Add one representative integration test under
`internal/modules/integration` with focused testdata:
- step 1 extracts and normalizes NPCs;
- step 2 consumes the generated NPC artifact in an interaction lane;
- the fake structured LLM returns multiple interaction kinds and at least
one name variant;
- the assertion proves the names-only handoff, current-transcript evidence,
canonical name, stable chronology, and final durable artifact.
2. Include one failure assertion showing that a rejected or absent normalized
NPC producer prevents interaction extraction. Reuse the existing runner
dependency behavior; do not duplicate every framework failure case.
3. Add `examples/dnd-npc-interactions.config.yml` as a complete copyable
two-step profile. The first step produces `npcs`; the second step binds that
exact step/lane artifact to its `npcs` reference and selects
`dnd/npc-interactions` for extract and normalize.
4. Add the example to the existing CLI production configuration contract test
so it is parsed and resolved offline.
5. Add the durable external contract at
`docs/integrations/dnd-npc-interaction-artifacts.md`. It owns schema
identity, JSON shape, categories, evidence semantics, ordering,
normalization, validator chains, generated-reference behavior, and manifest
metadata.
6. Update current canonical documentation:
- `docs/config.md` for module/validator catalogs, default chains, required
reference slot, and the maintained example;
- `docs/internal/modules.md` for implementation ownership, prompt inputs,
registry resolution, validation, merge, and normalization;
- `docs/internal/overview.md` for the implemented component inventory;
- `docs/internal/llm.md` only as needed to include the new lane in the
existing D&D prompt-order/cache policy without duplicating the manifest;
- `docs/integrations/dnd-npc-artifacts.md` to identify NPC interactions as a
consumer while retaining the rule that registry evidence is not event
evidence; and
- any directly affected configuration or integration links.
7. Remove the NPC-interactions entry from `docs/roadmap/future.md` once the
production behavior and current documentation are complete. Change
`docs/roadmap/dnd-npc-interactions.md` to `Status: Implemented` pending its
later retirement; do not leave it as the canonical current contract.
8. Create a small human-review worksheet or fixture set only if the repository
already has an appropriate non-test evaluation home. Otherwise record the
manual evaluation results in the implementation handoff rather than
inventing a new framework. Exercise all six categories, transitions,
repeated occurrences, mentions followed by presence, combat alignment
changes, cross-chunk repetition, and empty output on at least one intended
smaller model. Do not make model agreement a deterministic CI gate.
### Validation
Run focused tests while implementing, then run:
```sh
go test ./...
go vet ./...
go build ./cmd/notarius
```
Also run `git diff --check`, inspect the complete documentation diff for
current-versus-future claims, and verify every new relative documentation link.
### Completion Criteria
- All repository checks pass offline.
- The maintained example resolves with an explicit earlier NPC producer.
- The integration test proves the accepted generated artifact, not registry
source references, grounds the later model request.
- Durable output contains only `name`, `kind`, and current-transcript
`source_refs` per occurrence.
- Current documentation owns implemented contracts, while the feature roadmap
is marked implemented and `future.md` no longer advertises the work as
pending.
## Open Questions
None. The feature roadmap and fixed decisions above define the product and
architectural choices required for implementation.

View File

@@ -0,0 +1,50 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "notarius.dnd.npc_interactions",
"type": "object",
"additionalProperties": false,
"required": ["interactions"],
"properties": {
"interactions": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["name", "kind", "source_refs"],
"properties": {
"name": {
"type": "string",
"minLength": 1
},
"kind": {
"type": "string",
"enum": ["mentioned", "noncombat_presence", "dialogue", "combat_ally", "combat_opponent", "other"]
},
"source_refs": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["source_id", "start_unit_id", "end_unit_id"],
"properties": {
"source_id": {
"type": "string",
"minLength": 1
},
"start_unit_id": {
"type": "integer",
"minimum": 1
},
"end_unit_id": {
"type": "integer",
"minimum": 1
}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,140 @@
// Package npcinteractions encodes durable D&D NPC interaction artifacts.
package npcinteractions
import (
"bytes"
"embed"
"encoding/json"
"fmt"
"io"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
)
const (
SchemaID = "notarius.dnd.npc_interactions"
SchemaName = "notarius_dnd_npc_interactions_v1"
SchemaVersion = "v1"
MediaType = "application/json"
)
//go:embed assets/schemas/dnd_npc_interactions.v1.json
var schemaAssets embed.FS
var _ contracts.ArtifactCodec[dnd.NPCInteractionList] = (*Codec)(nil)
type Codec struct{}
func New() *Codec { return &Codec{} }
func (c *Codec) Kind() contracts.ArtifactKind { return dnd.NPCInteractionListKind }
func (c *Codec) Schema() contracts.ArtifactSchema {
raw, err := schemaAssets.ReadFile("assets/schemas/dnd_npc_interactions.v1.json")
if err != nil {
return contracts.ArtifactSchema{}
}
return contracts.ArtifactSchema{
ID: SchemaID,
Name: SchemaName,
Version: SchemaVersion,
JSONSchema: append([]byte(nil), raw...),
}
}
func (c *Codec) MediaType() string { return MediaType }
func (c *Codec) Metadata(value dnd.NPCInteractionList) map[string]any {
return map[string]any{"interaction_count": len(value.Interactions)}
}
func (c *Codec) Encode(value dnd.NPCInteractionList) ([]byte, error) {
if err := validate(value); err != nil {
return nil, fmt.Errorf("encode dnd npc interaction list: %w", err)
}
return c.EncodeCandidate(value)
}
// EncodeCandidate provides the durable representation before semantic
// validators have approved a value.
func (c *Codec) EncodeCandidate(value dnd.NPCInteractionList) ([]byte, error) {
content, err := json.Marshal(value)
if err != nil {
return nil, fmt.Errorf("encode dnd npc interaction list: %w", err)
}
return content, nil
}
func (c *Codec) Decode(content []byte) (dnd.NPCInteractionList, error) {
value, err := c.DecodeCandidate(content)
if err != nil {
return dnd.NPCInteractionList{}, err
}
if err := validate(value); err != nil {
return dnd.NPCInteractionList{}, fmt.Errorf("decode dnd npc interaction list: %w", err)
}
return value, nil
}
// DecodeCandidate reads one strict durable JSON value before semantic
// validators have approved it.
func (c *Codec) DecodeCandidate(content []byte) (dnd.NPCInteractionList, error) {
decoder := json.NewDecoder(bytes.NewReader(content))
decoder.DisallowUnknownFields()
var value dnd.NPCInteractionList
if err := decoder.Decode(&value); err != nil {
return dnd.NPCInteractionList{}, fmt.Errorf("decode dnd npc interaction list: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
return dnd.NPCInteractionList{}, fmt.Errorf("decode dnd npc interaction list: multiple JSON values")
}
return value, nil
}
func validate(value dnd.NPCInteractionList) error {
if value.Interactions == nil {
return fmt.Errorf("interactions must be present")
}
for index, interaction := range value.Interactions {
prefix := fmt.Sprintf("interactions[%d]", index)
if strings.TrimSpace(interaction.Name) == "" {
return fmt.Errorf("%s.name must not be empty", prefix)
}
if !validInteractionKind(interaction.Kind) {
return fmt.Errorf("%s.kind must be supported", prefix)
}
if len(interaction.SourceRefs) == 0 {
return fmt.Errorf("%s.source_refs must contain at least one reference", prefix)
}
for refIndex, ref := range interaction.SourceRefs {
refPrefix := fmt.Sprintf("%s.source_refs[%d]", prefix, refIndex)
if strings.TrimSpace(ref.SourceID) == "" {
return fmt.Errorf("%s.source_id must not be empty", refPrefix)
}
if ref.StartUnitID <= 0 {
return fmt.Errorf("%s.start_unit_id must be positive", refPrefix)
}
if ref.EndUnitID <= 0 {
return fmt.Errorf("%s.end_unit_id must be positive", refPrefix)
}
}
}
return nil
}
func validInteractionKind(value dnd.NPCInteractionKind) bool {
switch value {
case dnd.NPCInteractionKindMentioned,
dnd.NPCInteractionKindNoncombatPresence,
dnd.NPCInteractionKindDialogue,
dnd.NPCInteractionKindCombatAlly,
dnd.NPCInteractionKindCombatOpponent,
dnd.NPCInteractionKindOther:
return true
default:
return false
}
}

View File

@@ -0,0 +1,183 @@
package npcinteractions
import (
"bytes"
"encoding/json"
"errors"
"os"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
)
func validList() dnd.NPCInteractionList {
return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{
{
Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue,
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}},
},
{
Name: "Hooded Guard", Kind: dnd.NPCInteractionKindCombatOpponent,
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}},
},
}}
}
func TestCodecMatchesMaintainedDurableFixture(t *testing.T) {
raw, err := os.ReadFile("testdata/dnd_npc_interactions.v1.json")
if err != nil {
t.Fatal(err)
}
codec := New()
value, err := codec.Decode(raw)
if err != nil {
t.Fatalf("Decode() error = %v", err)
}
if want := validList(); !reflect.DeepEqual(value, want) {
t.Fatalf("Decode() = %#v, want %#v", value, want)
}
encoded, err := codec.Encode(value)
if err != nil {
t.Fatalf("Encode() error = %v", err)
}
var compact bytes.Buffer
if err := json.Compact(&compact, raw); err != nil {
t.Fatal(err)
}
if !bytes.Equal(encoded, compact.Bytes()) {
t.Fatalf("Encode() = %s, want %s", encoded, compact.Bytes())
}
}
func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) {
codec := New()
schema := codec.Schema()
if codec.Kind() != dnd.NPCInteractionListKind || codec.MediaType() != MediaType {
t.Fatalf("codec identity = %q/%q", codec.Kind(), codec.MediaType())
}
if schema.ID != SchemaID || schema.Name != SchemaName || schema.Version != SchemaVersion || !json.Valid(schema.JSONSchema) {
t.Fatalf("schema = %#v", schema)
}
registry := pipeline.NewArtifactCodecRegistry()
if err := pipeline.RegisterArtifactCodec(registry, codec); err != nil {
t.Fatal(err)
}
spec, ok := registry.Spec(dnd.NPCInteractionListKind)
if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) {
t.Fatalf("registered spec = %#v, %t", spec, ok)
}
if _, err := registry.Encode(dnd.NPCInteractionListKind, dnd.NPCList{}); err == nil {
t.Fatal("Encode() error = nil, want exact type rejection")
} else {
var typeErr *pipeline.ArtifactCodecTypeError
if !errors.As(err, &typeErr) {
t.Fatalf("Encode() error = %T, want ArtifactCodecTypeError", err)
}
}
}
func TestCodecSupportsEmptyListAndPreservesCollectionPresenceInCandidates(t *testing.T) {
codec := New()
empty := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{}}
content, err := codec.Encode(empty)
if err != nil || string(content) != `{"interactions":[]}` {
t.Fatalf("Encode() = %s, %v", content, err)
}
for _, candidate := range []dnd.NPCInteractionList{
{},
empty,
{Interactions: []dnd.NPCInteraction{{Name: " ", Kind: "unsupported", SourceRefs: nil}}},
{Interactions: []dnd.NPCInteraction{{Name: " ", Kind: "unsupported", SourceRefs: []source.SourceRef{}}}},
{Interactions: []dnd.NPCInteraction{{Name: " ", Kind: "unsupported", SourceRefs: []source.SourceRef{{StartUnitID: 0, EndUnitID: -1}}}}},
} {
content, err := codec.EncodeCandidate(candidate)
if err != nil || !json.Valid(content) {
t.Fatalf("EncodeCandidate() = %s, %v", content, err)
}
decoded, err := codec.DecodeCandidate(content)
if err != nil || !reflect.DeepEqual(decoded, candidate) {
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate)
}
}
}
func TestCodecStrictlyRejectsMalformedUnknownAndTrailingJSON(t *testing.T) {
validJSON := `{"interactions":[{"name":"Mira Thorn","kind":"dialogue","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`
for _, test := range []struct{ name, raw, want string }{
{"malformed", `{`, "decode dnd npc interaction list"},
{"unknown top-level", `{"interactions":[],"unexpected":true}`, "unknown field"},
{"unknown interaction field", strings.Replace(validJSON, `"kind":"dialogue"`, `"kind":"dialogue","unexpected":true`, 1), "unknown field"},
{"unknown source reference field", strings.Replace(validJSON, `"end_unit_id":1`, `"end_unit_id":1,"unexpected":true`, 1), "unknown field"},
{"trailing", `{"interactions":[]} {}`, "multiple JSON values"},
} {
t.Run(test.name, func(t *testing.T) {
if _, err := New().Decode([]byte(test.raw)); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("Decode() error = %v, want %q", err, test.want)
}
})
}
}
func TestCodecRejectsRequiredShapeEnumAndReferenceBoundaries(t *testing.T) {
tests := []struct {
name string
value dnd.NPCInteractionList
want string
}{
{"nil interactions", dnd.NPCInteractionList{}, "interactions must be present"},
{"blank name", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].Name = " " }), "name must not be empty"},
{"unsupported kind", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].Kind = "unsupported" }), "kind must be supported"},
{"nil source refs", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs = nil }), "source_refs must contain"},
{"empty source ID", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs[0].SourceID = " " }), "source_id must not be empty"},
{"non-positive start", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs[0].StartUnitID = 0 }), "start_unit_id must be positive"},
{"non-positive end", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs[0].EndUnitID = 0 }), "end_unit_id must be positive"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, err := New().Encode(test.value); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("Encode() error = %v, want %q", err, test.want)
}
})
}
}
func TestCodecAcceptsEveryInteractionKind(t *testing.T) {
for _, kind := range []dnd.NPCInteractionKind{
dnd.NPCInteractionKindMentioned,
dnd.NPCInteractionKindNoncombatPresence,
dnd.NPCInteractionKindDialogue,
dnd.NPCInteractionKindCombatAlly,
dnd.NPCInteractionKindCombatOpponent,
dnd.NPCInteractionKindOther,
} {
value := validList()
value.Interactions[0].Kind = kind
if _, err := New().Encode(value); err != nil {
t.Fatalf("Encode(%q) error = %v", kind, err)
}
}
}
func TestCodecSchemaAndMetadataAreDefensive(t *testing.T) {
codec := New()
first := codec.Schema()
first.JSONSchema[0] = '['
if second := codec.Schema(); !json.Valid(second.JSONSchema) || second.JSONSchema[0] == '[' {
t.Fatal("Schema() returned shared bytes")
}
metadata := codec.Metadata(validList())
metadata["other"] = true
if next := codec.Metadata(validList()); len(next) != 1 || next["interaction_count"] != 2 {
t.Fatalf("Metadata() = %#v", next)
}
}
func mutate(value dnd.NPCInteractionList, change func(*dnd.NPCInteractionList)) dnd.NPCInteractionList {
change(&value)
return value
}

View File

@@ -0,0 +1,18 @@
{
"interactions": [
{
"name": "Mira Thorn",
"kind": "dialogue",
"source_refs": [
{"source_id": "session-alpha", "start_unit_id": 1, "end_unit_id": 2}
]
},
{
"name": "Hooded Guard",
"kind": "combat_opponent",
"source_refs": [
{"source_id": "session-alpha", "start_unit_id": 3, "end_unit_id": 3}
]
}
]
}

View File

@@ -12,6 +12,8 @@ const NPCListKind contracts.ArtifactKind = "dnd/npc-list"
const CombatTurnListKind contracts.ArtifactKind = "dnd/combat-turn-list"
const NPCInteractionListKind contracts.ArtifactKind = "dnd/npc-interaction-list"
type SpellList struct {
SpellCasts []SpellCast `json:"spell_casts"`
}
@@ -51,3 +53,24 @@ type CombatTurn struct {
TurnKind CombatTurnKind `json:"turn_kind"`
SourceRefs []source.SourceRef `json:"source_refs"`
}
type NPCInteractionKind string
const (
NPCInteractionKindMentioned NPCInteractionKind = "mentioned"
NPCInteractionKindNoncombatPresence NPCInteractionKind = "noncombat_presence"
NPCInteractionKindDialogue NPCInteractionKind = "dialogue"
NPCInteractionKindCombatAlly NPCInteractionKind = "combat_ally"
NPCInteractionKindCombatOpponent NPCInteractionKind = "combat_opponent"
NPCInteractionKindOther NPCInteractionKind = "other"
)
type NPCInteractionList struct {
Interactions []NPCInteraction `json:"interactions"`
}
type NPCInteraction struct {
Name string `json:"name"`
Kind NPCInteractionKind `json:"kind"`
SourceRefs []source.SourceRef `json:"source_refs"`
}