Compose production D&D combat pipeline

This commit is contained in:
2026-07-21 05:42:33 +00:00
parent 50aa60e0b8
commit a1f5dce405
19 changed files with 963 additions and 62 deletions

View File

@@ -0,0 +1,160 @@
package cli
import (
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"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"
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
)
func TestProductionCombatConfigurationResolvesTypedLane(t *testing.T) {
components := productionTestComponents(t)
configPath := repositoryPath("examples", "dnd-combat-turns.config.yml")
cfg := loadMaintainedExample(t, configPath)
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-combat", Catalog: catalogFromRegistries(components.registries)})
if err != nil {
t.Fatalf("Resolve() error = %v, want nil", err)
}
if effective.ResolvedPipeline.Chunk.Module != pipeline.DefaultChunkModule {
t.Fatalf("chunk module = %q, want %q", effective.ResolvedPipeline.Chunk.Module, pipeline.DefaultChunkModule)
}
if len(effective.ResolvedPipeline.ArtifactLanes) != 1 {
t.Fatalf("artifact lanes = %#v, want one combat lane", effective.ResolvedPipeline.ArtifactLanes)
}
lane := effective.ResolvedPipeline.ArtifactLanes[0]
if lane.ID != "combat" || lane.ArtifactKind != dnd.CombatTurnListKind || lane.Extract.Module != combatextract.Key || lane.Extract.Retries != 2 || lane.Merge.Module != pipeline.DefaultMergeModule || lane.Normalize.Module != combatnormalize.Key {
t.Fatalf("resolved combat lane = %#v, want typed production composition", lane)
}
catalog := catalogFromRegistries(components.registries)
extractSpec, ok := catalog.Extractors.Spec(combatextract.Key)
if !ok || !reflect.DeepEqual(extractSpec.Requires, []string{"chunks", "source.transcript"}) || !reflect.DeepEqual(extractSpec.Provides, []string{"dnd.combat_turns"}) {
t.Fatalf("combat extractor spec = %#v, want source and artifact capabilities", extractSpec)
}
normalizeSpec, ok := catalog.Normalizers.SpecForArtifact(combatnormalize.Key, dnd.CombatTurnListKind)
if !ok || !reflect.DeepEqual(normalizeSpec.Requires, []string{"merged"}) || !reflect.DeepEqual(normalizeSpec.Provides, []string{"normalized"}) {
t.Fatalf("combat normalizer spec = %#v, want merged/normalized capabilities", normalizeSpec)
}
mergeSpec, ok := catalog.Mergers.SpecForArtifact(pipeline.DefaultMergeModule, dnd.CombatTurnListKind)
if !ok || !reflect.DeepEqual(mergeSpec.Provides, []string{"merged"}) {
t.Fatalf("combat merger spec = %#v, want merged capability", mergeSpec)
}
codecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.CombatTurnListKind)
if !ok || codecSpec.Schema.ID != "notarius.dnd.combat_turns" || codecSpec.Schema.Version != "v1" {
t.Fatalf("combat codec spec = %#v, want compatible durable schema", codecSpec)
}
if !hasReferenceSlot(extractSpec.ReferenceSlots, "npcs") || !hasReferenceSlot(normalizeSpec.ReferenceSlots, "npcs") {
t.Fatalf("combat reference slots = %#v / %#v, want stage-local NPC slots", extractSpec.ReferenceSlots, normalizeSpec.ReferenceSlots)
}
wantExtractChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/combat-turns/shape"),
pipeline.Binding("extract/dnd/combat-turns/source_refs"),
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
}
wantNormalizeChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/combat-turns/shape"),
pipeline.Binding("normalize/dnd/combat-turns/invariants"),
pipeline.Binding("extract/dnd/combat-turns/source_refs"),
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
}
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageExtract, combatextract.Key); !reflect.DeepEqual(got, wantExtractChain) {
t.Fatalf("combat extract chain = %#v, want %#v", got, wantExtractChain)
}
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageNormalize, combatnormalize.Key); !reflect.DeepEqual(got, wantNormalizeChain) {
t.Fatalf("combat normalize chain = %#v, want %#v", got, wantNormalizeChain)
}
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageMerge, pipeline.DefaultMergeModule); len(got) != 0 {
t.Fatalf("combat merge chain = %#v, want empty", got)
}
bound, err := cfg.Resolve(config.ResolveInput{
PipelineID: "dnd-combat",
Catalog: catalog,
ReferenceOverrides: []pipeline.ReferenceBinding{
{Stage: pipeline.StageExtract, LaneID: "combat", SlotName: "npcs", Source: "npc-run/lanes/npcs.json", BindingSource: contracts.ReferenceBindingSourceCLI},
{Stage: pipeline.StageNormalize, LaneID: "combat", SlotName: "npcs", Source: "npc-run/lanes/npcs.json", BindingSource: contracts.ReferenceBindingSourceCLI},
},
})
if err != nil {
t.Fatalf("Resolve(bound references) error = %v, want nil", err)
}
boundLane := bound.ResolvedPipeline.ArtifactLanes[0]
if len(boundLane.ExtractReferences.Bindings) != 1 || len(boundLane.NormalizeReferences.Bindings) != 1 || boundLane.ExtractReferences.Bindings[0].SlotName != "npcs" || boundLane.NormalizeReferences.Bindings[0].SlotName != "npcs" {
t.Fatalf("bound combat references = %#v / %#v, want one independent NPC binding per stage", boundLane.ExtractReferences, boundLane.NormalizeReferences)
}
}
func TestProductionCombatConfigurationRejectsLooseOptionsAndLaneValidators(t *testing.T) {
components := productionTestComponents(t)
configPath := repositoryPath("examples", "dnd-combat-turns.config.yml")
resolve := func(mutate func(*pipeline.PipelineProfile)) error {
cfg := loadMaintainedExample(t, configPath)
profile := cfg.Pipelines["dnd-combat"]
mutate(&profile)
cfg.Pipelines["dnd-combat"] = profile
_, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-combat", Catalog: catalogFromRegistries(components.registries)})
return err
}
if err := resolve(func(profile *pipeline.PipelineProfile) {
lane := profile.Artifacts["combat"]
lane.Extract.Options = map[string]any{"unexpected": true}
profile.Artifacts["combat"] = lane
}); err == nil || !strings.Contains(err.Error(), "unknown option") {
t.Fatalf("unknown extractor option error = %v, want strict option rejection", err)
}
if err := resolve(func(profile *pipeline.PipelineProfile) {
lane := profile.Artifacts["combat"]
lane.Normalize.Options = map[string]any{"unexpected": true}
profile.Artifacts["combat"] = lane
}); err == nil || !strings.Contains(err.Error(), "unknown option") {
t.Fatalf("unknown normalizer option error = %v, want strict option rejection", err)
}
if err := resolve(func(profile *pipeline.PipelineProfile) {
lane := profile.Artifacts["combat"]
lane.Validators = []pipeline.ModuleBinding{pipeline.Binding("generic/always_accept")}
profile.Artifacts["combat"] = lane
}); err == nil || !strings.Contains(err.Error(), "artifact lane level") {
t.Fatalf("lane-level validator error = %v, want invalid placement rejection", err)
}
}
func TestProductionCombatConfigurationResolvesTypedUnconditionalValidators(t *testing.T) {
components := productionTestComponents(t)
cfg := loadMaintainedExample(t, repositoryPath("examples", "dnd-combat-turns.config.yml"))
profile := cfg.Pipelines["dnd-combat"]
lane := profile.Artifacts["combat"]
lane.Extract.Validators = pipeline.ValidatorOverride{Set: true, Validators: []pipeline.ModuleBinding{pipeline.Binding("generic/always_accept")}}
lane.Normalize.Validators = pipeline.ValidatorOverride{Set: true, Validators: []pipeline.ModuleBinding{pipeline.Binding("generic/always_reject")}}
profile.Artifacts["combat"] = lane
cfg.Pipelines["dnd-combat"] = profile
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-combat", Catalog: catalogFromRegistries(components.registries)})
if err != nil {
t.Fatalf("Resolve() error = %v, want typed unconditional validators to resolve", err)
}
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageExtract, combatextract.Key); !reflect.DeepEqual(got, []pipeline.ModuleBinding{pipeline.Binding("generic/always_accept")}) {
t.Fatalf("extract override chain = %#v, want typed always-accept", got)
}
if got := validatorChain(effective.ResolvedPipeline, pipeline.StageNormalize, combatnormalize.Key); !reflect.DeepEqual(got, []pipeline.ModuleBinding{pipeline.Binding("generic/always_reject")}) {
t.Fatalf("normalize override chain = %#v, want typed always-reject", got)
}
}
func hasReferenceSlot(slots []contracts.ReferenceSlot, name string) bool {
for _, slot := range slots {
if slot.Name == name {
return true
}
}
return false
}

