From 4b0b166143ce24af146468e0323a3990981ad7f2 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 22 Jul 2026 19:23:10 +0000 Subject: [PATCH] Complete minimal D&D extraction cutover --- ...-evidence-grounded-extraction-artifacts.md | 2 +- .../integrations/dnd-combat-turn-artifacts.md | 8 +-- docs/integrations/dnd-npc-artifacts.md | 15 +++--- docs/integrations/dnd-spell-artifacts.md | 5 ++ docs/internal/modules.md | 6 +-- docs/roadmap/future.md | 13 ----- docs/roadmap/implementation.md | 2 +- .../minimal-dnd-extraction-contracts.md | 12 ++--- internal/cli/dnd_npc_contract_test.go | 5 ++ internal/modules/dnd/codec/npcs/codec_test.go | 1 - .../npcs/assets/prompts/instructions.md | 6 +-- .../dnd/npcs/registry/registry_test.go | 5 +- .../integration/dnd_npc_grounded_test.go | 49 ++++++++++++++++++- 13 files changed, 85 insertions(+), 44 deletions(-) diff --git a/docs/adr/0009-minimal-evidence-grounded-extraction-artifacts.md b/docs/adr/0009-minimal-evidence-grounded-extraction-artifacts.md index 6302bea..09e0cd4 100644 --- a/docs/adr/0009-minimal-evidence-grounded-extraction-artifacts.md +++ b/docs/adr/0009-minimal-evidence-grounded-extraction-artifacts.md @@ -1,6 +1,6 @@ # ADR-0009: Prefer minimal evidence-grounded extraction artifacts -**Status:** Proposed +**Status:** Accepted **Date:** 2026-07-22 ## Context diff --git a/docs/integrations/dnd-combat-turn-artifacts.md b/docs/integrations/dnd-combat-turn-artifacts.md index 9a6456d..c854a03 100644 --- a/docs/integrations/dnd-combat-turn-artifacts.md +++ b/docs/integrations/dnd-combat-turn-artifacts.md @@ -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. diff --git a/docs/integrations/dnd-npc-artifacts.md b/docs/integrations/dnd-npc-artifacts.md index f9f978e..33fb15a 100644 --- a/docs/integrations/dnd-npc-artifacts.md +++ b/docs/integrations/dnd-npc-artifacts.md @@ -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 diff --git a/docs/integrations/dnd-spell-artifacts.md b/docs/integrations/dnd-spell-artifacts.md index 20ee6a5..11114ad 100644 --- a/docs/integrations/dnd-spell-artifacts.md +++ b/docs/integrations/dnd-spell-artifacts.md @@ -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 diff --git a/docs/internal/modules.md b/docs/internal/modules.md index 32f9b33..fd06114 100644 --- a/docs/internal/modules.md +++ b/docs/internal/modules.md @@ -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 diff --git a/docs/roadmap/future.md b/docs/roadmap/future.md index 1d94e4e..411af44 100644 --- a/docs/roadmap/future.md +++ b/docs/roadmap/future.md @@ -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 diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index d7d408e..63d459a 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Minimal D&D Extraction Contracts Implementation Plan -**Status:** Ready for implementation +**Status:** Implemented ## Objective diff --git a/docs/roadmap/minimal-dnd-extraction-contracts.md b/docs/roadmap/minimal-dnd-extraction-contracts.md index 9b9ea2e..19e3606 100644 --- a/docs/roadmap/minimal-dnd-extraction-contracts.md +++ b/docs/roadmap/minimal-dnd-extraction-contracts.md @@ -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. diff --git a/internal/cli/dnd_npc_contract_test.go b/internal/cli/dnd_npc_contract_test.go index 210bf5e..d59c733 100644 --- a/internal/cli/dnd_npc_contract_test.go +++ b/internal/cli/dnd_npc_contract_test.go @@ -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"), diff --git a/internal/modules/dnd/codec/npcs/codec_test.go b/internal/modules/dnd/codec/npcs/codec_test.go index 8ddd07a..177578d 100644 --- a/internal/modules/dnd/codec/npcs/codec_test.go +++ b/internal/modules/dnd/codec/npcs/codec_test.go @@ -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"}, diff --git a/internal/modules/dnd/extract/npcs/assets/prompts/instructions.md b/internal/modules/dnd/extract/npcs/assets/prompts/instructions.md index 4b51915..f41a847 100644 --- a/internal/modules/dnd/extract/npcs/assets/prompts/instructions.md +++ b/internal/modules/dnd/extract/npcs/assets/prompts/instructions.md @@ -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. diff --git a/internal/modules/dnd/npcs/registry/registry_test.go b/internal/modules/dnd/npcs/registry/registry_test.go index ab7e0b4..9912398 100644 --- a/internal/modules/dnd/npcs/registry/registry_test.go +++ b/internal/modules/dnd/npcs/registry/registry_test.go @@ -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)) diff --git a/internal/modules/integration/dnd_npc_grounded_test.go b/internal/modules/integration/dnd_npc_grounded_test.go index d05a8d8..9688cf6 100644 --- a/internal/modules/integration/dnd_npc_grounded_test.go +++ b/internal/modules/integration/dnd_npc_grounded_test.go @@ -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)