From 6de470d541d6041f570a71f66e12853c780f0731 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 5 Aug 2026 20:07:31 +0000 Subject: [PATCH] Verify complete D&D entity handoffs --- examples/dnd-complete.config.yml | 14 +-- .../cli/dnd_enemy_events_contract_test.go | 109 ++++++++++++++---- internal/cli/dnd_occurrences_contract_test.go | 82 +++++++++++++ internal/cli/example_contract_test.go | 8 +- internal/cli/production_contract_test.go | 83 ++++++++++++- 5 files changed, 260 insertions(+), 36 deletions(-) diff --git a/examples/dnd-complete.config.yml b/examples/dnd-complete.config.yml index 7d989f0..5ddcf5a 100644 --- a/examples/dnd-complete.config.yml +++ b/examples/dnd-complete.config.yml @@ -37,9 +37,9 @@ pipelines: lanes: - item-occurrences - item-registry - - locations + - location-registry - location-occurrences - - npc_registry + - npc-registry - spells - combat-turns - npc-occurrences @@ -54,7 +54,7 @@ pipelines: retries: 2 merge: appendorder normalize: dnd/item-registry - npc_registry: + npc-registry: extract: module: dnd/npc-registry retries: 2 @@ -62,7 +62,7 @@ pipelines: normalize: module: dnd/npc-registry retries: 2 - locations: + location-registry: extract: module: dnd/location-registry retries: 2 @@ -83,11 +83,11 @@ pipelines: location_registry: artifact: step: describe-session - lane: locations + lane: location-registry npc_registry: artifact: step: describe-session - lane: npc_registry + lane: npc-registry scene_descriptions: artifact: step: describe-session @@ -138,7 +138,7 @@ pipelines: npc_registry: artifact: step: describe-session - lane: npc_registry + lane: npc-registry scene_descriptions: artifact: step: describe-session diff --git a/internal/cli/dnd_enemy_events_contract_test.go b/internal/cli/dnd_enemy_events_contract_test.go index 270938c..4567d96 100644 --- a/internal/cli/dnd_enemy_events_contract_test.go +++ b/internal/cli/dnd_enemy_events_contract_test.go @@ -20,8 +20,12 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes" + itemoccurrencecodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemoccurrences" + itemregistrycodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemregistry" locationoccurrencecodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/locationoccurrences" locationcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/locationregistry" + npcoccurrencecodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcoccurrences" + npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcregistry" combat "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns" enemyevents "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents" itemoccurrences "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemoccurrences" @@ -57,7 +61,7 @@ func TestProductionEnemyEventConfigurationResolvesGeneratedHandoffs(t *testing.T t.Fatalf("enemy event lane = %#v, want typed production composition", lane) } for slot, want := range map[string]struct{ step, lane string }{ - "npc_registry": {step: "describe-session", lane: "npc_registry"}, + "npc_registry": {step: "describe-session", lane: "npc-registry"}, "scene_descriptions": {step: "describe-session", lane: "scene-descriptions"}, "combat_turns": {step: "extract-events", lane: "combat-turns"}, "npc_occurrences": {step: "extract-events", lane: "npc-occurrences"}, @@ -67,7 +71,7 @@ func TestProductionEnemyEventConfigurationResolvesGeneratedHandoffs(t *testing.T t.Fatalf("enemy event %s reference = %#v, want generated %s/%s artifact", slot, binding, want.step, want.lane) } } - if binding, found := generatedReferenceBinding(lane.NormalizeReferences.Bindings, "npc_registry"); !found || binding.Artifact.Step != "describe-session" || binding.Artifact.Lane != "npc_registry" { + if binding, found := generatedReferenceBinding(lane.NormalizeReferences.Bindings, "npc_registry"); !found || binding.Artifact.Step != "describe-session" || binding.Artifact.Lane != "npc-registry" { t.Fatalf("enemy event normalizer NPC reference = %#v, want generated NPC artifact", binding) } @@ -97,7 +101,7 @@ func TestProductionEnemyEventConfigurationResolvesGeneratedHandoffs(t *testing.T } } -func TestMaintainedCompleteExampleProducesEnemyEventsThroughGeneratedHandoffs(t *testing.T) { +func TestMaintainedCompleteExamplePublishesRegistryBackedEntityOccurrences(t *testing.T) { t.Chdir(repositoryPath()) outputRoot := filepath.Join(t.TempDir(), "output") configPath := completeExampleConfigWithTemporaryCache(t) @@ -123,18 +127,17 @@ func TestMaintainedCompleteExampleProducesEnemyEventsThroughGeneratedHandoffs(t runRoot := filepath.Join(outputRoot, productionRunID) index := readProductionJSON[exampleOutputIndex](t, filepath.Join(runRoot, "index.json")) - var enemyOutput exampleOutputIndexEntry - var locationOutput, occurrenceOutput exampleOutputIndexEntry + outputs := make(map[string]exampleOutputIndexEntry) for _, entry := range index.OutputFiles { - switch entry.LaneID { - case "enemy-events": - enemyOutput = entry - case "locations": - locationOutput = entry - case "location-occurrences": - occurrenceOutput = entry - } + outputs[entry.LaneID] = entry } + enemyOutput := outputs["enemy-events"] + locationOutput := outputs["location-registry"] + locationOccurrenceOutput := outputs["location-occurrences"] + npcRegistryOutput := outputs["npc-registry"] + npcOccurrenceOutput := outputs["npc-occurrences"] + itemRegistryOutput := outputs["item-registry"] + itemOccurrenceOutput := outputs["item-occurrences"] if enemyOutput.File != "lanes/enemy-events.json" || enemyOutput.SchemaID != "notarius.dnd.enemy_events" || enemyOutput.SchemaVersion != "v1" { t.Fatalf("enemy event output = %#v, want typed enemy-event JSON", enemyOutput) } @@ -142,23 +145,51 @@ func TestMaintainedCompleteExampleProducesEnemyEventsThroughGeneratedHandoffs(t if len(value.Events) != 1 || value.Events[0].Name != "Kesh" || value.Events[0].Kind != dnd.EnemyEventKindFled || len(value.Events[0].SourceRefs) != 1 || value.Events[0].SourceRefs[0].SourceID != "session-ravenfall" || value.Events[0].SourceRefs[0].StartUnitID != 10 { t.Fatalf("enemy event artifact = %#v, want source-linked Kesh fleeing event", value) } - if locationOutput.File != "lanes/locations.json" || locationOutput.SchemaID != locationcodec.SchemaID || locationOutput.SchemaVersion != locationcodec.SchemaVersion { + if locationOutput.File != "lanes/location-registry.json" || locationOutput.SchemaID != locationcodec.SchemaID || locationOutput.SchemaVersion != locationcodec.SchemaVersion { t.Fatalf("location output = %#v, want typed location registry JSON", locationOutput) } locationsValue := readProductionJSON[dnd.LocationRegistry](t, filepath.Join(runRoot, locationOutput.File)) if len(locationsValue.Locations) != 2 || locationsValue.Locations[0].Name != "Moon Gate" || locationsValue.Locations[1].Name != "Moon Gate" || locationsValue.Locations[0].ID == locationsValue.Locations[1].ID { t.Fatalf("location registry = %#v, want distinct source-grounded identities for same-name locations", locationsValue) } - if occurrenceOutput.File != "lanes/location-occurrences.json" || occurrenceOutput.SchemaID != locationoccurrencecodec.SchemaID || occurrenceOutput.SchemaVersion != locationoccurrencecodec.SchemaVersion { - t.Fatalf("location occurrence output = %#v, want typed occurrence JSON", occurrenceOutput) + if locationOccurrenceOutput.File != "lanes/location-occurrences.json" || locationOccurrenceOutput.SchemaID != locationoccurrencecodec.SchemaID || locationOccurrenceOutput.SchemaVersion != locationoccurrencecodec.SchemaVersion { + t.Fatalf("location occurrence output = %#v, want typed occurrence JSON", locationOccurrenceOutput) } - occurrencesValue := readProductionJSON[dnd.LocationOccurrenceList](t, filepath.Join(runRoot, occurrenceOutput.File)) + occurrencesValue := readProductionJSON[dnd.LocationOccurrenceList](t, filepath.Join(runRoot, locationOccurrenceOutput.File)) if len(occurrencesValue.Occurrences) != 2 || occurrencesValue.Occurrences[0].LocationID == occurrencesValue.Occurrences[1].LocationID || occurrencesValue.Occurrences[0].Name != "Moon Gate" || occurrencesValue.Occurrences[1].Name != "Moon Gate" { t.Fatalf("location occurrences = %#v, want source-grounded references to distinct registry identities", occurrencesValue) } + if npcRegistryOutput.File != "lanes/npc-registry.json" || npcRegistryOutput.SchemaID != npccodec.SchemaID || npcRegistryOutput.SchemaVersion != npccodec.SchemaVersion { + t.Fatalf("NPC registry output = %#v, want typed registry JSON", npcRegistryOutput) + } + npcRegistry := readProductionJSON[dnd.NPCRegistry](t, filepath.Join(runRoot, npcRegistryOutput.File)) + if len(npcRegistry.NPCs) != 1 || npcRegistry.NPCs[0].Name != "Kesh" { + t.Fatalf("NPC registry = %#v, want Kesh identity", npcRegistry) + } + if npcOccurrenceOutput.File != "lanes/npc-occurrences.json" || npcOccurrenceOutput.SchemaID != npcoccurrencecodec.SchemaID || npcOccurrenceOutput.SchemaVersion != npcoccurrencecodec.SchemaVersion { + t.Fatalf("NPC occurrence output = %#v, want typed occurrence JSON", npcOccurrenceOutput) + } + npcOccurrences := readProductionJSON[dnd.NPCOccurrenceList](t, filepath.Join(runRoot, npcOccurrenceOutput.File)) + if len(npcOccurrences.Occurrences) != 1 || npcOccurrences.Occurrences[0].NPCID != npcRegistry.NPCs[0].ID || npcOccurrences.Occurrences[0].Name != "Kesh" || len(npcOccurrences.Occurrences[0].SourceRefs) != 1 || npcOccurrences.Occurrences[0].SourceRefs[0].StartUnitID != 7 { + t.Fatalf("NPC occurrences = %#v, want independently evidenced Kesh registry grounding", npcOccurrences) + } + if itemRegistryOutput.File != "lanes/item-registry.json" || itemRegistryOutput.SchemaID != itemregistrycodec.SchemaID || itemRegistryOutput.SchemaVersion != itemregistrycodec.SchemaVersion { + t.Fatalf("item registry output = %#v, want typed registry JSON", itemRegistryOutput) + } + itemRegistry := readProductionJSON[dnd.ItemRegistry](t, filepath.Join(runRoot, itemRegistryOutput.File)) + if len(itemRegistry.Items) != 1 || itemRegistry.Items[0].Name != "Moonblade" { + t.Fatalf("item registry = %#v, want Moonblade identity", itemRegistry) + } + if itemOccurrenceOutput.File != "lanes/item-occurrences.json" || itemOccurrenceOutput.SchemaID != itemoccurrencecodec.SchemaID || itemOccurrenceOutput.SchemaVersion != itemoccurrencecodec.SchemaVersion { + t.Fatalf("item occurrence output = %#v, want typed occurrence JSON", itemOccurrenceOutput) + } + itemOccurrences := readProductionJSON[dnd.ItemOccurrenceList](t, filepath.Join(runRoot, itemOccurrenceOutput.File)) + if len(itemOccurrences.Occurrences) != 1 || itemOccurrences.Occurrences[0].ItemID != itemRegistry.Items[0].ID || itemOccurrences.Occurrences[0].Name != "Moonblade" || len(itemOccurrences.Occurrences[0].SourceRefs) != 1 || itemOccurrences.Occurrences[0].SourceRefs[0].StartUnitID != 5 { + t.Fatalf("item occurrences = %#v, want independently evidenced Moonblade registry grounding", itemOccurrences) + } evidence := readProductionJSON[evidencecontext.Document](t, filepath.Join(runRoot, "evidence-context.json")) - for _, laneID := range []string{"enemy-events", "locations", "location-occurrences"} { + for _, laneID := range []string{"enemy-events", "npc-registry", "npc-occurrences", "item-registry", "item-occurrences", "location-registry", "location-occurrences"} { if !containsString(evidence.SelectedLanes, laneID) || !evidenceHasLane(evidence, laneID) { t.Fatalf("evidence context = %#v, want direct %s evidence", evidence, laneID) } @@ -192,6 +223,25 @@ func TestMaintainedCompleteExampleProducesEnemyEventsThroughGeneratedHandoffs(t t.Fatalf("location occurrence registry input = %q, want source-free ID grounding", registryInput.Content) } } + for _, test := range []struct { + promptID string + slot string + name string + }{ + {promptID: npcoccurrences.PromptID, slot: "npc_registry", name: "Kesh"}, + {promptID: itemoccurrences.PromptID, slot: "item_registry", name: "Moonblade"}, + } { + requests := client.requestsFor(test.promptID) + if len(requests) != 2 { + t.Fatalf("%s requests = %#v, want one request per scene", test.promptID, requests) + } + for _, request := range requests { + registryInput := request.Inputs[test.slot] + if !strings.Contains(string(registryInput.Content), test.name) || !strings.Contains(string(registryInput.Content), `"id"`) || strings.Contains(string(registryInput.Content), "source_refs") { + t.Fatalf("%s registry input = %q, want source-free ID grounding", test.promptID, registryInput.Content) + } + } + } } func completeExampleConfigWithTemporaryCache(t *testing.T) string { @@ -256,11 +306,30 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque case spells.PromptID: content = []byte(`{"spell_casts":[]}`) case itemregistry.PromptID: - content = []byte(`{"items":[]}`) + if combatScene { + content = []byte(`{"items":[]}`) + } else { + content = []byte(`{"items":[{"name":"Moonblade","source_refs":[{"start_unit_id":5,"end_unit_id":5}]}]}`) + } case itemregistrynormalize.PromptID: content = []byte(`{"duplicate_groups":[]}`) case itemoccurrences.PromptID: - content = []byte(`{"occurrences":[]}`) + if combatScene { + content = []byte(`{"occurrences":[]}`) + } else { + var registry struct { + Items []struct { + ID string `json:"id"` + } `json:"items"` + } + if err := json.Unmarshal(request.Inputs["item_registry"].Content, ®istry); err != nil { + return contracts.StructuredCompletionResponse{}, fmt.Errorf("decode generated item registry: %w", err) + } + if len(registry.Items) != 1 { + return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated item registry has %d items, want 1", len(registry.Items)) + } + content = []byte(fmt.Sprintf(`{"occurrences":[{"item_id":%q,"name":"Moonblade","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_segment":5,"end_segment":5}]}]}`, registry.Items[0].ID)) + } case combat.PromptID: content = []byte(`{"combat_turns":[{"actor":"Kesh","turn_kind":"turn","source_refs":[{"start_unit_id":8,"end_unit_id":8}]}]}`) case npcoccurrences.PromptID: diff --git a/internal/cli/dnd_occurrences_contract_test.go b/internal/cli/dnd_occurrences_contract_test.go index 95ede60..59d0c5e 100644 --- a/internal/cli/dnd_occurrences_contract_test.go +++ b/internal/cli/dnd_occurrences_contract_test.go @@ -6,13 +6,24 @@ import ( "strings" "testing" + "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + itemregistrycodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemregistry" + locationregistrycodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/locationregistry" occurrencecodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcoccurrences" + npcregistrycodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcregistry" + itemoccurrenceextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemoccurrences" + locationoccurrenceextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/locationoccurrences" occurrenceextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcoccurrences" npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcregistry" + itemidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity" + locationidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity" + itemoccurrencenormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemoccurrences" + locationoccurrencenormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/locationoccurrences" occurrencenormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcoccurrences" npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcregistry" + npcidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" ) func TestProductionNPCOccurrencePipelineResolvesAndPrepares(t *testing.T) { @@ -116,6 +127,77 @@ func TestProductionNPCOccurrenceReferencesRejectIncompatibleExternalRegistries(t } } +func TestProductionOccurrencePipelinesAcceptCompatibleExternalRegistries(t *testing.T) { + components := productionTestComponents(t) + catalog := catalogFromRegistries(components.registries) + reference := source.SourceRef{SourceID: "external-registry", StartUnitID: 1, EndUnitID: 1} + npcContent, err := npcregistrycodec.New().Encode(dnd.NPCRegistry{NPCs: []dnd.NPC{{ + ID: npcidentity.DeriveID("Mira Thorn"), Name: "Mira Thorn", SourceRefs: []source.SourceRef{reference}, + }}}) + if err != nil { + t.Fatal(err) + } + locationContent, err := locationregistrycodec.New().Encode(dnd.LocationRegistry{Locations: []dnd.Location{{ + ID: locationidentity.DeriveID("Moon Gate", []source.SourceRef{reference}), Name: "Moon Gate", SourceRefs: []source.SourceRef{reference}, + }}}) + if err != nil { + t.Fatal(err) + } + itemContent, err := itemregistrycodec.New().Encode(dnd.ItemRegistry{Items: []dnd.Item{{ + ID: itemidentity.DeriveID("Moonblade"), Name: "Moonblade", SourceRefs: []source.SourceRef{reference}, + }}}) + if err != nil { + t.Fatal(err) + } + + root := t.TempDir() + for _, test := range []struct { + name string + slot string + extractor string + normalizer string + content []byte + }{ + {name: "NPC", slot: "npc_registry", extractor: occurrenceextract.Key, normalizer: occurrencenormalize.Key, content: npcContent}, + {name: "location", slot: "location_registry", extractor: locationoccurrenceextract.Key, normalizer: locationoccurrencenormalize.Key, content: locationContent}, + {name: "item", slot: "item_registry", extractor: itemoccurrenceextract.Key, normalizer: itemoccurrencenormalize.Key, content: itemContent}, + } { + t.Run(test.name, func(t *testing.T) { + path := filepath.Join(root, strings.ToLower(test.name)+"-registry.json") + if err := os.WriteFile(path, test.content, 0o600); err != nil { + t.Fatal(err) + } + resolved, err := pipeline.ResolvePipeline(externalOccurrenceProfile(test.slot, test.extractor, test.normalizer, pipeline.ExternalReference(path)), pipeline.ResolveOptions{}, catalog) + if err != nil { + t.Fatalf("ResolvePipeline() error = %v", err) + } + materialized, warnings, err := pipeline.MaterializeReferences(resolved, catalog, pipeline.ReferenceMaterializationOptions{}) + if err != nil || len(warnings) != 0 { + t.Fatalf("MaterializeReferences() error = %v warnings = %#v", err, warnings) + } + if _, err := pipeline.Prepare(materialized, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}}); err != nil { + t.Fatalf("Prepare() error = %v", err) + } + }) + } +} + +func externalOccurrenceProfile(slot, extractor, normalizer string, reference pipeline.ReferenceSource) pipeline.PipelineProfile { + return pipeline.PipelineProfile{ + ID: "external-registry-occurrences", + Input: pipeline.Binding("seriatim"), + Chunk: pipeline.ModuleBinding{Module: "generic", Options: map[string]any{"max_units": 1}}, + Output: pipeline.Binding("json"), + Steps: []pipeline.PipelineStepProfile{{ + ID: "occurrences", + References: map[string]pipeline.ReferenceSource{slot: reference}, + Artifacts: map[string]pipeline.ArtifactLaneProfile{ + "occurrences": {Extract: pipeline.Binding(extractor), Normalize: pipeline.Binding(normalizer)}, + }, + }}, + } +} + func npcOccurrenceProfile(reference pipeline.ReferenceSource) pipeline.PipelineProfile { profile := pipeline.PipelineProfile{ ID: "dnd-npc-occurrences", diff --git a/internal/cli/example_contract_test.go b/internal/cli/example_contract_test.go index b9ec044..b524bb0 100644 --- a/internal/cli/example_contract_test.go +++ b/internal/cli/example_contract_test.go @@ -58,10 +58,10 @@ func TestMaintainedExamplesLoadResolveAndList(t *testing.T) { t.Fatalf("materialize maintained example references for %q: %v", pipelineID, err) } if example.name == "complete" { - if got := exampleStepLaneIDs(materialized); strings.Join(got, "|") != "describe-session:item-registry,locations,npc_registry,scene-descriptions|extract-events:combat-turns,item-occurrences,location-occurrences,npc-occurrences,spells|track-enemies:enemy-events" { + if got := exampleStepLaneIDs(materialized); strings.Join(got, "|") != "describe-session:item-registry,location-registry,npc-registry,scene-descriptions|extract-events:combat-turns,item-occurrences,location-occurrences,npc-occurrences,spells|track-enemies:enemy-events" { t.Fatalf("complete example steps and lanes = %v, want the documented D&D extractor composition", got) } - locationLane := referenceContractLane(t, materialized, "locations") + locationLane := referenceContractLane(t, materialized, "location-registry") if locationLane.ArtifactKind != dnd.LocationRegistryKind || locationLane.Extract.Module != locationextract.Key || locationLane.Extract.Retries != 2 || locationLane.Merge.Module != pipeline.DefaultMergeModule || locationLane.Normalize.Module != locationnormalize.Key || locationLane.Normalize.Retries != 2 { t.Fatalf("location lane = %#v, want typed registry composition", locationLane) } @@ -71,7 +71,7 @@ func TestMaintainedExamplesLoadResolveAndList(t *testing.T) { } for _, target := range []pipeline.ResolvedReferenceTarget{occurrenceLane.ExtractReferences, occurrenceLane.NormalizeReferences} { binding, found := generatedReferenceBinding(target.Bindings, "location_registry") - if !found || binding.Artifact.Step != "describe-session" || binding.Artifact.Lane != "locations" { + if !found || binding.Artifact.Step != "describe-session" || binding.Artifact.Lane != "location-registry" { t.Fatalf("location occurrence %s reference = %#v, want generated location registry", target.Stage, binding) } } @@ -99,7 +99,7 @@ func TestMaintainedExamplesLoadResolveAndList(t *testing.T) { } enemyEventLane := referenceContractLane(t, materialized, "enemy-events") for slot, want := range map[string]struct{ step, lane string }{ - "npc_registry": {step: "describe-session", lane: "npc_registry"}, + "npc_registry": {step: "describe-session", lane: "npc-registry"}, "scene_descriptions": {step: "describe-session", lane: "scene-descriptions"}, "combat_turns": {step: "extract-events", lane: "combat-turns"}, "npc_occurrences": {step: "extract-events", lane: "npc-occurrences"}, diff --git a/internal/cli/production_contract_test.go b/internal/cli/production_contract_test.go index c35b2e6..129ea06 100644 --- a/internal/cli/production_contract_test.go +++ b/internal/cli/production_contract_test.go @@ -30,16 +30,32 @@ import ( combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns" enemyeventcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/enemyevents" itemoccurrencecodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemoccurrences" + itemregistrycodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemregistry" + locationoccurrencecodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/locationoccurrences" + locationcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/locationregistry" + npcoccurrencecodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcoccurrences" + npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcregistry" + scenecodec "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" enemyeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents" itemoccurrenceextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemoccurrences" itemregistryextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemregistry" + locationoccurrenceextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/locationoccurrences" + locationextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/locationregistry" + npcoccurrenceextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcoccurrences" + npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcregistry" + sceneextract "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" enemyeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/enemyevents" itemoccurrencenormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemoccurrences" itemregistrynormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemregistry" + locationoccurrencenormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/locationoccurrences" + locationnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/locationregistry" + npcoccurrencenormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcoccurrences" + npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcregistry" + scenenormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/scenedescriptions" spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells" "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop" "gitea.maximumdirect.net/eric/promptkit" @@ -51,9 +67,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/npc-registry", combatextract.Key, itemoccurrenceextract.Key, enemyeventextract.Key}) + assertProductionContains(t, "extractors", registries.Extractors.RegisteredKeys(), []string{spells.Key, npcextract.Key, combatextract.Key, itemoccurrenceextract.Key, itemregistryextract.Key, npcoccurrenceextract.Key, sceneextract.Key, locationextract.Key, locationoccurrenceextract.Key, enemyeventextract.Key}) assertProductionContains(t, "mergers", registries.Mergers.RegisteredKeys(), []string{"appendorder"}) - assertProductionContains(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{"noop", spellnormalize.Key, "dnd/npc-registry", combatnormalize.Key, itemoccurrencenormalize.Key, enemyeventnormalize.Key}) + assertProductionContains(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{pipeline.DefaultNormalizeModule, spellnormalize.Key, npcnormalize.Key, combatnormalize.Key, itemoccurrencenormalize.Key, itemregistrynormalize.Key, npcoccurrencenormalize.Key, scenenormalize.Key, locationnormalize.Key, locationoccurrencenormalize.Key, enemyeventnormalize.Key}) assertProductionContains(t, "outputs", registries.Outputs.RegisteredKeys(), []string{"json"}) assertProductionContains(t, "validators", registries.Validators.RegisteredKeys(), []string{ "extract/dnd/spells/catalog", @@ -65,9 +81,36 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) { "extract/dnd/combat-turns/source_relatedness", "normalize/dnd/combat-turns/invariants", "extract/dnd/item-occurrences/shape", + "extract/dnd/item-occurrences/registry", "extract/dnd/item-occurrences/source_refs", "extract/dnd/item-occurrences/source_relatedness", "normalize/dnd/item-occurrences/invariants", + "extract/dnd/item-registry/shape", + "extract/dnd/item-registry/source_refs", + "extract/dnd/item-registry/source_relatedness", + "normalize/dnd/item-registry/identity", + "extract/dnd/npc-registry/shape", + "extract/dnd/npc-registry/source_refs", + "extract/dnd/npc-registry/source_relatedness", + "normalize/dnd/npc-registry/identity", + "extract/dnd/npc-occurrences/shape", + "extract/dnd/npc-occurrences/registry", + "extract/dnd/npc-occurrences/source_refs", + "extract/dnd/npc-occurrences/source_relatedness", + "normalize/dnd/npc-occurrences/invariants", + "extract/dnd/scene-descriptions/shape", + "extract/dnd/scene-descriptions/source_refs", + "extract/dnd/scene-descriptions/source_relatedness", + "normalize/dnd/scene-descriptions/invariants", + "extract/dnd/location-registry/shape", + "extract/dnd/location-registry/source_refs", + "extract/dnd/location-registry/source_relatedness", + "normalize/dnd/location-registry/identity", + "extract/dnd/location-occurrences/shape", + "extract/dnd/location-occurrences/registry", + "extract/dnd/location-occurrences/source_refs", + "extract/dnd/location-occurrences/source_relatedness", + "normalize/dnd/location-occurrences/invariants", "extract/dnd/enemy-events/shape", "extract/dnd/enemy-events/engagements", "extract/dnd/enemy-events/source_refs", @@ -78,12 +121,19 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) { "generic/valid_json", "generic/valid_json_schema", }) - assertProductionContains(t, "artifact codec kinds", registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.ItemOccurrenceListKind, dnd.EnemyEventListKind}) - assertProductionContains(t, "merger variants", registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.ItemOccurrenceListKind, dnd.EnemyEventListKind}) - assertProductionContains(t, "normalizer variants", registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.ItemOccurrenceListKind, dnd.EnemyEventListKind}) + allArtifactKinds := []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.NPCOccurrenceListKind, dnd.CombatTurnListKind, dnd.SceneDescriptionListKind, dnd.LocationRegistryKind, dnd.LocationOccurrenceListKind, dnd.ItemRegistryKind, dnd.ItemOccurrenceListKind, dnd.EnemyEventListKind} + assertProductionContains(t, "artifact codec kinds", registries.ArtifactCodecs.RegisteredKinds(), allArtifactKinds) + assertProductionContains(t, "merger variants", registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), allArtifactKinds) + assertProductionContains(t, "normalizer variants", registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), allArtifactKinds) 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 occurrence normalizer variants", registries.Normalizers.RegisteredArtifactKinds(itemoccurrencenormalize.Key), []contracts.ArtifactKind{dnd.ItemOccurrenceListKind}) + assertProductionContains(t, "item registry normalizer variants", registries.Normalizers.RegisteredArtifactKinds(itemregistrynormalize.Key), []contracts.ArtifactKind{dnd.ItemRegistryKind}) + assertProductionContains(t, "NPC registry normalizer variants", registries.Normalizers.RegisteredArtifactKinds(npcnormalize.Key), []contracts.ArtifactKind{dnd.NPCRegistryKind}) + assertProductionContains(t, "NPC occurrence normalizer variants", registries.Normalizers.RegisteredArtifactKinds(npcoccurrencenormalize.Key), []contracts.ArtifactKind{dnd.NPCOccurrenceListKind}) + assertProductionContains(t, "scene description normalizer variants", registries.Normalizers.RegisteredArtifactKinds(scenenormalize.Key), []contracts.ArtifactKind{dnd.SceneDescriptionListKind}) + assertProductionContains(t, "location registry normalizer variants", registries.Normalizers.RegisteredArtifactKinds(locationnormalize.Key), []contracts.ArtifactKind{dnd.LocationRegistryKind}) + assertProductionContains(t, "location occurrence normalizer variants", registries.Normalizers.RegisteredArtifactKinds(locationoccurrencenormalize.Key), []contracts.ArtifactKind{dnd.LocationOccurrenceListKind}) assertProductionContains(t, "enemy event normalizer variants", registries.Normalizers.RegisteredArtifactKinds(enemyeventnormalize.Key), []contracts.ArtifactKind{dnd.EnemyEventListKind}) wantChain := []pipeline.ModuleBinding{ @@ -167,18 +217,24 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) { {stage: pipeline.StageChunk, key: "dnd/scenes", want: contracts.ExecutionClassLLMBacked}, {stage: pipeline.StageExtract, key: "dnd/spells", want: contracts.ExecutionClassLLMBacked}, {stage: pipeline.StageExtract, key: "dnd/npc-registry", want: contracts.ExecutionClassLLMBacked}, + {stage: pipeline.StageExtract, key: "dnd/location-registry", want: contracts.ExecutionClassLLMBacked}, + {stage: pipeline.StageExtract, key: "dnd/item-registry", want: contracts.ExecutionClassLLMBacked}, {stage: pipeline.StageExtract, key: "dnd/combat-turns", want: contracts.ExecutionClassLLMBacked}, {stage: pipeline.StageExtract, key: "dnd/item-occurrences", want: contracts.ExecutionClassLLMBacked}, {stage: pipeline.StageExtract, key: "dnd/npc-occurrences", want: contracts.ExecutionClassLLMBacked}, + {stage: pipeline.StageExtract, key: "dnd/location-occurrences", want: contracts.ExecutionClassLLMBacked}, {stage: pipeline.StageExtract, key: "dnd/scene-descriptions", want: contracts.ExecutionClassLLMBacked}, {stage: pipeline.StageExtract, key: enemyeventextract.Key, want: contracts.ExecutionClassLLMBacked}, {stage: pipeline.StageMerge, key: "appendorder", want: contracts.ExecutionClassDeterministic}, {stage: pipeline.StageNormalize, key: "noop", want: contracts.ExecutionClassDeterministic}, {stage: pipeline.StageNormalize, key: "dnd/spells", want: contracts.ExecutionClassDeterministic}, {stage: pipeline.StageNormalize, key: "dnd/npc-registry", want: contracts.ExecutionClassLLMBacked}, + {stage: pipeline.StageNormalize, key: "dnd/location-registry", want: contracts.ExecutionClassLLMBacked}, + {stage: pipeline.StageNormalize, key: "dnd/item-registry", want: contracts.ExecutionClassLLMBacked}, {stage: pipeline.StageNormalize, key: "dnd/combat-turns", want: contracts.ExecutionClassDeterministic}, {stage: pipeline.StageNormalize, key: "dnd/item-occurrences", want: contracts.ExecutionClassDeterministic}, {stage: pipeline.StageNormalize, key: "dnd/npc-occurrences", want: contracts.ExecutionClassDeterministic}, + {stage: pipeline.StageNormalize, key: "dnd/location-occurrences", want: contracts.ExecutionClassDeterministic}, {stage: pipeline.StageNormalize, key: "dnd/scene-descriptions", want: contracts.ExecutionClassDeterministic}, {stage: pipeline.StageNormalize, key: enemyeventnormalize.Key, want: contracts.ExecutionClassDeterministic}, {stage: pipeline.StageOutput, key: "json", want: contracts.ExecutionClassDeterministic}, @@ -204,6 +260,23 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) { if !ok || itemOccurrenceCodecSpec.Kind != dnd.ItemOccurrenceListKind || itemOccurrenceCodecSpec.Schema.ID != itemoccurrencecodec.SchemaID { t.Fatalf("item occurrence codec spec = %#v, ok=%t, want typed D&D item-occurrence codec", itemOccurrenceCodecSpec, ok) } + for _, test := range []struct { + name string + kind contracts.ArtifactKind + id string + }{ + {name: "NPC registry", kind: dnd.NPCRegistryKind, id: npccodec.SchemaID}, + {name: "NPC occurrences", kind: dnd.NPCOccurrenceListKind, id: npcoccurrencecodec.SchemaID}, + {name: "item registry", kind: dnd.ItemRegistryKind, id: itemregistrycodec.SchemaID}, + {name: "scene descriptions", kind: dnd.SceneDescriptionListKind, id: scenecodec.SchemaID}, + {name: "location registry", kind: dnd.LocationRegistryKind, id: locationcodec.SchemaID}, + {name: "location occurrences", kind: dnd.LocationOccurrenceListKind, id: locationoccurrencecodec.SchemaID}, + } { + spec, found := catalog.ArtifactCodecs.Spec(test.kind) + if !found || spec.Kind != test.kind || spec.Schema.ID != test.id { + t.Fatalf("%s codec spec = %#v, found=%t, want typed D&D codec", test.name, spec, found) + } + } enemyEventCodecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.EnemyEventListKind) if !ok || enemyEventCodecSpec.Kind != dnd.EnemyEventListKind || enemyEventCodecSpec.Schema.ID != enemyeventcodec.SchemaID { t.Fatalf("enemy event codec spec = %#v, ok=%t, want typed D&D enemy-event codec", enemyEventCodecSpec, ok)