Files
notarius/internal/modules/integration/dnd_combat_runner_test.go

420 lines
18 KiB
Go

package integration_test
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"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"
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
scenecodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/scenedescriptions"
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
"gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/input/transcript"
)
func TestProductionCombatPipelineRetriesMergesNormalizesAndWritesJSON(t *testing.T) {
registries := productionNPCRegistries(t)
catalog := moduleCatalog(registries)
npcPayload := combatTestNPCPayload(t)
configValue := combatOnlyConfig()
effective, err := configValue.Resolve(config.ResolveInput{
PipelineID: "dnd-combat-fixture",
Catalog: catalog,
ReferenceOverrides: []pipeline.ReferenceBinding{
{Stage: pipeline.StageExtract, LaneID: "combat", SlotName: "npcs", Source: npcPayload.path, BindingSource: contracts.ReferenceBindingSourceCLI},
{Stage: pipeline.StageNormalize, LaneID: "combat", SlotName: "npcs", Source: npcPayload.path, BindingSource: contracts.ReferenceBindingSourceCLI},
},
})
if err != nil {
t.Fatalf("Resolve() error = %v, want nil", err)
}
materialized, warnings, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalog, pipeline.ReferenceMaterializationOptions{})
if err != nil || len(warnings) != 0 {
t.Fatalf("MaterializeReferences() error = %v warnings = %#v, want no warnings", err, warnings)
}
client := &fakeCombatLLMClient{responses: []string{
combatTestInvalidEnumResponse("unsupported"),
combatTestTurnResponse("mira thorn", "turn", 1),
combatTestTurnResponse("Mira Thorn", "reaction", 2),
combatTestTurnResponse("Hooded Guard", "turn", 3),
}}
prepared, err := pipeline.Prepare(materialized, registries, pipeline.ModuleDependencies{LLM: client})
if err != nil {
t.Fatalf("Prepare() error = %v, want nil", err)
}
fingerprints := prepared.CheckpointFingerprints()
assertCombatFingerprint(t, fingerprints, "extract:combat:"+combatextract.Key+":npc_registry")
assertCombatFingerprint(t, fingerprints, "extract:combat:"+combatextract.Key+":scene_eligibility")
assertCombatFingerprint(t, fingerprints, "normalize:combat:"+combatnormalize.Key+":npc_registry")
if len(fingerprints) == 0 {
t.Fatal("checkpoint fingerprints = empty, want production combat identities")
}
output, err := pipeline.New().Run(context.Background(), pipeline.RunInput{
Prepared: prepared,
RawInput: readNPCFixture(t),
ExtractWorkers: 1,
})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
if len(client.requests) != 4 || len(output.Rejected) != 0 {
t.Fatalf("LLM requests = %d rejected = %#v, want one retry and no durable rejection", len(client.requests), output.Rejected)
}
if output.Manifest.ValidationStatus != "approved" || len(output.NormalizeOutputs) != 1 || len(output.OutputFiles) == 0 {
t.Fatalf("manifest/output = %#v / %#v, want approved combat JSON output", output.Manifest, output.OutputFiles)
}
serialized := output.NormalizeOutputs[0]
if serialized.LaneID != "combat" || serialized.NormalizerKey != combatnormalize.Key || serialized.Artifact.Schema.ID != combatcodec.SchemaID {
t.Fatalf("serialized combat output = %#v, want production combat lane schema", serialized)
}
value, err := combatcodec.New().Decode(serialized.Artifact.Content)
if err != nil {
t.Fatalf("Decode(combat output) error = %v", err)
}
if len(value.CombatTurns) != 3 || value.CombatTurns[0].Actor != "Mira Thorn" || value.CombatTurns[1].Actor != "Mira Thorn" || value.CombatTurns[2].Actor != "Hooded Guard" {
t.Fatalf("normalized combat output = %#v, want ordered canonical actors", value)
}
for _, turn := range value.CombatTurns {
for _, ref := range turn.SourceRefs {
if ref.SourceID != "npc-session" {
t.Fatalf("combat evidence ref = %#v, want current transcript source", ref)
}
}
}
if !hasCombatWarning(output.Warnings, combatnormalize.ReasonCodeActorCanonicalized) {
t.Fatalf("warnings = %#v, want registry normalization warning", output.Warnings)
}
if len(output.Manifest.References) != 3 {
t.Fatalf("manifest references = %#v, want scene eligibility plus extract and normalize NPC provenance", output.Manifest.References)
}
lane := output.Manifest.ArtifactLanes[0]
if lane.ID != "combat" || lane.Extractor != combatextract.Key || lane.Merger != pipeline.DefaultMergeModule || lane.Normalizer != combatnormalize.Key {
t.Fatalf("manifest lane = %#v, want complete combat composition", lane)
}
extractorMetadata, ok := lane.Metadata["extractor"].(map[string]any)
if !ok || extractorMetadata["npc_count"] != 2 || extractorMetadata["npc_registry_digest"] == "" || extractorMetadata["scene_description_count"] != 3 || extractorMetadata["scene_eligibility_digest"] == "" || extractorMetadata["scene_gate_policy"] != "dnd.combat_turns.scene_gate.v1" {
t.Fatalf("extractor metadata = %#v, want registry and scene eligibility metadata", lane.Metadata)
}
normalizerMetadata, ok := lane.Metadata["normalizer"].(map[string]any)
if !ok || normalizerMetadata["npc_count"] != 2 || normalizerMetadata["normalization_policy"] != combatnormalize.NormalizationPolicy {
t.Fatalf("normalizer metadata = %#v, want registry and normalization policy", lane.Metadata)
}
var combatFile *contracts.OutputFile
for index := range output.OutputFiles {
if output.OutputFiles[index].Name == "lanes/combat.json" {
combatFile = &output.OutputFiles[index]
break
}
}
if combatFile == nil || combatFile.ContentType != combatcodec.MediaType {
t.Fatalf("output files = %#v, want lanes/combat.json JSON payload", output.OutputFiles)
}
}
func TestProductionCombatPipelineAttributesExhaustedInvalidEnumsToShapeValidation(t *testing.T) {
registries := productionNPCRegistries(t)
configValue := combatOnlyConfig()
profile := configValue.Pipelines["dnd-combat-fixture"]
profile.Chunk.Options["max_units"] = 100
profile.References["scene_descriptions"] = pipeline.ExternalReference(combatSceneDescriptionPath(t, []dnd.SceneDescription{{
ID: "chunk-000001",
SourceRef: source.SourceRef{SourceID: "npc-session", StartUnitID: 1, EndUnitID: 5},
Kind: dnd.SceneKindCombat,
Title: "Complete combat encounter",
Summary: "The accepted chunk contains active combat.",
}}))
configValue.Pipelines["dnd-combat-fixture"] = profile
effective, err := configValue.Resolve(config.ResolveInput{
PipelineID: "dnd-combat-fixture",
Catalog: moduleCatalog(registries),
})
if err != nil {
t.Fatalf("Resolve() error = %v, want nil", err)
}
client := &fakeCombatLLMClient{responses: []string{
combatTestInvalidEnumResponse("unsupported"),
combatTestInvalidEnumResponse("invalid"),
combatTestInvalidEnumResponse("unknown"),
}}
materialized, _, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, moduleCatalog(registries), pipeline.ReferenceMaterializationOptions{})
if err != nil {
t.Fatalf("MaterializeReferences() error = %v", err)
}
prepared, err := pipeline.Prepare(materialized, registries, pipeline.ModuleDependencies{LLM: client})
if err != nil {
t.Fatalf("Prepare() error = %v, want nil", err)
}
output, err := pipeline.New().Run(context.Background(), pipeline.RunInput{
Prepared: prepared,
RawInput: readNPCFixture(t),
ExtractWorkers: 1,
})
if err != nil {
t.Fatalf("Run() error = %v, want non-fatal rejected output", err)
}
if len(client.requests) != 3 || len(output.Rejected) != 1 {
t.Fatalf("LLM requests = %d rejected = %#v, want exhausted retry and one rejection", len(client.requests), output.Rejected)
}
rejected := output.Rejected[0]
if rejected.ReasonCode != combatshape.ReasonCode || rejected.ValidatorName != combatshape.Key || rejected.AttemptCount != 3 {
t.Fatalf("rejected output = %#v, want exhausted combat shape rejection", rejected)
}
if output.Manifest.ValidationStatus != "rejected" || len(output.NormalizeOutputs) != 0 {
t.Fatalf("output = %#v, want non-fatal rejected combat result without normalized artifacts", output)
}
}
func TestCombatPipelinePublishesUnavailableSceneWarningForMismatchedReference(t *testing.T) {
registries := productionNPCRegistries(t)
configValue := combatOnlyConfig()
profile := configValue.Pipelines["dnd-combat"]
profile.Chunk.Options["max_units"] = 100
profile.References["scene_descriptions"] = pipeline.ExternalReference(combatSceneDescriptionPath(t, []dnd.SceneDescription{{
ID: "chunk-000001",
SourceRef: source.SourceRef{SourceID: "npc-session", StartUnitID: 2, EndUnitID: 5},
Kind: dnd.SceneKindCombat,
Title: "Mismatched combat encounter",
Summary: "The classification range does not match the accepted chunk.",
}}))
configValue.Pipelines["dnd-combat"] = profile
catalog := moduleCatalog(registries)
effective, err := configValue.Resolve(config.ResolveInput{PipelineID: "dnd-combat", Catalog: catalog})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
materialized, warnings, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalog, pipeline.ReferenceMaterializationOptions{})
if err != nil || len(warnings) != 0 {
t.Fatalf("MaterializeReferences() error = %v warnings = %#v", err, warnings)
}
client := &fakeCombatLLMClient{}
prepared, err := pipeline.Prepare(materialized, registries, pipeline.ModuleDependencies{LLM: client})
if err != nil {
t.Fatalf("Prepare() error = %v", err)
}
output, err := pipeline.New().Run(context.Background(), pipeline.RunInput{
Prepared: prepared,
RawInput: readNPCFixture(t),
ExtractWorkers: 1,
})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if len(client.requests) != 0 || len(output.Rejected) != 0 || !hasCombatWarning(output.Warnings, "scene_classification_unavailable") {
t.Fatalf("requests/rejected/warnings = %#v / %#v / %#v, want accepted unavailable-scene skip without a combat request", client.requests, output.Rejected, output.Warnings)
}
if len(output.NormalizeOutputs) != 1 {
t.Fatalf("normalized outputs = %#v, want accepted empty combat lane", output.NormalizeOutputs)
}
value, err := combatcodec.New().Decode(output.NormalizeOutputs[0].Artifact.Content)
if err != nil {
t.Fatalf("Decode(combat output) error = %v", err)
}
if value.CombatTurns == nil || len(value.CombatTurns) != 0 {
t.Fatalf("mismatched scene combat output = %#v, want non-nil empty turns", value)
}
}
func TestCombatNormalizerRejectsCampaignReferenceBinding(t *testing.T) {
registries := productionNPCRegistries(t)
catalog := moduleCatalog(registries)
referencePath := filepath.Join(t.TempDir(), "party.yml")
if err := os.WriteFile(referencePath, []byte("Aria: cleric\n"), 0o600); err != nil {
t.Fatal(err)
}
effective, err := combatOnlyConfig().Resolve(config.ResolveInput{
PipelineID: "dnd-combat-fixture",
Catalog: catalog,
ReferenceOverrides: []pipeline.ReferenceBinding{{
Stage: pipeline.StageNormalize, LaneID: "combat", SlotName: "party", Source: referencePath,
BindingSource: contracts.ReferenceBindingSourceCLI,
}},
})
if err != nil {
if !strings.Contains(err.Error(), "party") {
t.Fatalf("Resolve() error = %v, want undeclared party context", err)
}
return
}
_, _, err = pipeline.MaterializeReferences(effective.ResolvedPipeline, catalog, pipeline.ReferenceMaterializationOptions{})
if err == nil || !strings.Contains(err.Error(), "party") || !strings.Contains(err.Error(), "not declared") {
t.Fatalf("MaterializeReferences() error = %v, want undeclared normalize party binding", err)
}
}
func TestCombatPreparationRejectsMalformedOrOversizedNPCReferencesBeforeExecution(t *testing.T) {
registries := productionNPCRegistries(t)
catalog := moduleCatalog(registries)
for _, test := range []struct {
name string
content []byte
}{
{name: "malformed", content: []byte(`{"npcs":[`)},
{name: "oversized", content: append([]byte(`{"npcs":[]}`), make([]byte, npcregistry.MaxBytes+1)...)},
} {
t.Run(test.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "npcs.json")
if err := os.WriteFile(path, test.content, 0o600); err != nil {
t.Fatal(err)
}
cfg := combatOnlyConfig()
effective, err := cfg.Resolve(config.ResolveInput{
PipelineID: "dnd-combat",
Catalog: catalog,
ReferenceOverrides: []pipeline.ReferenceBinding{
{Stage: pipeline.StageExtract, LaneID: "combat", SlotName: "npcs", Source: path, BindingSource: contracts.ReferenceBindingSourceCLI},
{Stage: pipeline.StageNormalize, LaneID: "combat", SlotName: "npcs", Source: path, BindingSource: contracts.ReferenceBindingSourceCLI},
},
})
if err != nil {
t.Fatalf("Resolve() error = %v, want path binding to resolve before content preparation", err)
}
materialized, _, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalog, pipeline.ReferenceMaterializationOptions{})
if test.name == "oversized" {
if err == nil || !strings.Contains(err.Error(), "limit") {
t.Fatalf("MaterializeReferences() error = %v, want bounded oversized-reference failure", err)
}
return
}
if err != nil {
t.Fatalf("MaterializeReferences() error = %v, want materialization before typed preparation", err)
}
client := &fakeCombatLLMClient{}
if _, err := pipeline.Prepare(materialized, registries, pipeline.ModuleDependencies{LLM: client}); err == nil {
t.Fatal("Prepare() error = nil, want NPC registry preparation failure")
}
if len(client.requests) != 0 {
t.Fatalf("LLM requests = %d, want no pipeline execution after preparation failure", len(client.requests))
}
})
}
}
type combatNPCPayload struct {
path string
}
func combatTestNPCPayload(t *testing.T) combatNPCPayload {
t.Helper()
value := dnd.NPCList{NPCs: []dnd.NPC{
{ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "prior-npc-session", StartUnitID: 1, EndUnitID: 1}}},
{ID: identity.DeriveID("Hooded Guard"), Name: "Hooded Guard", SourceRefs: []source.SourceRef{{SourceID: "prior-npc-session", StartUnitID: 3, EndUnitID: 3}}},
}}
content, err := npccodec.New().Encode(value)
if err != nil {
t.Fatalf("Encode(NPC fixture) error = %v", err)
}
path := filepath.Join(t.TempDir(), "npc-run", "lanes", "npcs.json")
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, content, 0o600); err != nil {
t.Fatal(err)
}
return combatNPCPayload{path: path}
}
func combatSceneDescriptionPath(t *testing.T, scenes []dnd.SceneDescription) string {
t.Helper()
content, err := scenecodec.New().Encode(dnd.SceneDescriptionList{Scenes: scenes})
if err != nil {
t.Fatalf("Encode(scene descriptions) error = %v", err)
}
path := filepath.Join(t.TempDir(), "scene-descriptions.json")
if err := os.WriteFile(path, content, 0o600); err != nil {
t.Fatal(err)
}
return path
}
func combatOnlyConfig() config.Config {
cfg := config.Default()
cfg.Pipelines["dnd-combat"] = pipeline.PipelineProfile{
Input: pipeline.Binding(transcript.Key),
Chunk: pipeline.ModuleBinding{Module: pipeline.DefaultChunkModule, Options: map[string]any{"max_units": 2}},
References: map[string]pipeline.ReferenceSource{"scene_descriptions": pipeline.ExternalReference(repositoryPathForIntegration("internal", "modules", "integration", "testdata", "combat_scene_descriptions.json"))},
Artifacts: map[string]pipeline.ArtifactLaneProfile{
"combat": {
Extract: pipeline.ModuleBinding{Module: combatextract.Key, Retries: 2},
Normalize: pipeline.Binding(combatnormalize.Key),
},
},
}
cfg.Pipelines["dnd-combat-fixture"] = cfg.Pipelines["dnd-combat"]
return cfg
}
func repositoryPathForIntegration(parts ...string) string {
_, file, _, _ := runtime.Caller(0)
return filepath.Join(append([]string{filepath.Dir(file), "..", "..", ".."}, parts...)...)
}
type fakeCombatLLMClient struct {
mu sync.Mutex
responses []string
requests []contracts.StructuredCompletionRequest
}
func (client *fakeCombatLLMClient) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
if err := ctx.Err(); err != nil {
return contracts.StructuredCompletionResponse{}, err
}
client.mu.Lock()
client.requests = append(client.requests, req)
index := len(client.requests) - 1
client.mu.Unlock()
if index >= len(client.responses) {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("no combat fake response for call %d", index)
}
content := []byte(client.responses[index])
if err := json.Unmarshal(content, out); err != nil {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("populate combat fake target: %w", err)
}
return contracts.StructuredCompletionResponse{Content: content, Provider: "test", Model: "combat-fake"}, nil
}
func combatTestInvalidEnumResponse(turnKind string) string {
return fmt.Sprintf(`{"combat_turns":[{"actor":"Aria","turn_kind":%q,"source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`, turnKind)
}
func combatTestTurnResponse(actor, turnKind string, unit int) string {
return fmt.Sprintf(`{"combat_turns":[{"actor":%q,"turn_kind":%q,"source_refs":[{"start_unit_id":%d,"end_unit_id":%d}]}]}`, actor, turnKind, unit, unit)
}
func hasCombatWarning(warnings []contracts.Warning, reason string) bool {
for _, warning := range warnings {
if warning.ReasonCode == reason {
return true
}
}
return false
}
func assertCombatFingerprint(t *testing.T, fingerprints []pipeline.CheckpointFingerprint, name string) {
t.Helper()
for _, fingerprint := range fingerprints {
if fingerprint.Name == name && strings.HasPrefix(fingerprint.Value, "sha256:") {
return
}
}
t.Fatalf("fingerprints = %#v, want %q with semantic digest", fingerprints, name)
}
var _ contracts.StructuredLLMClient = (*fakeCombatLLMClient)(nil)