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 # ADR-0009: Prefer minimal evidence-grounded extraction artifacts
**Status:** Proposed **Status:** Accepted
**Date:** 2026-07-22 **Date:** 2026-07-22
## Context ## 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. with the shared Unicode-aware NPC identity policy.
The production D&D registrar exposes the extractor and these validators. Its The production D&D registrar exposes the extractor and these validators. Its
default extraction chain preserves this order: JSON syntax, private response default extraction chain preserves this order: JSON syntax, combat shape,
schema, combat shape, source references, then source relatedness. source references, private response schema, then source relatedness.
## Normalization boundary ## Normalization boundary
@@ -143,8 +143,8 @@ with `invalid_combat_turn_normalization` under policy
`dnd.combat_turns.validator.normalized.v1`. `dnd.combat_turns.validator.normalized.v1`.
The production D&D registrar exposes the normalizer and normalized-invariants The production D&D registrar exposes the normalizer and normalized-invariants
validator. Its default normalization chain is JSON syntax, durable schema, validator. Its default normalization chain is JSON syntax, combat shape,
combat shape, normalized invariants, source references, then source normalized invariants, source references, durable schema, then source
relatedness. The lane uses the framework's typed append-order merger and has no relatedness. The lane uses the framework's typed append-order merger and has no
merge validator chain. merge validator chain.

View File

@@ -3,7 +3,7 @@
This document defines the durable D&D NPC-list artifact, its JSON codec, and 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 the selectable production NPC pipeline. The normalized JSON payload can be
passed explicitly to the spell extractor as an optional caster-name registry 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. remains a reference, not spell or combat evidence.
## Identity ## Identity
@@ -81,12 +81,13 @@ by source evidence. Groups, generic roles, invented labels, and descriptive or
relationship enrichment are outside the contract. 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`, `extract/dnd/npcs/shape`, `extract/dnd/npcs/source_refs`,
`extract/dnd/npcs/source_refs`, and `generic/valid_json_schema`, and `extract/dnd/npcs/source_relatedness`. The
`extract/dnd/npcs/source_relatedness`. The normalize chain adds default normalize chain is `generic/valid_json`, `extract/dnd/npcs/shape`,
`normalize/dnd/npcs/identity` before the source-reference and relatedness `normalize/dnd/npcs/identity`, `extract/dnd/npcs/source_refs`,
checks. Relatedness emits bounded warnings when an NPC canonical name is not `generic/valid_json_schema`, and `extract/dnd/npcs/source_relatedness`.
present near its cited transcript text; opaque campaign 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. references may explain such a warning but do not become evidence.
## Manifest And Artifact Handoff ## 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 explicit validator override that accepts the candidate promotes the unresolved
warning normally. 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 ## Manifest Metadata
The extractor adds prompt and response-schema provenance under the artifact lane 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 normalized-invariants validator owns actor display normalization, canonical
source-reference order, chronology, and exact duplicate identity; it defers source-reference order, chronology, and exact duplicate identity; it defers
shape and source-reference failures. All four validators are deterministic and shape and source-reference failures. All four validators are deterministic and
expose local policy fingerprints. The D&D registrar orders them after generic expose local policy fingerprints. In the registered defaults, JSON syntax runs
JSON validation and before response-schema validation at extraction and first; combat shape, normalized invariants when applicable, and source-reference
normalization. validation precede JSON Schema validation; warning-only relatedness runs last.
## Production Registration ## Production Registration

View File

@@ -7,19 +7,6 @@ not as committed release dates.
## Near-Term D&D Pipeline ## 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 Spell Extraction And Normalization
- Evaluate ordinary extraction retries and the completed normalization path - Evaluate ordinary extraction retries and the completed normalization path

View File

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

View File

@@ -1,6 +1,6 @@
# Minimal D&D Extraction Contracts # Minimal D&D Extraction Contracts
**Status:** Proposed **Status:** Implemented
## Intent ## Intent
@@ -213,8 +213,8 @@ and live model calls are evaluation aids rather than deterministic CI gates.
## Documentation Ownership ## Documentation Ownership
The spell, NPC, and combat integration documents are the canonical owners of The spell, NPC, and combat integration documents are the canonical owners of
their redesigned durable schemas once implemented. Internal LLM and module their redesigned durable schemas. Internal LLM and module documents own the
documents own the corresponding current prompt, DTO, validator, normalizer, and corresponding current prompt, DTO, validator, normalizer, and NPC
NPC prompt-projection behavior. This roadmap owns the desired end state and prompt-projection behavior. This roadmap records the implemented design and
policy; [the implementation plan](implementation.md) owns sequencing and policy; [the implementation plan](implementation.md) records the completed
completion criteria until the cutover is complete. 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/core/config"
"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"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs" npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/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"}) { 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) 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{ wantExtractChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"), 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 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: "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","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"},

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"sync" "sync"
"testing" "testing"
@@ -15,6 +16,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns" combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs" 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" 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/npcs"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
@@ -34,7 +36,25 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T
} }
client := &groundedDNDLLMClient{} 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), RawInput: readNPCFixture(t),
ExtractWorkers: 1, ExtractWorkers: 1,
}) })
@@ -44,6 +64,24 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T
if len(output.Rejected) != 0 || len(output.NormalizeOutputs) != 3 { 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) 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 var npcPayload []byte
for _, serialized := range output.NormalizeOutputs { for _, serialized := range output.NormalizeOutputs {
@@ -94,6 +132,15 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T
if provenanceCount != 3 { if provenanceCount != 3 {
t.Fatalf("NPC generated provenance count = %d, want spell extract plus combat extract/normalize", provenanceCount) 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 _, lane := range output.Manifest.ArtifactLanes {
for _, component := range []string{"extractor", "normalizer"} { for _, component := range []string{"extractor", "normalizer"} {
metadata, ok := lane.Metadata[component].(map[string]any) metadata, ok := lane.Metadata[component].(map[string]any)