View File

@@ -22,8 +22,11 @@ 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"
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
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/spells"
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop"
)
@@ -34,24 +37,29 @@ 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"})
assertProductionContains(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", "dnd/npcs", combatextract.Key})
assertProductionContains(t, "mergers", registries.Mergers.RegisteredKeys(), []string{"appendorder"})
assertProductionContains(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{"noop", spellnormalize.Key})
assertProductionContains(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{"noop", spellnormalize.Key, "dnd/npcs", combatnormalize.Key})
assertProductionContains(t, "outputs", registries.Outputs.RegisteredKeys(), []string{"json"})
assertProductionContains(t, "validators", registries.Validators.RegisteredKeys(), []string{
"extract/dnd/spells/catalog",
"extract/dnd/spells/shape",
"extract/dnd/spells/source_refs",
"extract/dnd/spells/source_relatedness",
"extract/dnd/combat-turns/shape",
"extract/dnd/combat-turns/source_refs",
"extract/dnd/combat-turns/source_relatedness",
"normalize/dnd/combat-turns/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})
assertProductionContains(t, "merger variants", registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind})
assertProductionContains(t, "normalizer variants", registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind})
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, "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})
wantChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"),
@@ -67,6 +75,27 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, spellnormalize.Key); !reflect.DeepEqual(got, wantChain) {
t.Fatalf("spell normalize validator chain = %#v, want %#v", got, wantChain)
}
combatExtractChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/combat-turns/shape"),
pipeline.Binding("extract/dnd/combat-turns/source_refs"),
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
}
combatNormalizeChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/combat-turns/shape"),
pipeline.Binding("normalize/dnd/combat-turns/invariants"),
pipeline.Binding("extract/dnd/combat-turns/source_refs"),
pipeline.Binding("extract/dnd/combat-turns/source_relatedness"),
}
if got := registries.ValidatorChains.Validators(pipeline.StageExtract, combatextract.Key); !reflect.DeepEqual(got, combatExtractChain) {
t.Fatalf("combat extract validator chain = %#v, want %#v", got, combatExtractChain)
}
if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, combatnormalize.Key); !reflect.DeepEqual(got, combatNormalizeChain) {
t.Fatalf("combat normalize validator chain = %#v, want %#v", got, combatNormalizeChain)
}
assetNames := productionAssetNames(t, components.assets.PromptFS)
requiredAssets := []string{
@@ -83,6 +112,12 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
"dnd.spells/sharedassets/common-dnd-system.md",
"dnd.spells/sharedassets/common-dnd-transcript.md",
"dnd.spells/task.md",
"dnd.combat_turns/dnd.combat_turns.yaml",
"dnd.combat_turns/instructions.md",
"dnd.combat_turns/sharedassets/common-dnd-references.md",
"dnd.combat_turns/sharedassets/common-dnd-system.md",
"dnd.combat_turns/sharedassets/common-dnd-transcript.md",
"dnd.combat_turns/task.md",
}
assertProductionContains(t, "production prompt assets", assetNames, requiredAssets)
@@ -95,6 +130,10 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
if !ok || codecSpec.Kind != dnd.SpellListKind || codecSpec.Schema.ID != spellcodec.SchemaID {
t.Fatalf("catalog codec spec = %#v, ok=%t, want typed D&D spell codec", codecSpec, ok)
}
combatCodecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.CombatTurnListKind)
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)
}
if got := catalog.ValidatorChains.Validators(pipeline.StageExtract, spells.Key); !reflect.DeepEqual(got, wantChain) {
t.Fatalf("catalog validator chain = %#v, want %#v", got, wantChain)
}
@@ -466,6 +505,8 @@ func maintainedExampleFiles(t *testing.T) []maintainedExample {
{name: "production", path: repositoryPath("examples", "dnd-spells-production.config.yml"), pipelineIDs: []string{"dnd-session"}},
{name: "npcs", path: repositoryPath("examples", "dnd-npcs.config.yml"), pipelineIDs: []string{"dnd-session"}},
{name: "sequential", path: repositoryPath("examples", "dnd-npc-spell-sequential.config.yml"), pipelineIDs: []string{"dnd-npcs", "dnd-spells"}},
{name: "combat", path: repositoryPath("examples", "dnd-combat-turns.config.yml"), pipelineIDs: []string{"dnd-combat"}},
{name: "npc-combat-sequential", path: repositoryPath("examples", "dnd-npc-combat-sequential.config.yml"), pipelineIDs: []string{"dnd-combat", "dnd-npcs"}},
}
}