Complete minimal D&D extraction cutover

This commit is contained in:
2026-07-22 19:23:10 +00:00
parent 90481a0e4b
commit 4b0b166143
13 changed files with 85 additions and 44 deletions

View File

@@ -1,6 +1,6 @@
# ADR-0009: Prefer minimal evidence-grounded extraction artifacts
**Status:** Proposed
**Status:** Accepted
**Date:** 2026-07-22
## Context

View File

@@ -110,8 +110,8 @@ combines overlapping cited ranges once in document order and compares actors
with the shared Unicode-aware NPC identity policy.
The production D&D registrar exposes the extractor and these validators. Its
default extraction chain preserves this order: JSON syntax, private response
schema, combat shape, source references, then source relatedness.
default extraction chain preserves this order: JSON syntax, combat shape,
source references, private response schema, then source relatedness.
## Normalization boundary
@@ -143,8 +143,8 @@ with `invalid_combat_turn_normalization` under policy
`dnd.combat_turns.validator.normalized.v1`.
The production D&D registrar exposes the normalizer and normalized-invariants
validator. Its default normalization chain is JSON syntax, durable schema,
combat shape, normalized invariants, source references, then source
validator. Its default normalization chain is JSON syntax, combat shape,
normalized invariants, source references, durable schema, then source
relatedness. The lane uses the framework's typed append-order merger and has no
merge validator chain.

View File

