From f0ede9dacc49aa5239de7b5d05d7d7f5dec3774e Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 25 Jul 2026 22:05:52 +0000 Subject: [PATCH] Register D&D item event pipeline --- internal/cli/production_contract_test.go | 51 +++++++++-- internal/modules/dnd/register/chains.go | 33 +++++++ internal/modules/dnd/register/merge.go | 31 +++++++ internal/modules/dnd/register/modules.go | 13 +++ .../modules/dnd/register/register_test.go | 88 +++++++++++++++++-- internal/modules/dnd/register/validators.go | 14 +++ .../source_relatedness/validator.go | 88 +++++++++++++++++++ .../source_relatedness/validator_test.go | 74 ++++++++++++++++ 8 files changed, 382 insertions(+), 10 deletions(-) create mode 100644 internal/modules/dnd/validate/itemevents/source_relatedness/validator.go create mode 100644 internal/modules/dnd/validate/itemevents/source_relatedness/validator_test.go diff --git a/internal/cli/production_contract_test.go b/internal/cli/production_contract_test.go index aed5861..21f83bc 100644 --- a/internal/cli/production_contract_test.go +++ b/internal/cli/production_contract_test.go @@ -24,10 +24,13 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes" combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns" + itemeventcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemevents" spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells" combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns" + itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns" + itemeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents" spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells" "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop" ) @@ -38,9 +41,9 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) { assertProductionContains(t, "inputs", registries.Inputs.RegisteredKeys(), []string{"seriatim"}) assertProductionContains(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes", "generic"}) - assertProductionContains(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", "dnd/npcs", combatextract.Key}) + assertProductionContains(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", "dnd/npcs", combatextract.Key, itemeventextract.Key}) assertProductionContains(t, "mergers", registries.Mergers.RegisteredKeys(), []string{"appendorder"}) - assertProductionContains(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{"noop", spellnormalize.Key, "dnd/npcs", combatnormalize.Key}) + assertProductionContains(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{"noop", spellnormalize.Key, "dnd/npcs", combatnormalize.Key, itemeventnormalize.Key}) assertProductionContains(t, "outputs", registries.Outputs.RegisteredKeys(), []string{"json"}) assertProductionContains(t, "validators", registries.Validators.RegisteredKeys(), []string{ "extract/dnd/spells/catalog", @@ -51,16 +54,21 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) { "extract/dnd/combat-turns/source_refs", "extract/dnd/combat-turns/source_relatedness", "normalize/dnd/combat-turns/invariants", + "extract/dnd/item-events/shape", + "extract/dnd/item-events/source_refs", + "extract/dnd/item-events/source_relatedness", + "normalize/dnd/item-events/invariants", "generic/always_accept", "generic/always_reject", "generic/valid_json", "generic/valid_json_schema", }) - assertProductionContains(t, "artifact codec kinds", registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind}) - assertProductionContains(t, "merger variants", registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind}) - assertProductionContains(t, "normalizer variants", registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind}) + assertProductionContains(t, "artifact codec kinds", registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind}) + assertProductionContains(t, "merger variants", registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind}) + assertProductionContains(t, "normalizer variants", registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind}) assertProductionContains(t, "spell normalizer variants", registries.Normalizers.RegisteredArtifactKinds(spellnormalize.Key), []contracts.ArtifactKind{dnd.SpellListKind}) assertProductionContains(t, "combat normalizer variants", registries.Normalizers.RegisteredArtifactKinds(combatnormalize.Key), []contracts.ArtifactKind{dnd.CombatTurnListKind}) + assertProductionContains(t, "item event normalizer variants", registries.Normalizers.RegisteredArtifactKinds(itemeventnormalize.Key), []contracts.ArtifactKind{dnd.ItemEventListKind}) wantChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), @@ -97,6 +105,27 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) { if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, combatnormalize.Key); !reflect.DeepEqual(got, combatNormalizeChain) { t.Fatalf("combat normalize validator chain = %#v, want %#v", got, combatNormalizeChain) } + itemEventExtractChain := []pipeline.ModuleBinding{ + pipeline.Binding("generic/valid_json"), + pipeline.Binding("extract/dnd/item-events/shape"), + pipeline.Binding("extract/dnd/item-events/source_refs"), + pipeline.Binding("generic/valid_json_schema"), + pipeline.Binding("extract/dnd/item-events/source_relatedness"), + } + itemEventNormalizeChain := []pipeline.ModuleBinding{ + pipeline.Binding("generic/valid_json"), + pipeline.Binding("extract/dnd/item-events/shape"), + pipeline.Binding("normalize/dnd/item-events/invariants"), + pipeline.Binding("extract/dnd/item-events/source_refs"), + pipeline.Binding("generic/valid_json_schema"), + pipeline.Binding("extract/dnd/item-events/source_relatedness"), + } + if got := registries.ValidatorChains.Validators(pipeline.StageExtract, itemeventextract.Key); !reflect.DeepEqual(got, itemEventExtractChain) { + t.Fatalf("item event extract validator chain = %#v, want %#v", got, itemEventExtractChain) + } + if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, itemeventnormalize.Key); !reflect.DeepEqual(got, itemEventNormalizeChain) { + t.Fatalf("item event normalize validator chain = %#v, want %#v", got, itemEventNormalizeChain) + } assetNames := productionAssetNames(t, components.assets.PromptFS) requiredAssets := []string{ @@ -119,6 +148,14 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) { "dnd.combat_turns/sharedassets/common-dnd-system.md", "dnd.combat_turns/sharedassets/common-dnd-transcript.md", "dnd.combat_turns/task.md", + "dnd.item_events/dnd.item_events.yaml", + "dnd.item_events/instructions.md", + "dnd.item_events/sharedassets/common-dnd-extraction-evidence.md", + "dnd.item_events/sharedassets/common-dnd-identity.md", + "dnd.item_events/sharedassets/common-dnd-references.md", + "dnd.item_events/sharedassets/common-dnd-system.md", + "dnd.item_events/sharedassets/common-dnd-transcript.md", + "dnd.item_events/task.md", } assertProductionContains(t, "production prompt assets", assetNames, requiredAssets) @@ -135,6 +172,10 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) { if !ok || combatCodecSpec.Kind != dnd.CombatTurnListKind || combatCodecSpec.Schema.ID != combatcodec.SchemaID { t.Fatalf("combat codec spec = %#v, ok=%t, want typed D&D combat codec", combatCodecSpec, ok) } + itemEventCodecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.ItemEventListKind) + if !ok || itemEventCodecSpec.Kind != dnd.ItemEventListKind || itemEventCodecSpec.Schema.ID != itemeventcodec.SchemaID { + t.Fatalf("item event codec spec = %#v, ok=%t, want typed D&D item-event codec", itemEventCodecSpec, ok) + } if got := catalog.ValidatorChains.Validators(pipeline.StageExtract, spells.Key); !reflect.DeepEqual(got, wantChain) { t.Fatalf("catalog validator chain = %#v, want %#v", got, wantChain) } diff --git a/internal/modules/dnd/register/chains.go b/internal/modules/dnd/register/chains.go index dcf33b4..3628a19 100644 --- a/internal/modules/dnd/register/chains.go +++ b/internal/modules/dnd/register/chains.go @@ -3,11 +3,13 @@ package register import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns" + itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents" interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions" npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs" scenedescriptionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions" spellextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns" + itemeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents" interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions" npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs" scenedescriptionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/scenedescriptions" @@ -16,6 +18,10 @@ import ( combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape" combatsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_refs" combatrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_relatedness" + itemeventinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/invariants" + itemeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/shape" + itemeventrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/source_refs" + itemeventrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/source_relatedness" interactioninvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/invariants" interactionregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/registry" interactionshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/shape" @@ -121,6 +127,33 @@ func registerDefaultChains(registry *pipeline.ValidatorChainRegistry) error { }, }) }}, + {name: "item events validator chain", register: func() error { + return registry.Register(pipeline.ValidatorChainMapping{ + Stage: pipeline.StageExtract, + Module: itemeventextract.Key, + Validators: []pipeline.ModuleBinding{ + pipeline.Binding(validjson.Key), + pipeline.Binding(itemeventshape.Key), + pipeline.Binding(itemeventrefs.Key), + pipeline.Binding(validjsonschema.Key), + pipeline.Binding(itemeventrelatedness.Key), + }, + }) + }}, + {name: "item events normalize validator chain", register: func() error { + return registry.Register(pipeline.ValidatorChainMapping{ + Stage: pipeline.StageNormalize, + Module: itemeventnormalize.Key, + Validators: []pipeline.ModuleBinding{ + pipeline.Binding(validjson.Key), + pipeline.Binding(itemeventshape.Key), + pipeline.Binding(itemeventinvariants.Key), + pipeline.Binding(itemeventrefs.Key), + pipeline.Binding(validjsonschema.Key), + pipeline.Binding(itemeventrelatedness.Key), + }, + }) + }}, {name: "npc interactions validator chain", register: func() error { return registry.Register(pipeline.ValidatorChainMapping{ Stage: pipeline.StageExtract, diff --git a/internal/modules/dnd/register/merge.go b/internal/modules/dnd/register/merge.go index 74656dc..e7abc0c 100644 --- a/internal/modules/dnd/register/merge.go +++ b/internal/modules/dnd/register/merge.go @@ -70,6 +70,27 @@ func appendCombatTurnLists(values []dnd.CombatTurnList) (dnd.CombatTurnList, err return combined, nil } +func appendItemEventLists(values []dnd.ItemEventList) (dnd.ItemEventList, error) { + count := 0 + present := false + for _, value := range values { + if value.Events != nil { + present = true + } + count += len(value.Events) + } + if !present { + return dnd.ItemEventList{}, nil + } + combined := dnd.ItemEventList{Events: make([]dnd.ItemEvent, 0, count)} + for _, value := range values { + for _, event := range value.Events { + combined.Events = append(combined.Events, cloneItemEvent(event)) + } + } + return combined, nil +} + func appendNPCInteractionLists(values []dnd.NPCInteractionList) (dnd.NPCInteractionList, error) { count := 0 present := false @@ -116,6 +137,16 @@ func cloneCombatTurn(value dnd.CombatTurn) dnd.CombatTurn { return clone } +func cloneItemEvent(value dnd.ItemEvent) dnd.ItemEvent { + clone := value + if value.Quantity != nil { + quantity := *value.Quantity + clone.Quantity = &quantity + } + clone.SourceRefs = cloneSourceRefs(value.SourceRefs) + return clone +} + func cloneSpellCast(value dnd.SpellCast) dnd.SpellCast { clone := value clone.SourceRefs = cloneSourceRefs(value.SourceRefs) diff --git a/internal/modules/dnd/register/modules.go b/internal/modules/dnd/register/modules.go index 6808f30..3562c23 100644 --- a/internal/modules/dnd/register/modules.go +++ b/internal/modules/dnd/register/modules.go @@ -6,16 +6,19 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes" combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns" + itemeventcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemevents" interactioncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcinteractions" npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs" scenedescriptioncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/scenedescriptions" spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells" combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns" + itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents" interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions" npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs" scenedescriptionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions" spellextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns" + itemeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents" interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions" npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs" scenedescriptionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/scenedescriptions" @@ -30,6 +33,7 @@ func registerModules(registries pipeline.Registries) error { {name: "spells codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, codec) }}, {name: "npcs codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, npccodec.New()) }}, {name: "combat turns codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, combatcodec.New()) }}, + {name: "item events codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, itemeventcodec.New()) }}, {name: "npc interactions codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, interactioncodec.New()) }}, {name: "scene descriptions codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, scenedescriptioncodec.New()) @@ -38,6 +42,7 @@ func registerModules(registries pipeline.Registries) error { {name: "spells extractor", register: func() error { return spellextract.Register(registries.Extractors) }}, {name: "npcs extractor", register: func() error { return npcextract.Register(registries.Extractors) }}, {name: "combat turns extractor", register: func() error { return combatextract.Register(registries.Extractors) }}, + {name: "item events extractor", register: func() error { return itemeventextract.Register(registries.Extractors) }}, {name: "npc interactions extractor", register: func() error { return interactionextract.Register(registries.Extractors) }}, {name: "scene descriptions extractor", register: func() error { return scenedescriptionextract.Register(registries.Extractors) }}, {name: "spell-list appendorder merger", register: func() error { @@ -49,6 +54,9 @@ func registerModules(registries pipeline.Registries) error { {name: "combat-turn-list appendorder merger", register: func() error { return appendorder.RegisterTyped(registries.Mergers, dnd.CombatTurnListKind, appendCombatTurnLists) }}, + {name: "item-event-list appendorder merger", register: func() error { + return appendorder.RegisterTyped(registries.Mergers, dnd.ItemEventListKind, appendItemEventLists) + }}, {name: "npc-interaction-list appendorder merger", register: func() error { return appendorder.RegisterTyped(registries.Mergers, dnd.NPCInteractionListKind, appendNPCInteractionLists) }}, @@ -58,6 +66,7 @@ func registerModules(registries pipeline.Registries) error { {name: "spells normalizer", register: func() error { return spellnormalize.Register(registries.Normalizers) }}, {name: "npcs normalizer", register: func() error { return npcnormalize.Register(registries.Normalizers) }}, {name: "combat turns normalizer", register: func() error { return combatnormalize.Register(registries.Normalizers) }}, + {name: "item events normalizer", register: func() error { return itemeventnormalize.Register(registries.Normalizers) }}, {name: "npc interactions normalizer", register: func() error { return interactionnormalize.Register(registries.Normalizers) }}, {name: "scene descriptions normalizer", register: func() error { return scenedescriptionnormalize.Register(registries.Normalizers) }}, {name: "spell-list noop normalizer", register: func() error { @@ -69,6 +78,9 @@ func registerModules(registries pipeline.Registries) error { {name: "combat-turn-list noop normalizer", register: func() error { return noop.RegisterTyped[dnd.CombatTurnList](registries.Normalizers, dnd.CombatTurnListKind) }}, + {name: "item-event-list noop normalizer", register: func() error { + return noop.RegisterTyped[dnd.ItemEventList](registries.Normalizers, dnd.ItemEventListKind) + }}, {name: "npc-interaction-list noop normalizer", register: func() error { return noop.RegisterTyped[dnd.NPCInteractionList](registries.Normalizers, dnd.NPCInteractionListKind) }}, @@ -84,6 +96,7 @@ func registerPromptAssets(assets *llm.AssetRegistry) error { {name: "spells prompt assets", register: func() error { return spellextract.RegisterPromptAssets(assets) }}, {name: "npcs prompt assets", register: func() error { return npcextract.RegisterPromptAssets(assets) }}, {name: "combat turns prompt assets", register: func() error { return combatextract.RegisterPromptAssets(assets) }}, + {name: "item events prompt assets", register: func() error { return itemeventextract.RegisterPromptAssets(assets) }}, {name: "npc interactions prompt assets", register: func() error { return interactionextract.RegisterPromptAssets(assets) }}, {name: "scene descriptions prompt assets", register: func() error { return scenedescriptionextract.RegisterPromptAssets(assets) }}, }) diff --git a/internal/modules/dnd/register/register_test.go b/internal/modules/dnd/register/register_test.go index 733f3ae..33dfd8c 100644 --- a/internal/modules/dnd/register/register_test.go +++ b/internal/modules/dnd/register/register_test.go @@ -13,11 +13,13 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns" + itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents" interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions" npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs" scenedescriptionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns" + itemeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents" interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions" npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs" scenedescriptionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/scenedescriptions" @@ -31,13 +33,14 @@ func TestRegisterAddsDNDFamily(t *testing.T) { t.Fatalf("Register() error = %v, want nil", err) } assertContainsKeys(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes"}) - assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", npcextract.Key, combatextract.Key, interactionextract.Key, scenedescriptionextract.Key}) - assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, npcnormalize.Key, combatnormalize.Key, interactionnormalize.Key, scenedescriptionnormalize.Key, pipeline.DefaultNormalizeModule}) - assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind}) - assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind}) - assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind}) + assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", npcextract.Key, combatextract.Key, itemeventextract.Key, interactionextract.Key, scenedescriptionextract.Key}) + assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, npcnormalize.Key, combatnormalize.Key, itemeventnormalize.Key, interactionnormalize.Key, scenedescriptionnormalize.Key, pipeline.DefaultNormalizeModule}) + assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind}) + assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind}) + assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(npcnormalize.Key), []contracts.ArtifactKind{dnd.NPCListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(combatnormalize.Key), []contracts.ArtifactKind{dnd.CombatTurnListKind}) + assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(itemeventnormalize.Key), []contracts.ArtifactKind{dnd.ItemEventListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(interactionnormalize.Key), []contracts.ArtifactKind{dnd.NPCInteractionListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(scenedescriptionnormalize.Key), []contracts.ArtifactKind{dnd.SceneDescriptionListKind}) assertContainsKeys(t, "validators", registries.Validators.RegisteredKeys(), []string{ @@ -53,6 +56,10 @@ func TestRegisterAddsDNDFamily(t *testing.T) { "extract/dnd/combat-turns/source_refs", "extract/dnd/combat-turns/source_relatedness", "normalize/dnd/combat-turns/invariants", + "extract/dnd/item-events/shape", + "extract/dnd/item-events/source_refs", + "extract/dnd/item-events/source_relatedness", + "normalize/dnd/item-events/invariants", "extract/dnd/npc-interactions/shape", "extract/dnd/npc-interactions/registry", "extract/dnd/npc-interactions/source_refs", @@ -121,6 +128,27 @@ func TestRegisterAddsDNDFamily(t *testing.T) { if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, combatnormalize.Key); !reflect.DeepEqual(got, combatNormalizeChain) { t.Fatalf("combat normalize validator chain = %#v, want %#v", got, combatNormalizeChain) } + itemEventExtractChain := []pipeline.ModuleBinding{ + pipeline.Binding("generic/valid_json"), + pipeline.Binding("extract/dnd/item-events/shape"), + pipeline.Binding("extract/dnd/item-events/source_refs"), + pipeline.Binding("generic/valid_json_schema"), + pipeline.Binding("extract/dnd/item-events/source_relatedness"), + } + itemEventNormalizeChain := []pipeline.ModuleBinding{ + pipeline.Binding("generic/valid_json"), + pipeline.Binding("extract/dnd/item-events/shape"), + pipeline.Binding("normalize/dnd/item-events/invariants"), + pipeline.Binding("extract/dnd/item-events/source_refs"), + pipeline.Binding("generic/valid_json_schema"), + pipeline.Binding("extract/dnd/item-events/source_relatedness"), + } + if got := registries.ValidatorChains.Validators(pipeline.StageExtract, itemeventextract.Key); !reflect.DeepEqual(got, itemEventExtractChain) { + t.Fatalf("item event extract validator chain = %#v, want %#v", got, itemEventExtractChain) + } + if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, itemeventnormalize.Key); !reflect.DeepEqual(got, itemEventNormalizeChain) { + t.Fatalf("item event normalize validator chain = %#v, want %#v", got, itemEventNormalizeChain) + } interactionExtractChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/npc-interactions/shape"), @@ -196,6 +224,14 @@ func TestRegisterAddsDNDFamily(t *testing.T) { "dnd.combat_turns/sharedassets/common-dnd-system.md", "dnd.combat_turns/sharedassets/common-dnd-transcript.md", "dnd.combat_turns/task.md", + "dnd.item_events/dnd.item_events.yaml", + "dnd.item_events/instructions.md", + "dnd.item_events/sharedassets/common-dnd-extraction-evidence.md", + "dnd.item_events/sharedassets/common-dnd-identity.md", + "dnd.item_events/sharedassets/common-dnd-references.md", + "dnd.item_events/sharedassets/common-dnd-system.md", + "dnd.item_events/sharedassets/common-dnd-transcript.md", + "dnd.item_events/task.md", "dnd.npc_interactions/dnd.npc_interactions.yaml", "dnd.npc_interactions/instructions.md", "dnd.npc_interactions/sharedassets/common-dnd-npcs.md", @@ -213,6 +249,7 @@ func TestRegisterAddsDNDFamily(t *testing.T) { "dnd_spells_llm.v1.json", "dnd_npcs_llm.v1.json", "dnd_combat_turns_llm.v1.json", + "dnd_item_events_llm.v1.json", "dnd_npc_interactions_llm.v1.json", "dnd_scene_descriptions_llm.v1.json", }) @@ -237,6 +274,16 @@ func TestRegisterAddsDNDFamily(t *testing.T) { if spec, ok := registries.Normalizers.Spec(combatnormalize.Key); !ok || spec.ArtifactKind != dnd.CombatTurnListKind || spec.Stage != pipeline.StageNormalize { t.Fatalf("combat normalizer spec = %#v, present = %t; want dnd combat-turn-list artifact", spec, ok) } + itemEventExtractSpec, itemEventExtractOK := registries.Extractors.Spec(itemeventextract.Key) + itemEventNormalizeSpec, itemEventNormalizeOK := registries.Normalizers.Spec(itemeventnormalize.Key) + if !itemEventExtractOK || itemEventExtractSpec.ArtifactKind != dnd.ItemEventListKind || !itemEventNormalizeOK || itemEventNormalizeSpec.ArtifactKind != dnd.ItemEventListKind || itemEventNormalizeSpec.Stage != pipeline.StageNormalize || len(itemEventNormalizeSpec.ReferenceSlots) != 0 { + t.Fatalf("item event specs = %#v / %#v, present = %t / %t", itemEventExtractSpec, itemEventNormalizeSpec, itemEventExtractOK, itemEventNormalizeOK) + } + for _, slot := range itemEventExtractSpec.ReferenceSlots { + if slot.Required || len(slot.AcceptedArtifactKinds) != 0 { + t.Fatalf("item event extractor has a generated-reference dependency: %#v", slot) + } + } interactionExtractSpec, extractOK := registries.Extractors.Spec(interactionextract.Key) interactionNormalizeSpec, normalizeOK := registries.Normalizers.Spec(interactionnormalize.Key) if !extractOK || interactionExtractSpec.ArtifactKind != dnd.NPCInteractionListKind || !normalizeOK || interactionNormalizeSpec.ArtifactKind != dnd.NPCInteractionListKind || interactionNormalizeSpec.Stage != pipeline.StageNormalize { @@ -409,6 +456,32 @@ func TestAppendCombatTurnListsPreservesOrderPresenceAndOwnership(t *testing.T) { } } +func TestAppendItemEventListsPreservesOrderPresenceAndOwnership(t *testing.T) { + quantity := 3 + refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}} + input := []dnd.ItemEventList{ + {}, + {Events: []dnd.ItemEvent{}}, + {Events: []dnd.ItemEvent{{Name: "first", Kind: dnd.ItemEventKindDiscovered, Quantity: &quantity, SourceRefs: refs}}}, + {Events: []dnd.ItemEvent{{Name: "second", Kind: dnd.ItemEventKindAcquired, To: "party", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}}}}, + } + got, err := appendItemEventLists(input) + if err != nil || got.Events == nil || !reflect.DeepEqual([]string{got.Events[0].Name, got.Events[1].Name}, []string{"first", "second"}) { + t.Fatalf("appendItemEventLists() = %#v, error = %v", got, err) + } + *got.Events[0].Quantity = 99 + got.Events[0].SourceRefs[0].StartUnitID = 999 + if quantity != 3 || input[2].Events[0].SourceRefs[0].StartUnitID != 1 { + t.Fatal("merged item events share quantity or source-reference storage") + } + for _, values := range [][]dnd.ItemEventList{nil, []dnd.ItemEventList{{}, {}}} { + result, err := appendItemEventLists(values) + if err != nil || result.Events != nil { + t.Fatalf("nil-only merge = %#v, %v; want nil events", result, err) + } + } +} + func TestAppendListsPreserveNestedSourceReferencePresence(t *testing.T) { spells, err := appendSpellLists([]dnd.SpellList{{SpellCasts: []dnd.SpellCast{{SourceRefs: []source.SourceRef{}}}}}) if err != nil || spells.SpellCasts[0].SourceRefs == nil { @@ -429,6 +502,11 @@ func TestAppendListsPreserveNestedSourceReferencePresence(t *testing.T) { if err != nil || interactions.Interactions[0].SourceRefs == nil { t.Fatalf("appendNPCInteractionLists() = %#v, %v; want present-empty source refs", interactions, err) } + + events, err := appendItemEventLists([]dnd.ItemEventList{{Events: []dnd.ItemEvent{{SourceRefs: []source.SourceRef{}}}}}) + if err != nil || events.Events[0].SourceRefs == nil { + t.Fatalf("appendItemEventLists() = %#v, %v; want present-empty source refs", events, err) + } } func TestRegisterRejectsMissingDNDDependenciesBeforeMutation(t *testing.T) { diff --git a/internal/modules/dnd/register/validators.go b/internal/modules/dnd/register/validators.go index 2afc8fc..75c9de8 100644 --- a/internal/modules/dnd/register/validators.go +++ b/internal/modules/dnd/register/validators.go @@ -7,6 +7,10 @@ import ( combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape" combatsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_refs" combatrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_relatedness" + itemeventinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/invariants" + itemeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/shape" + itemeventrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/source_refs" + itemeventrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/source_relatedness" interactioninvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/invariants" interactionregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/registry" interactionshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/shape" @@ -42,6 +46,10 @@ func registerValidators(registries pipeline.Registries) error { {name: "combat source references validator", register: func() error { return combatsourcerefs.Register(registries.Validators) }}, {name: "combat source relatedness validator", register: func() error { return combatrelatedness.Register(registries.Validators) }}, {name: "combat normalized invariants validator", register: func() error { return combatinvariants.Register(registries.Validators) }}, + {name: "item event shape validator", register: func() error { return itemeventshape.Register(registries.Validators) }}, + {name: "item event source references validator", register: func() error { return itemeventrefs.Register(registries.Validators) }}, + {name: "item event source relatedness validator", register: func() error { return itemeventrelatedness.Register(registries.Validators) }}, + {name: "item event normalized invariants validator", register: func() error { return itemeventinvariants.Register(registries.Validators) }}, {name: "npc interaction shape validator", register: func() error { return interactionshape.Register(registries.Validators) }}, {name: "npc interaction registry validator", register: func() error { return interactionregistry.Register(registries.Validators) }}, {name: "npc interaction source references validator", register: func() error { return interactionrefs.Register(registries.Validators) }}, @@ -69,6 +77,12 @@ func registerValidators(registries pipeline.Registries) error { {name: "combat-turn-list always reject validator", register: func() error { return alwaysreject.RegisterTyped[dnd.CombatTurnList](registries.Validators, dnd.CombatTurnListKind) }}, + {name: "item-event-list always accept validator", register: func() error { + return alwaysaccept.RegisterTyped[dnd.ItemEventList](registries.Validators, dnd.ItemEventListKind) + }}, + {name: "item-event-list always reject validator", register: func() error { + return alwaysreject.RegisterTyped[dnd.ItemEventList](registries.Validators, dnd.ItemEventListKind) + }}, {name: "npc-interaction-list always accept validator", register: func() error { return alwaysaccept.RegisterTyped[dnd.NPCInteractionList](registries.Validators, dnd.NPCInteractionListKind) }}, diff --git a/internal/modules/dnd/validate/itemevents/source_relatedness/validator.go b/internal/modules/dnd/validate/itemevents/source_relatedness/validator.go new file mode 100644 index 0000000..dbe0ad3 --- /dev/null +++ b/internal/modules/dnd/validate/itemevents/source_relatedness/validator.go @@ -0,0 +1,88 @@ +// Package sourcerelatedness warns when item-event evidence does not mention its item. +package sourcerelatedness + +import ( + "context" + "fmt" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" + itemeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/shape" +) + +const ( + Key = "extract/dnd/item-events/source_relatedness" + WarningReasonCode = "item_event_source_unrelated" + OmittedReasonCode = "item_event_relatedness_warnings_omitted" + policy = "dnd.item_events.source_relatedness.v1" +) + +type Options struct{} +type Validator struct{} + +var _ contracts.TypedValidator[dnd.ItemEventList] = (*Validator)(nil) +var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) + +func New(Options) *Validator { return &Validator{} } +func (v *Validator) Name() string { return Key } +func (v *Validator) ExecutionClass() contracts.ExecutionClass { + return contracts.ExecutionClassDeterministic +} +func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint { + return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}} +} + +// Validate performs advisory source grounding only. Shape and range failures +// are intentionally left to their blocking owners. +func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemEventList]) (contracts.ValidationResult, error) { + if itemeventshape.Validate(req.Value) != nil { + return contracts.ValidationResult{Approved: true}, nil + } + resolver, err := shared.NewCitationResolver(req.Source) + if err != nil { + return contracts.ValidationResult{Approved: true}, nil + } + + warnings := make([]contracts.Warning, 0) + for index, event := range req.Value.Events { + citedText, err := resolver.CitedText(event.SourceRefs) + if err != nil || shared.ContainsTokenSequence(citedText, event.Name) { + continue + } + warnings = append(warnings, contracts.Warning{ + Scope: fmt.Sprintf("events[%d]", index), + ReasonCode: WarningReasonCode, + Message: fmt.Sprintf("item event name %s was not found in cited source text", diagnostics.Quote(event.Name)), + }) + } + return contracts.ValidationResult{ + Approved: true, + Warnings: diagnostics.LimitWarnings(warnings, "item_events", OmittedReasonCode), + }, nil +} + +func Spec() pipeline.ValidatorSpec { + return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} +} + +func Register(registry *pipeline.ValidatorRegistry) error { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemEventList], error) { + options, err := DecodeOptions(request.Options) + if err != nil { + return nil, err + } + return New(options), nil + }) +} + +func DecodeOptions(options map[string]any) (Options, error) { + if err := pipeline.RejectUnknownOptions(options); err != nil { + return Options{}, err + } + return Options{}, nil +} + +func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err } diff --git a/internal/modules/dnd/validate/itemevents/source_relatedness/validator_test.go b/internal/modules/dnd/validate/itemevents/source_relatedness/validator_test.go new file mode 100644 index 0000000..7819dc2 --- /dev/null +++ b/internal/modules/dnd/validate/itemevents/source_relatedness/validator_test.go @@ -0,0 +1,74 @@ +package sourcerelatedness + +import ( + "context" + "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" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" +) + +func TestValidatorMatchesTokenSequencesAcrossRanges(t *testing.T) { + doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{ + {ID: 1, Text: "Aria finds a potion,"}, + {ID: 2, Text: "of healing in the chest."}, + {ID: 3, Text: "The group rests."}, + }} + value := dnd.ItemEventList{Events: []dnd.ItemEvent{ + {Name: "POTION OF HEALING", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}, {SourceID: doc.ID, StartUnitID: 2, EndUnitID: 2}}}, + {Name: "missing-item", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 3, EndUnitID: 3}}}, + }} + references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ + "glossary": {Items: []contracts.ReferenceItem{{Content: []byte("missing-item")}}}, + }} + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: doc, References: references, Value: value}) + if err != nil || !result.Approved || len(result.Warnings) != 1 { + t.Fatalf("Validate() = %#v, %v", result, err) + } + if warning := result.Warnings[0]; warning.Scope != "events[1]" || warning.ReasonCode != WarningReasonCode || !strings.Contains(warning.Message, "missing-item") { + t.Fatalf("warning = %#v", warning) + } +} + +func TestValidatorDefersMalformedAndUnreadableEvidence(t *testing.T) { + doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "a potion"}}} + for _, value := range []dnd.ItemEventList{ + {Events: []dnd.ItemEvent{{Name: "", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}}}}, + {Events: []dnd.ItemEvent{{Name: "potion", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 99, EndUnitID: 99}}}}}, + } { + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: doc, Value: value}) + if err != nil || !result.Approved || len(result.Warnings) != 0 { + t.Fatalf("deferred result = %#v, %v", result, err) + } + } +} + +func TestValidatorBoundsWarningsAndRegistersPolicy(t *testing.T) { + count := diagnostics.MaxWarnings + 5 + events := make([]dnd.ItemEvent, count) + for index := range events { + events[index] = dnd.ItemEvent{Name: "missing item", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}} + } + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{ + Source: &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "nothing useful"}}}, + Value: dnd.ItemEventList{Events: events}, + }) + if err != nil || !result.Approved || len(result.Warnings) != diagnostics.MaxWarnings || result.Warnings[len(result.Warnings)-1].ReasonCode != OmittedReasonCode { + t.Fatalf("Validate() = %#v, %v", result, err) + } + if got := New(Options{}).CheckpointFingerprints(); !reflect.DeepEqual(got, []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}) { + t.Fatalf("CheckpointFingerprints() = %#v", got) + } + registry := pipeline.NewValidatorRegistry() + if err := Register(registry); err != nil { + t.Fatalf("Register() error = %v", err) + } + if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil { + t.Fatal("DecodeOptions() accepted unknown option") + } +}