@@ -3,7 +3,7 @@
This document defines the durable D&D NPC-list artifact, its JSON codec, and
the selectable production NPC pipeline. The normalized JSON payload can be
passed explicitly to the spell extractor as an optional caster-name registry
or to the combat extractor and normalizer as an actor/target registry. It
or to the combat extractor and normalizer as an actor registry. It
remains a reference, not spell or combat evidence.
## Identity
@@ -81,12 +81,13 @@ 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`,
`generic/valid_json_schema`, `extract/dnd/npcs/shape`,
`extract/dnd/npcs/source_refs`, and
`extract/dnd/npcs/source_relatedness`. The normalize chain adds
`normalize/dnd/npcs/identity` before the source-reference and relatedness
checks. Relatedness emits bounded warnings when an NPC canonical name is not
present near its cited transcript text; opaque campaign
`extract/dnd/npcs/shape`, `extract/dnd/npcs/source_refs`,
`generic/valid_json_schema`, and `extract/dnd/npcs/source_relatedness`. The
default normalize chain is `generic/valid_json`, `extract/dnd/npcs/shape`,
`normalize/dnd/npcs/identity`, `extract/dnd/npcs/source_refs`,
`generic/valid_json_schema`, and `extract/dnd/npcs/source_relatedness`.
Relatedness emits bounded warnings when an NPC canonical name is not present
near its cited transcript text; opaque campaign
references may explain such a warning but do not become evidence.
## Manifest And Artifact Handoff

View File

@@ -140,6 +140,11 @@ chain, the catalog validator rejects the candidate with `unknown_spell`; the
explicit validator override that accepts the candidate promotes the unresolved
warning normally.
The default extraction and normalization chains both preserve this registered
order: JSON syntax, spell shape, catalog membership, source references, JSON
Schema, then source relatedness. Extraction validates the private response
schema; normalization validates the durable artifact schema.
## Manifest Metadata
The extractor adds prompt and response-schema provenance under the artifact lane

View File

@@ -424,9 +424,9 @@ an unrelated actor. Actors use normalized consecutive-token matching. The
normalized-invariants validator owns actor display normalization, canonical
source-reference order, chronology, and exact duplicate identity; it defers
shape and source-reference failures. All four validators are deterministic and
expose local policy fingerprints. The D&D registrar orders them after generic
JSON validation and before response-schema validation at extraction and
normalization.
expose local policy fingerprints. In the registered defaults, JSON syntax runs
first; combat shape, normalized invariants when applicable, and source-reference
validation precede JSON Schema validation; warning-only relatedness runs last.
## Production Registration

View File

@@ -7,19 +7,6 @@ not as committed release dates.
## Near-Term D&D Pipeline
### Simplify D&D Extraction Contracts
- Adopt the proposed principle in
[ADR-0009](../adr/0009-minimal-evidence-grounded-extraction-artifacts.md)
that extractors should return the smallest useful evidence-grounded facts
needed to answer one narrow question.
- Redesign the spell, NPC, and combat-turn durable contracts and prompts as
described in
[Minimal D&D Extraction Contracts](minimal-dnd-extraction-contracts.md).
- Replace the unpublished v1 schema shapes in place rather than adding optional
or synthetic compatibility fields, and evaluate the reduced outputs against
human-reviewed transcripts after cutover.
### Evaluate Spell Extraction And Normalization
- Evaluate ordinary extraction retries and the completed normalization path

View File

@@ -1,6 +1,6 @@
# Minimal D&D Extraction Contracts Implementation Plan
**Status:** Ready for implementation
**Status:** Implemented
## Objective

View File

@@ -1,6 +1,6 @@
# Minimal D&D Extraction Contracts
**Status:** Proposed
**Status:** Implemented
## Intent
@@ -213,8 +213,8 @@ and live model calls are evaluation aids rather than deterministic CI gates.
## Documentation Ownership
The spell, NPC, and combat integration documents are the canonical owners of
their redesigned durable schemas once implemented. Internal LLM and module
documents own the corresponding current prompt, DTO, validator, normalizer, and
NPC prompt-projection behavior. This roadmap owns the desired end state and
policy; [the implementation plan](implementation.md) owns sequencing and
completion criteria until the cutover is complete.
their redesigned durable schemas. Internal LLM and module documents own the
corresponding current prompt, DTO, validator, normalizer, and NPC
prompt-projection behavior. This roadmap records the implemented design and
policy; [the implementation plan](implementation.md) records the completed
sequencing and completion criteria.

View File

@@ -8,6 +8,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
)
@@ -47,6 +48,10 @@ func TestProductionNPCConfigurationResolvesTypedLane(t *testing.T) {
if !ok || !reflect.DeepEqual(normalizeSpec.Requires, []string{"merged"}) || !reflect.DeepEqual(normalizeSpec.Provides, []string{"normalized"}) {
t.Fatalf("NPC normalizer spec = %#v, want merged/normalized capabilities", normalizeSpec)
}
codecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.NPCListKind)
if !ok || codecSpec.Kind != dnd.NPCListKind || codecSpec.Schema.ID != npccodec.SchemaID || codecSpec.Schema.Version != npccodec.SchemaVersion {
t.Fatalf("NPC codec spec = %#v, want typed v1 durable schema", codecSpec)
}
wantExtractChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"),

View File

@@ -82,7 +82,6 @@ func TestCodecStrictlyRejectsMalformedOrUnknownJSON(t *testing.T) {
}{
{name: "unknown top-level", raw: `{"npcs":[],"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: "missing array", raw: `{}`, want: "npcs must be present"},
{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"},

View File

@@ -1,10 +1,8 @@
For every NPC record, return only the observed display name and transcript
units that support that identity.
Do not return descriptions, aliases, relationships, biographies, statistics,
alignment, motivations, encounter summaries, or lore inferred from general
D&D knowledge. Do not invent a label for an anonymous creature, crowd, or
generic role.
Return no other details or lore inferred from general D&D knowledge. Do not
invent a label for an anonymous creature, crowd, or generic role.
Preserve observed display spelling. Return at least one narrow source range for
every record.

View File

@@ -98,10 +98,9 @@ func TestRegistryLookupAndAccessorsAreImmutable(t *testing.T) {
}
}
func TestResolveRejectsRichOrInvalidRegistryJSON(t *testing.T) {
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}]}]}`)
func TestResolveRejectsMalformedOrUnsupportedRegistryInput(t *testing.T) {
for _, item := range []contracts.ReferenceItem{
{MediaType: "application/json", Content: rich},
{MediaType: "application/json", Content: []byte(`{"npcs":[`)},
{MediaType: "text/plain", Content: []byte(`{"npcs":[]}`)},
} {
_, err := Resolve(referenceSet(item))

View File

@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"encoding/json"
"fmt"
"strings"
"sync"
"testing"
@@ -15,6 +16,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells"
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
@@ -34,7 +36,25 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T
}
client := &groundedDNDLLMClient{}
output, err := runPreparedPipeline(t, registries, materialized, client, pipeline.RunInput{
prepared, err := pipeline.Prepare(materialized, registries, pipeline.ModuleDependencies{LLM: client})
if err != nil {
t.Fatalf("Prepare() error = %v", err)
}
for _, name := range []string{
"extract:npcs:dnd/npcs:prompt",
"extract:npcs:dnd/npcs:response_schema",
"extract:spells:dnd/spells:prompt",
"extract:spells:dnd/spells:response_schema",
"extract:spells:dnd/spells:npc_registry",
"extract:combat:dnd/combat-turns:prompt",
"extract:combat:dnd/combat-turns:response_schema",
"extract:combat:dnd/combat-turns:npc_registry",
"normalize:combat:dnd/combat-turns:npc_registry",
} {
assertCombatFingerprint(t, prepared.CheckpointFingerprints(), name)
}
output, err := pipeline.New().Run(context.Background(), pipeline.RunInput{
Prepared: prepared,
RawInput: readNPCFixture(t),
ExtractWorkers: 1,
})
@@ -44,6 +64,24 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T
if len(output.Rejected) != 0 || len(output.NormalizeOutputs) != 3 {
t.Fatalf("run outputs = %#v rejected = %#v, want NPC, spell, and combat outputs", output.NormalizeOutputs, output.Rejected)
}
wantSchemas := map[string]string{"npcs": npccodec.SchemaID, "spells": spellcodec.SchemaID, "combat": combatcodec.SchemaID}
for _, serialized := range output.NormalizeOutputs {
if serialized.Artifact.Schema.ID != wantSchemas[serialized.LaneID] || serialized.Artifact.Schema.Version != "v1" {
t.Fatalf("%s artifact schema = %#v, want minimal v1 identity", serialized.LaneID, serialized.Artifact.Schema)
}
}
wantExtractorIdentity := map[string]struct{ promptID, schemaID string }{
"npcs": {npcs.PromptID, npcs.ResponseSchemaID},
"spells": {spells.PromptID, spells.ResponseSchemaID},
"combat": {combatextract.PromptID, combatextract.ResponseSchemaID},
}
for _, lane := range output.Manifest.ArtifactLanes {
want, ok := wantExtractorIdentity[lane.ID]
metadata, metadataOK := lane.Metadata["extractor"].(map[string]any)
if !ok || !metadataOK || metadata["prompt_id"] != want.promptID || metadata["prompt_version"] != "v1" || metadata["response_schema_id"] != want.schemaID || metadata["response_schema_version"] != "v1" {
t.Fatalf("%s extractor metadata = %#v, want v1 prompt/schema identity", lane.ID, metadata)
}
}
var npcPayload []byte
for _, serialized := range output.NormalizeOutputs {
@@ -94,6 +132,15 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T
if provenanceCount != 3 {
t.Fatalf("NPC generated provenance count = %d, want spell extract plus combat extract/normalize", provenanceCount)
}
manifestContent, err := json.Marshal(output.Manifest)
if err != nil {
t.Fatal(err)
}
for _, forbidden := range []string{"Mira Thorn", "Hooded Guard", "For every NPC record", "Extract Dungeons & Dragons"} {
if strings.Contains(string(manifestContent), forbidden) {
t.Fatalf("manifest exposes prompt or reference payload content %q", forbidden)
}
}
for _, lane := range output.Manifest.ArtifactLanes {
for _, component := range []string{"extractor", "normalizer"} {
metadata, ok := lane.Metadata[component].(map[string]any)