diff --git a/docs/cli.md b/docs/cli.md index 645df0f..53d9cbc 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -104,6 +104,21 @@ go run ./cmd/notarius run dnd-spells \ --reference spells.extract.npcs=./npc-run/lanes/npcs.json ``` +For the independent NPC-to-combat workflow, bind the same completed NPC lane +to both combat stages explicitly: + +```sh +go run ./cmd/notarius run dnd-combat \ + --config examples/dnd-npc-combat-sequential.config.yml \ + --input examples/seriatim-minimal-transcript.json \ + --reference combat.extract.npcs=./npc-run/lanes/npcs.json \ + --reference combat.normalize.npcs=./npc-run/lanes/npcs.json +``` + +The two selectors are independent stage-local bindings. Binding extraction +does not implicitly bind normalization, and Notarius does not discover or +schedule the preceding NPC run. + The same grammar can target chunk, merge, and normalize slots when the configured modules declare them: diff --git a/docs/config.md b/docs/config.md index 51a0e4c..f79996f 100644 --- a/docs/config.md +++ b/docs/config.md @@ -23,6 +23,8 @@ The explicit-path option is defined in the [CLI reference](cli.md). - [Production-oriented D&D spell configuration](../examples/dnd-spells-production.config.yml) - [D&D NPC configuration](../examples/dnd-npcs.config.yml) - [Sequential D&D NPC and spell configuration](../examples/dnd-npc-spell-sequential.config.yml) +- [D&D combat-turn configuration](../examples/dnd-combat-turns.config.yml) +- [Sequential D&D NPC and combat-turn configuration](../examples/dnd-npc-combat-sequential.config.yml) All are complete version 3 files. The fragments below illustrate individual fields and are not alternate complete configurations. @@ -286,10 +288,12 @@ production validators do not call the LLM and must not set `llm_profile`. | chunk | `dnd/scenes` | Uses an LLM to split transcript source units into D&D scenes. | | extract | `dnd/spells` | Extracts typed D&D spell-list artifacts. | | extract | `dnd/npcs` | Extracts typed D&D NPC-list artifacts. | +| extract | `dnd/combat-turns` | Extracts typed D&D combat-turn-list artifacts. | | merge | `appendorder` | Combines typed artifacts in chunk order. | | normalize | `noop` | Passes merged typed artifacts through unchanged. | | normalize | `dnd/spells` | Deterministically canonicalizes and de-duplicates typed D&D spell-list artifacts. | | normalize | `dnd/npcs` | Deterministically consolidates typed D&D NPC-list artifacts by canonical identity and aliases. | +| normalize | `dnd/combat-turns` | Deterministically canonicalizes, orders, and de-duplicates typed D&D combat-turn artifacts. | | output | `json` | Produces JSON output files for normalized `application/json` lanes. | ## Implemented Production Validators @@ -308,6 +312,10 @@ production validators do not call the LLM and must not set `llm_profile`. | `extract/dnd/npcs/source_refs` | deterministic | Rejects missing or invalid D&D NPC source references. | | `extract/dnd/npcs/source_relatedness` | deterministic | Emits warnings when an NPC name or alias is not found near its cited source text. | | `normalize/dnd/npcs/identity` | deterministic | Rejects invalid canonical IDs, aliases, and cross-record identity collisions. | +| `extract/dnd/combat-turns/shape` | deterministic | Rejects malformed D&D combat-turn artifacts. | +| `extract/dnd/combat-turns/source_refs` | deterministic | Rejects missing or invalid D&D combat-turn source references. | +| `extract/dnd/combat-turns/source_relatedness` | deterministic | Emits warnings when an actor or declared action is not found near cited source text. | +| `normalize/dnd/combat-turns/invariants` | deterministic | Rejects normalized combat-turn identity, target, evidence-order, and chronology violations. | The production default chain for `dnd/spells` is used for both its extract and normalize stages: @@ -342,6 +350,26 @@ normalize: - extract/dnd/npcs/source_relatedness ``` +The production default chains for `dnd/combat-turns` are: + +```yaml +extract: + validators: + - generic/valid_json + - generic/valid_json_schema + - extract/dnd/combat-turns/shape + - extract/dnd/combat-turns/source_refs + - extract/dnd/combat-turns/source_relatedness +normalize: + validators: + - generic/valid_json + - generic/valid_json_schema + - extract/dnd/combat-turns/shape + - normalize/dnd/combat-turns/invariants + - extract/dnd/combat-turns/source_refs + - extract/dnd/combat-turns/source_relatedness +``` + Empty chains approve output by default. The `generic` chunker accepts: @@ -395,6 +423,26 @@ accepts no references. To pass an NPC result to a later spell run, bind the normalized payload explicitly at runtime; the maintained sequential example documents that operator workflow. +The `dnd/combat-turns` extractor and normalizer declare the same optional +campaign slots and the structured `npcs` slot. The combat extractor uses the +NPC payload only to ground actor and target identity; the normalizer uses its +prepared immutable registry for the same canonicalization. Both slots accept +exactly one UTF-8 `application/json` file no larger than 1 MiB. The registry's +source ranges remain provenance for the reference and never become combat +evidence. Binding `npcs` to extraction and normalization is stage-local, so an +operator-driven combat run uses two explicit selectors: + +```text +combat.extract.npcs=/lanes/npcs.json +combat.normalize.npcs=/lanes/npcs.json +``` + +When bound, the combat extractor and normalizer record only the registry's +semantic digest and count in their metadata and checkpoint fingerprints; names, +aliases, content, and paths are not recorded there. When absent, the combat +prompt receives the exact empty registry value `{"npcs":[]}` and no registry +provenance or fingerprint is recorded. + ## State Surfaces The `output`, `cache`, and `debug` top-level fields select independent physical diff --git a/docs/integrations/dnd-combat-turn-artifacts.md b/docs/integrations/dnd-combat-turn-artifacts.md index 2cde835..42af1df 100644 --- a/docs/integrations/dnd-combat-turn-artifacts.md +++ b/docs/integrations/dnd-combat-turn-artifacts.md @@ -1,9 +1,8 @@ # D&D Combat-Turn Artifact Contract This document defines the durable artifact, serialization, extraction, -candidate-validation, and standalone normalization boundaries for D&D combat -turns. Production composition and a selectable pipeline lane are not part of -this contract yet. +candidate-validation, normalization, and production lane boundaries for D&D +combat turns. ## Artifact identity @@ -119,8 +118,9 @@ the shared Unicode-aware NPC identity policy, and checks declaration tokens of at least four Unicode code points against complete cited-text tokens. Targets are not checked deterministically. -The extractor and validators are package-complete but are not registered by the -production D&D family registrar yet. +The production D&D registrar exposes the extractor and these validators. Its +default extraction chain preserves this order: JSON syntax, private response +schema, combat shape, source references, then source relatedness. ## Normalization boundary @@ -149,5 +149,19 @@ canonical evidence ordering, chronology, and duplicate identity. It rejects with `invalid_combat_turn_normalization` under policy `dnd.combat_turns.validator.normalized.v1`. -The normalizer and normalized-invariants validator are package-complete but are -not registered by the production D&D family registrar yet. +The production D&D registrar exposes the normalizer and normalized-invariants +validator. Its default normalization chain is JSON syntax, durable schema, +combat shape, normalized invariants, source references, then source +relatedness. The lane uses the framework's typed append-order merger and has no +merge validator chain. + +## Production manifest and references + +The selectable lane uses extractor and normalizer key `dnd/combat-turns`, +`appendorder` for the typed merger, and the durable codec above. A bound `npcs` +reference contributes raw-file provenance to the run manifest. Prepared combat +extractor and normalizer metadata and checkpoint fingerprints contain only the +NPC registry's semantic digest and count; the registry content, path, and NPC +source ranges are not copied into combat output. The normalized lane is emitted +as `lanes/.json` by the JSON output module, and warnings and rejection +summaries remain in their shared companion files. diff --git a/docs/integrations/dnd-npc-artifacts.md b/docs/integrations/dnd-npc-artifacts.md index 57b2fcb..a18e230 100644 --- a/docs/integrations/dnd-npc-artifacts.md +++ b/docs/integrations/dnd-npc-artifacts.md @@ -2,8 +2,9 @@ This document defines the durable D&D NPC-list artifact, its JSON codec, and the selectable production NPC pipeline. The normalized JSON payload can be -passed explicitly to the spell extractor as an optional caster-name registry; -it remains a reference, not spell evidence. +passed explicitly to the spell extractor as an optional caster-name registry +or to the combat extractor and normalizer as an actor/target registry. It +remains a reference, not spell or combat evidence. ## Identity diff --git a/docs/integrations/json-output.md b/docs/integrations/json-output.md index 721669c..206a9f3 100644 --- a/docs/integrations/json-output.md +++ b/docs/integrations/json-output.md @@ -151,8 +151,9 @@ accepts only artifacts whose codec media type is `application/json`. The file contains the codec-owned JSON bytes pretty-printed. The schema of each lane payload is owned by that artifact contract. For the -current D&D lanes, see [D&D Spell Artifact](dnd-spell-artifacts.md) and -[D&D NPC Artifact](dnd-npc-artifacts.md). +current D&D lanes, see [D&D Spell Artifact](dnd-spell-artifacts.md), +[D&D NPC Artifact](dnd-npc-artifacts.md), and +[D&D Combat-Turn Artifact](dnd-combat-turn-artifacts.md). ## `rejected.json` diff --git a/docs/internal/llm.md b/docs/internal/llm.md index 4feccc2..a5258bc 100644 --- a/docs/internal/llm.md +++ b/docs/internal/llm.md @@ -39,9 +39,9 @@ without exposing Scriptorium types through stage contracts. 7. injecting that one shared client into complete pipeline preparation before the source file is read or the runner is invoked. -The D&D scene chunker and spell and NPC extractors retain this injected client -and use it for every structured completion. Operation requests do not carry an -LLM client. +The D&D scene chunker and spell, NPC, and combat extractors retain this +injected client and use it for every structured completion. Operation requests +do not carry an LLM client. The CLI separately gathers explicit profile IDs from resolved LLM-capable stage and validator bindings. It prepares a small internal check prompt for each ID so @@ -103,18 +103,22 @@ return defensive copies, and expose a diagnostics map that omits schema bytes. The small framework registry contains only generic test schemas; production schemas remain package-owned. -The spell extractor's package-owned prompt declares a required +The spell and combat extractors' package-owned prompts declare their structured +JSON inputs and private response schemas. The spell extractor's prompt declares a required `application/json` `spell_catalog` input and an optional `application/json` `npcs` input. The extractor generates the catalog input from its prepared effective catalog as `{"spell_names":[...]}` using sorted canonical names only. The shared D&D prompt assets include a generic NPC grounding fragment directly -after the campaign reference message. When an NPC registry is bound, the +after the campaign reference message for both extractors. When an NPC registry +is bound, the domain registry boundary strictly decodes and identity-validates one durable artifact, re-encodes canonical JSON, and generates a semantic digest over those bytes. The unbound input is exactly `{"npcs":[]}`. Input digests cover the generated bytes; manifests record catalog identity and optional NPC registry digest/count rather than names, aliases, overlay bytes, registry -paths, or source metadata. +paths, or source metadata. Combat prompt, response-schema, mapping, +normalization, identity, and bound-registry fingerprints remain separate +semantic inputs to checkpoint identity. ## Debug And Redaction Boundaries diff --git a/docs/internal/modules.md b/docs/internal/modules.md index 03b443a..05b84ec 100644 --- a/docs/internal/modules.md +++ b/docs/internal/modules.md @@ -17,17 +17,17 @@ validator registry. Package-family registrars compose those leaf registrations into the production catalog and own family-level policy such as default validator chains and prompt asset collection. -Production input, chunk, output, and D&D spell-extract packages register strict -option decoders and run-local builders. Preparation decodes their options into +Production input, chunk, output, and D&D spell- and combat-extract packages +register strict option decoders and run-local builders. Preparation decodes their options into implementation-owned values and injects dependencies plus the materialized reference set for the selected target. Each builder receives an isolated clone -of that set; input and output builders receive no references. The spell -extractor is typed over the canonical D&D model. D&D validators, merge, and -normalize use typed variants; JSON representation validators use serialized +of that set; input and output builders receive no references. The spell and +combat extractors are typed over the canonical D&D model. D&D validators, merge, +and normalize use typed variants; JSON representation validators use serialized requests; and unconditional validators expose separate chunk and typed -variants. The D&D production registrar registers the canonical typed spell and -NPC implementations, including their kind-specific merge and normalize -behavior. +variants. The D&D production registrar registers the canonical typed spell, +NPC, and combat implementations, including their kind-specific merge and +normalize behavior. Prepared extractors, extract validators, and codecs may be reused concurrently by the run-wide extract pool. Production implementations are immutable after @@ -98,8 +98,8 @@ candidate versus approved encode/decode operations. The `internal/modules/dnd/codec/combatturns` package owns the durable `dnd/combat-turn-list` schema and candidate versus approved JSON boundary. It -is available as a package contract but is not registered by the production D&D -family registrar yet. +is registered by the production D&D family registrar for the selectable combat +lane. ## Input Adapter @@ -235,8 +235,8 @@ source-document position while preserving malformed candidate fields for deterministic validators. Its prompt and private response schema are package-owned, and its prepared metadata and checkpoint fingerprints contain only prompt/schema/mapping identities plus an optional NPC registry digest. -The package exposes typed registration for focused composition but is not in -the production D&D registrar yet. +The package exposes typed registration and is included in the production D&D +registrar with the default combat extraction chain. ### `internal/modules/dnd/normalize/npcs` @@ -294,8 +294,8 @@ position, and collapses only exact duplicate identities with fully valid evidence. It deep-clones output storage and emits bounded warnings scoped to merged input indexes. Its metadata and fingerprints identify the normalization and NPC identity policies, with registry digest/count only when bound. The -normalizer is package-complete but is not registered in the production D&D -registrar. +normalizer is included in the production D&D registrar with the default combat +normalization chain. ## Output Encoder @@ -365,8 +365,8 @@ turn for unrelated actor or declaration text. The normalized-invariants validator owns display normalization, comparison-unique targets, canonical source-reference order, chronology, and exact duplicate identity; it defers shape and source-reference failures. All four validators are deterministic and -expose local policy fingerprints; they are package-complete but not yet in a -production validator chain. +expose local policy fingerprints. The D&D registrar orders them after generic +JSON and response-schema validation at extraction and normalization. ## Production Registration @@ -376,8 +376,9 @@ complete framework registry set and one LLM asset registry. It invokes `internal/modules/seriatim/register`, and `internal/modules/dnd/register` in that order, then exposes the matching catalog for resolution. The generic and Seriatim registrars own their production leaf registrations. The D&D registrar -owns D&D leaf registrations, typed spell and NPC default-validator chains, -typed append-order specializations, and D&D prompt/schema asset collection. +owns D&D leaf registrations, typed spell, NPC, and combat default-validator +chains, typed append-order specializations, and D&D prompt/schema asset +collection. Concrete implementation packages do not import generic implementation packages directly. A concrete family's `register` package is its composition diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 11fce37..77b5370 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -65,9 +65,10 @@ run-local construction closures. Preparation injects shared dependencies and constructs input, chunk, validators, ordered lanes, and output before source parsing. Production modules use strict construction-time option decoding, and LLM-backed modules retain the injected shared client. The D&D family registers -the canonical `dnd/spell-list` and `dnd/npc-list` codecs, typed spell and NPC -extractors and normalizers, validators, plus kind-specific generic merge -strategies; generic JSON validators use the serialized-validation contract. The runner executes lanes through +the canonical `dnd/spell-list`, `dnd/npc-list`, and `dnd/combat-turn-list` +codecs, typed spell, NPC, and combat extractors and normalizers, validators, +plus kind-specific generic merge strategies; generic JSON validators use the +serialized-validation contract. The runner executes lanes through private exact-type-checked closures, coordinates extract results independently of completion timing, and serializes artifacts only through their codec at checkpoint, debug, and output boundaries. @@ -91,8 +92,8 @@ Configuration. The implemented module packages are: | `internal/modules/dnd/extract/spells` | Maps private structured model output to canonical source-grounded D&D spell lists. | | `internal/modules/dnd/extract/npcs` | Maps private structured model output to canonical source-grounded D&D NPC lists. | | `internal/modules/dnd/extract/combatturns` | Maps private structured model output to source-grounded D&D combat-turn candidates and preserves chronology and invalid candidate values for validators. | -| `internal/modules/dnd/normalize/combatturns` | Canonicalizes and orders merged combat turns, applies exact NPC identity matches, and collapses only exact valid-evidence duplicates without production composition. | -| `internal/modules/dnd/validate/combatturns` | Provides deterministic shape, source-reference, source-relatedness, and normalized-invariant validation for combat turns without production composition. | +| `internal/modules/dnd/normalize/combatturns` | Canonicalizes and orders merged combat turns, applies exact NPC identity matches, and collapses only exact valid-evidence duplicates. | +| `internal/modules/dnd/validate/combatturns` | Provides deterministic shape, source-reference, source-relatedness, and normalized-invariant validation for the production combat chains. | | `internal/modules/dnd/npcs/registry` | Resolves validated normalized NPC references into immutable grounding data and exact identity lookup. | | `internal/modules/dnd/npcs/identity` | Owns Unicode-aware NPC identity, ID derivation, and registry collision validation. | | `internal/modules/dnd/spells/catalog` | Embeds and validates the versioned D&D 5e 2014 SRD catalog, composes optional overlays, and provides immutable effective lookup. | diff --git a/docs/operations.md b/docs/operations.md index 2a9caef..b1ca307 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -70,6 +70,34 @@ campaign data; protect both output roots and any checkpoint or debug roots that retain derived application data. A registry from another session is allowed, but its source references are never copied into spell output evidence. +## Sequential NPC And Combat Runs + +The maintained [sequential NPC and combat configuration](../examples/dnd-npc-combat-sequential.config.yml) +also represents two independent runs. Run `dnd-npcs` first, then bind its +normalized `lanes/npcs.json` payload to both combat stages: + +```sh +go run ./cmd/notarius run dnd-npcs \ + --config examples/dnd-npc-combat-sequential.config.yml \ + --input examples/seriatim-minimal-transcript.json \ + --output-dir ./npc-output + +go run ./cmd/notarius run dnd-combat \ + --config examples/dnd-npc-combat-sequential.config.yml \ + --input examples/seriatim-minimal-transcript.json \ + --reference combat.extract.npcs=./npc-output//lanes/npcs.json \ + --reference combat.normalize.npcs=./npc-output//lanes/npcs.json +``` + +Extraction and normalization bindings are stage-local and are intentionally +specified separately. Notarius does not discover the NPC run, copy its source +ranges into combat evidence, or compose the two runs into one workflow. The +combat manifest records both reference bindings and the prepared registry's +semantic digest/count. Changing the referenced NPC payload, prompt or schema, +normalization policy, or registry digest makes affected checkpoint state +incompatible; output, checkpoint, and debug roots remain independent sensitive +state surfaces. + ## Chunk-Plan Cache Chunk plans are stored at: diff --git a/docs/roadmap/dnd-combat-turn-extraction.md b/docs/roadmap/dnd-combat-turn-extraction.md index 7e98a6f..5fb97f3 100644 --- a/docs/roadmap/dnd-combat-turn-extraction.md +++ b/docs/roadmap/dnd-combat-turn-extraction.md @@ -1,6 +1,6 @@ # D&D Combat-Turn Extraction -Status: Accepted. +Status: Complete. ## Purpose diff --git a/docs/roadmap/future.md b/docs/roadmap/future.md index a5b5c15..f6139d3 100644 --- a/docs/roadmap/future.md +++ b/docs/roadmap/future.md @@ -16,18 +16,13 @@ not as committed release dates. validator, and normalizer development. Treat model-quality review as an iterative human evaluation aid, not a deterministic correctness gate. -### Add Sequential D&D Artifacts +### Expand Sequential D&D Artifacts -- The next proposed increment is - [D&D combat-turn extraction](dnd-combat-turn-extraction.md), using earlier NPC - output as an explicit identity reference while preserving independent runs. - Add narrative extraction for scene summaries, party actions, and NPCs encountered when that output proves useful beyond the dedicated NPC artifact. -- Define the preferred operational sequence for independent pipelines on the - same transcript. The initial direction is NPCs first, followed by spells and - combat turns as appropriate, with earlier JSON artifacts supplied to later - runs as references. -- Keep this sequencing operator- or script-driven initially. Do not require a +- Continue refining the preferred operational sequence for independent + pipelines on the same transcript as additional artifacts are introduced. +- Keep sequencing operator- or script-driven initially. Do not require a general DAG or concurrent cross-lane reconciliation model. ### Improve D&D Scene Classification diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 231084d..1822641 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # D&D Combat-Turn Extraction Implementation Plan -Status: Ready for implementation. +Status: Complete. Implement this plan in order. The feature policy and target state are defined in [D&D Combat-Turn Extraction](dnd-combat-turn-extraction.md); this document @@ -257,7 +257,7 @@ consumer, while preserving spell behavior. existing content-safe error behavior. - Update the internal overview, module, and LLM documentation in this stage so the implemented registry owner and shared prompt-fragment ownership remain - accurate; do not claim combat extraction exists yet. + accurate while the later combat work remains independently scoped. ### Tests diff --git a/examples/dnd-combat-turns.config.yml b/examples/dnd-combat-turns.config.yml new file mode 100644 index 0000000..2542633 --- /dev/null +++ b/examples/dnd-combat-turns.config.yml @@ -0,0 +1,22 @@ +version: 3 +output: + directory: ./notarius-output +cache: + chunk_plans: + mode: bypass + directory: ./notarius-cache/chunk-plans + checkpoints: + enabled: false + directory: ./notarius-cache/checkpoints +debug: + directory: ./notarius-debug +pipelines: + dnd-combat: + input: seriatim + chunk: generic + artifacts: + combat: + extract: + module: dnd/combat-turns + retries: 2 + normalize: dnd/combat-turns diff --git a/examples/dnd-npc-combat-sequential.config.yml b/examples/dnd-npc-combat-sequential.config.yml new file mode 100644 index 0000000..dbae917 --- /dev/null +++ b/examples/dnd-npc-combat-sequential.config.yml @@ -0,0 +1,31 @@ +version: 3 +output: + directory: ./notarius-output +cache: + chunk_plans: + mode: bypass + directory: ./notarius-cache/chunk-plans + checkpoints: + enabled: false + directory: ./notarius-cache/checkpoints +debug: + directory: ./notarius-debug +pipelines: + dnd-npcs: + input: seriatim + chunk: generic + artifacts: + npcs: + extract: + module: dnd/npcs + retries: 2 + normalize: dnd/npcs + dnd-combat: + input: seriatim + chunk: generic + artifacts: + combat: + extract: + module: dnd/combat-turns + retries: 2 + normalize: dnd/combat-turns diff --git a/internal/cli/dnd_combat_contract_test.go b/internal/cli/dnd_combat_contract_test.go new file mode 100644 index 0000000..0cc46d5 --- /dev/null +++ b/internal/cli/dnd_combat_contract_test.go @@ -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 +} diff --git a/internal/cli/production_contract_test.go b/internal/cli/production_contract_test.go index 5652286..bdb7b13 100644 --- a/internal/cli/production_contract_test.go +++ b/internal/cli/production_contract_test.go @@ -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"}}, } } diff --git a/internal/modules/dnd/register/register.go b/internal/modules/dnd/register/register.go index 70b67eb..b066d13 100644 --- a/internal/modules/dnd/register/register.go +++ b/internal/modules/dnd/register/register.go @@ -4,16 +4,24 @@ package register import ( "fmt" + "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/llm" "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" npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs" spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells" + combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns" npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" + combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns" npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs" spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells" + combatinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/invariants" + 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" npcidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/identity" npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape" npcsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/source_refs" @@ -42,19 +50,28 @@ func Register(registries pipeline.Registries, assets *llm.AssetRegistry) 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: "scenes chunker", register: func() error { return scenes.Register(registries.Chunkers) }}, {name: "spells extractor", register: func() error { return spells.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: "spell-list appendorder merger", register: func() error { return appendorder.RegisterTyped(registries.Mergers, dnd.SpellListKind, appendSpellLists) }}, {name: "npc-list appendorder merger", register: func() error { return appendorder.RegisterTyped(registries.Mergers, dnd.NPCListKind, appendNPCLists) }}, + {name: "combat-turn-list appendorder merger", register: func() error { + return appendorder.RegisterTyped(registries.Mergers, dnd.CombatTurnListKind, appendCombatTurnLists) + }}, {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: "spell-list noop normalizer", register: func() error { return noop.RegisterTyped[dnd.SpellList](registries.Normalizers, dnd.SpellListKind) }}, {name: "npc-list noop normalizer", register: func() error { return noop.RegisterTyped[dnd.NPCList](registries.Normalizers, dnd.NPCListKind) }}, + {name: "combat-turn-list noop normalizer", register: func() error { + return noop.RegisterTyped[dnd.CombatTurnList](registries.Normalizers, dnd.CombatTurnListKind) + }}, {name: "spell shape validator", register: func() error { return spellshape.Register(registries.Validators) }}, {name: "spell catalog validator", register: func() error { return spellcatalog.Register(registries.Validators) }}, {name: "spell source references validator", register: func() error { return spellsourcerefs.Register(registries.Validators) }}, @@ -63,6 +80,10 @@ func Register(registries pipeline.Registries, assets *llm.AssetRegistry) error { {name: "npc identity validator", register: func() error { return npcidentity.Register(registries.Validators) }}, {name: "npc source references validator", register: func() error { return npcsourcerefs.Register(registries.Validators) }}, {name: "npc source relatedness validator", register: func() error { return npcrelatedness.Register(registries.Validators) }}, + {name: "combat shape validator", register: func() error { return combatshape.Register(registries.Validators) }}, + {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: "spell-list always accept validator", register: func() error { return alwaysaccept.RegisterTyped[dnd.SpellList](registries.Validators, dnd.SpellListKind) }}, @@ -75,9 +96,16 @@ func Register(registries pipeline.Registries, assets *llm.AssetRegistry) error { {name: "npc-list always reject validator", register: func() error { return alwaysreject.RegisterTyped[dnd.NPCList](registries.Validators, dnd.NPCListKind) }}, + {name: "combat-turn-list always accept validator", register: func() error { + return alwaysaccept.RegisterTyped[dnd.CombatTurnList](registries.Validators, dnd.CombatTurnListKind) + }}, + {name: "combat-turn-list always reject validator", register: func() error { + return alwaysreject.RegisterTyped[dnd.CombatTurnList](registries.Validators, dnd.CombatTurnListKind) + }}, {name: "scenes prompt assets", register: func() error { return scenes.RegisterPromptAssets(assets) }}, {name: "spells prompt assets", register: func() error { return spells.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) }}, } for _, registration := range registrations { if err := registration.register(); err != nil { @@ -139,6 +167,33 @@ func Register(registries pipeline.Registries, assets *llm.AssetRegistry) error { }); err != nil { return fmt.Errorf("register dnd npcs normalize validator chain: %w", err) } + if err := registries.ValidatorChains.Register(pipeline.ValidatorChainMapping{ + Stage: pipeline.StageExtract, + Module: combatextract.Key, + Validators: []pipeline.ModuleBinding{ + pipeline.Binding(validjson.Key), + pipeline.Binding(validjsonschema.Key), + pipeline.Binding(combatshape.Key), + pipeline.Binding(combatsourcerefs.Key), + pipeline.Binding(combatrelatedness.Key), + }, + }); err != nil { + return fmt.Errorf("register dnd combat turns validator chain: %w", err) + } + if err := registries.ValidatorChains.Register(pipeline.ValidatorChainMapping{ + Stage: pipeline.StageNormalize, + Module: combatnormalize.Key, + Validators: []pipeline.ModuleBinding{ + pipeline.Binding(validjson.Key), + pipeline.Binding(validjsonschema.Key), + pipeline.Binding(combatshape.Key), + pipeline.Binding(combatinvariants.Key), + pipeline.Binding(combatsourcerefs.Key), + pipeline.Binding(combatrelatedness.Key), + }, + }); err != nil { + return fmt.Errorf("register dnd combat turns normalize validator chain: %w", err) + } return nil } @@ -173,6 +228,52 @@ func appendNPCLists(values []dnd.NPCList) (dnd.NPCList, error) { return combined, nil } +func appendCombatTurnLists(values []dnd.CombatTurnList) (dnd.CombatTurnList, error) { + count := 0 + present := false + for _, value := range values { + if value.CombatTurns != nil { + present = true + } + count += len(value.CombatTurns) + } + if !present { + return dnd.CombatTurnList{}, nil + } + combined := dnd.CombatTurnList{CombatTurns: make([]dnd.CombatTurn, 0, count)} + for _, value := range values { + for _, turn := range value.CombatTurns { + combined.CombatTurns = append(combined.CombatTurns, cloneCombatTurn(turn)) + } + } + return combined, nil +} + +func cloneCombatTurn(value dnd.CombatTurn) dnd.CombatTurn { + clone := value + if value.Round != nil { + round := *value.Round + clone.Round = &round + } + if value.Actions != nil { + clone.Actions = make([]dnd.CombatAction, len(value.Actions)) + for index, action := range value.Actions { + clone.Actions[index] = action + if action.Targets != nil { + clone.Actions[index].Targets = append([]string(nil), action.Targets...) + } + if action.Resolution != nil { + resolution := *action.Resolution + clone.Actions[index].Resolution = &resolution + } + } + } + if value.SourceRefs != nil { + clone.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...) + } + return clone +} + func validateRegistries(registries pipeline.Registries, assets *llm.AssetRegistry) error { switch { case registries.Chunkers == nil: diff --git a/internal/modules/dnd/register/register_test.go b/internal/modules/dnd/register/register_test.go index aaa27ef..cfa124e 100644 --- a/internal/modules/dnd/register/register_test.go +++ b/internal/modules/dnd/register/register_test.go @@ -7,12 +7,15 @@ import ( "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/llm" "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" npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" + combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns" npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs" spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells" ) @@ -24,12 +27,13 @@ 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}) - assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, npcnormalize.Key, pipeline.DefaultNormalizeModule}) - assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind}) - assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind}) - assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind}) + assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", npcextract.Key, combatextract.Key}) + assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, npcnormalize.Key, combatnormalize.Key, pipeline.DefaultNormalizeModule}) + assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind}) + assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind}) + assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(npcnormalize.Key), []contracts.ArtifactKind{dnd.NPCListKind}) + assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(combatnormalize.Key), []contracts.ArtifactKind{dnd.CombatTurnListKind}) assertContainsKeys(t, "validators", registries.Validators.RegisteredKeys(), []string{ "extract/dnd/npcs/shape", "extract/dnd/npcs/source_refs", @@ -39,6 +43,10 @@ func TestRegisterAddsDNDFamily(t *testing.T) { "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", }) @@ -77,6 +85,27 @@ func TestRegisterAddsDNDFamily(t *testing.T) { if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, npcnormalize.Key); !reflect.DeepEqual(got, npcNormalizeChain) { t.Fatalf("NPC normalize validator chain = %#v, want %#v", got, npcNormalizeChain) } + 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"), + } + if got := registries.ValidatorChains.Validators(pipeline.StageExtract, combatextract.Key); !reflect.DeepEqual(got, combatExtractChain) { + t.Fatalf("combat extract validator chain = %#v, want %#v", got, combatExtractChain) + } + 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.StageNormalize, combatnormalize.Key); !reflect.DeepEqual(got, combatNormalizeChain) { + t.Fatalf("combat normalize validator chain = %#v, want %#v", got, combatNormalizeChain) + } if got := registries.ValidatorChains.Validators(pipeline.StageMerge, npcextract.Key); got != nil { t.Fatalf("NPC merge validator chain = %#v, want absent", got) } @@ -99,11 +128,18 @@ func TestRegisterAddsDNDFamily(t *testing.T) { "dnd.npcs/sharedassets/common-dnd-system.md", "dnd.npcs/sharedassets/common-dnd-transcript.md", "dnd.npcs/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", }) assertAssetNamesContain(t, assets.SchemaFS, []string{ "dnd_scenes.v1.json", "dnd_spells_llm.v1.json", "dnd_npcs_llm.v1.json", + "dnd_combat_turns_llm.v1.json", }) if spec, ok := registries.Chunkers.Spec("dnd/scenes"); !ok || spec.Key != "dnd/scenes" { t.Fatalf("scene chunker spec = %#v, present = %t; want family-owned spec", spec, ok) @@ -120,6 +156,12 @@ func TestRegisterAddsDNDFamily(t *testing.T) { if spec, ok := registries.Normalizers.Spec(npcnormalize.Key); !ok || spec.ArtifactKind != dnd.NPCListKind || spec.Stage != pipeline.StageNormalize { t.Fatalf("NPC normalizer spec = %#v, present = %t; want dnd NPC-list artifact", spec, ok) } + if spec, ok := registries.Extractors.Spec(combatextract.Key); !ok || spec.ArtifactKind != dnd.CombatTurnListKind { + t.Fatalf("combat extractor spec = %#v, present = %t; want dnd combat-turn-list artifact", spec, ok) + } + 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) + } } func TestAppendNPCListsPreservesOrderAndArrayPresence(t *testing.T) { @@ -143,6 +185,44 @@ func TestAppendNPCListsPreservesOrderAndArrayPresence(t *testing.T) { } } +func TestAppendCombatTurnListsPreservesOrderPresenceAndOwnership(t *testing.T) { + round := 1 + resolution := "hit" + targets := []string{"Mira"} + refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}} + input := []dnd.CombatTurnList{ + {CombatTurns: []dnd.CombatTurn{{Actor: "first", Round: &round, Actions: []dnd.CombatAction{{Targets: targets, Resolution: &resolution}}, SourceRefs: refs}}}, + {CombatTurns: []dnd.CombatTurn{{Actor: "second"}}}, + } + got, err := appendCombatTurnLists(input) + if err != nil { + t.Fatalf("appendCombatTurnLists() error = %v, want nil", err) + } + if len(got.CombatTurns) != 2 || got.CombatTurns[0].Actor != "first" || got.CombatTurns[1].Actor != "second" { + t.Fatalf("combat turns = %#v, want chunk order", got.CombatTurns) + } + if got.CombatTurns[0].Round == &round || &got.CombatTurns[0].Actions[0].Targets[0] == &targets[0] || got.CombatTurns[0].Actions[0].Resolution == &resolution || &got.CombatTurns[0].SourceRefs[0] == &refs[0] { + t.Fatal("appendCombatTurnLists() retained nested input aliases") + } + tests := []struct { + name string + in []dnd.CombatTurnList + want dnd.CombatTurnList + }{ + {name: "no values", in: nil, want: dnd.CombatTurnList{}}, + {name: "nil values", in: []dnd.CombatTurnList{{}, {}}, want: dnd.CombatTurnList{}}, + {name: "present empty", in: []dnd.CombatTurnList{{CombatTurns: []dnd.CombatTurn{}}}, want: dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{}}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := appendCombatTurnLists(test.in) + if err != nil || !reflect.DeepEqual(got, test.want) { + t.Fatalf("appendCombatTurnLists() = %#v, error = %v, want %#v", got, err, test.want) + } + }) + } +} + func TestRegisterRejectsMissingDNDDependenciesBeforeMutation(t *testing.T) { tests := []struct { name string diff --git a/internal/modules/integration/dnd_combat_runner_test.go b/internal/modules/integration/dnd_combat_runner_test.go new file mode 100644 index 0000000..bcadae7 --- /dev/null +++ b/internal/modules/integration/dnd_combat_runner_test.go @@ -0,0 +1,358 @@ +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" + 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" + "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{ + combatTestInvalidResponse(), + combatTestTurnResponse("The Greencloak", "turn", "watches", "The Greencloak", 1, 1), + combatTestTurnResponse("Mira Thorn", "reaction", "asks", "Hooded Guard", 2, 2), + combatTestTurnResponse("Hooded Guard", "turn", "attacks", "The Greencloak", 3, 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, "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[0].Actions[0].Targets[0] != "Mira Thorn" || value.CombatTurns[1].Actor != "Mira Thorn" || value.CombatTurns[2].Actor != "Hooded Guard" { + t.Fatalf("normalized combat output = %#v, want ordered canonical actors and targets", 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, "combat_turn_not_near_source") || !hasCombatWarning(output.Warnings, combatnormalize.ReasonCodeActorCanonicalized) || !hasCombatWarning(output.Warnings, combatnormalize.ReasonCodeTargetCanonicalized) { + t.Fatalf("warnings = %#v, want relatedness and registry normalization warnings", output.Warnings) + } + if len(output.Manifest.References) != 2 { + t.Fatalf("manifest references = %#v, want separate extract and normalize 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"] == "" { + t.Fatalf("extractor metadata = %#v, want registry digest and count", 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 TestSequentialNPCOutputGroundsCombatAtBothStageLocalReferences(t *testing.T) { + registries := productionNPCRegistries(t) + catalog := moduleCatalog(registries) + configValue := loadSequentialCombatPipelineConfig(t) + npcEffective, err := configValue.Resolve(config.ResolveInput{PipelineID: "dnd-npcs", Catalog: catalog}) + if err != nil { + t.Fatalf("Resolve(NPC) error = %v", err) + } + npcClient := &fakeNPCProductionLLMClient{response: npcProductionResponse{NPCs: []npcProductionRecord{ + {Name: "Mira Thorn", Aliases: []string{"The Greencloak", "Mira"}, Description: "A ranger.", Relationships: []npcProductionRelationship{}, SourceRefs: []npcProductionSourceRef{{StartUnitID: 1, EndUnitID: 2}}}, + {Name: "Hooded Guard", Aliases: []string{}, Description: "A sentry.", Relationships: []npcProductionRelationship{}, SourceRefs: []npcProductionSourceRef{{StartUnitID: 3, EndUnitID: 3}}}, + }}} + npcOutput, err := runPreparedPipeline(t, registries, npcEffective.ResolvedPipeline, npcClient, pipeline.RunInput{RawInput: readNPCFixture(t)}) + if err != nil || len(npcOutput.NormalizeOutputs) != 1 { + t.Fatalf("NPC run error = %v output = %#v, want one normalized NPC lane", err, npcOutput.NormalizeOutputs) + } + npcPayload := npcOutput.NormalizeOutputs[0].Artifact.Content + if _, err := npccodec.New().Decode(npcPayload); err != nil { + t.Fatalf("Decode(NPC output) error = %v", err) + } + npcPath := filepath.Join(t.TempDir(), "npc-run", "lanes", "npcs.json") + if err := os.MkdirAll(filepath.Dir(npcPath), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(npcPath, npcPayload, 0o600); err != nil { + t.Fatal(err) + } + + combatEffective, err := configValue.Resolve(config.ResolveInput{ + PipelineID: "dnd-combat", + Catalog: catalog, + ReferenceOverrides: []pipeline.ReferenceBinding{ + {Stage: pipeline.StageExtract, LaneID: "combat", SlotName: "npcs", Source: npcPath, BindingSource: contracts.ReferenceBindingSourceCLI}, + {Stage: pipeline.StageNormalize, LaneID: "combat", SlotName: "npcs", Source: npcPath, BindingSource: contracts.ReferenceBindingSourceCLI}, + }, + }) + if err != nil { + t.Fatalf("Resolve(combat) error = %v", err) + } + materialized, warnings, err := pipeline.MaterializeReferences(combatEffective.ResolvedPipeline, catalog, pipeline.ReferenceMaterializationOptions{}) + if err != nil || len(warnings) != 0 { + t.Fatalf("MaterializeReferences() error = %v warnings = %#v", err, warnings) + } + client := &fakeCombatLLMClient{responses: []string{combatTestTurnResponse("The Greencloak", "turn", "watches", "Hooded Guard", 1, 1)}} + output, err := runPreparedPipeline(t, registries, materialized, client, pipeline.RunInput{RawInput: readNPCFixture(t)}) + if err != nil { + t.Fatalf("Run(combat) error = %v", err) + } + value, err := combatcodec.New().Decode(output.NormalizeOutputs[0].Artifact.Content) + if err != nil { + t.Fatalf("Decode(combat output) error = %v", err) + } + if len(value.CombatTurns) != 1 || value.CombatTurns[0].Actor != "Mira Thorn" || value.CombatTurns[0].Actions[0].Targets[0] != "Hooded Guard" { + t.Fatalf("combat value = %#v, want canonical actor and target", value) + } + for _, ref := range value.CombatTurns[0].SourceRefs { + if ref.SourceID != "npc-session" { + t.Fatalf("combat source ref = %#v, want transcript evidence only", ref) + } + } + if len(output.Manifest.References) != 2 { + t.Fatalf("manifest references = %#v, want both stage-local NPC provenance entries", output.Manifest.References) + } + for _, provenance := range output.Manifest.References { + if provenance.SlotName != "npcs" || provenance.LaneID != "combat" || !strings.Contains(provenance.OriginURI, "npcs.json") || provenance.BindingSource != contracts.ReferenceBindingSourceCLI { + t.Fatalf("NPC provenance = %#v, want combat stage-local CLI binding", provenance) + } + } + if len(client.requests) != 1 || string(client.requests[0].Inputs[combatextract.NPCRegistryReferenceSlot].Content) != string(npcPayload) || client.requests[0].Inputs[combatextract.NPCRegistryReferenceSlot].OriginURI != "" { + t.Fatalf("combat NPC prompt input = %#v, want canonical payload without path provenance", client.requests) + } +} + +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", Aliases: []string{"The Greencloak", "Mira"}, Description: "A ranger.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "prior-npc-session", StartUnitID: 1, EndUnitID: 1}}}, + {ID: identity.DeriveID("Hooded Guard"), Name: "Hooded Guard", Aliases: []string{}, Description: "A sentry.", Relationships: []dnd.NPCRelationship{}, 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 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}}, + 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 loadSequentialCombatPipelineConfig(t *testing.T) config.Config { + t.Helper() + fileConfig, err := config.LoadFileConfig(repositoryPathForIntegration("examples", "dnd-npc-combat-sequential.config.yml")) + if err != nil { + t.Fatalf("LoadFileConfig() error = %v", err) + } + cfg := config.Default() + if err := cfg.ApplyFileConfig(fileConfig); err != nil { + t.Fatalf("ApplyFileConfig() error = %v", err) + } + 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 combatTestInvalidResponse() string { + return `{"combat_turns":[{"actor":"","turn_kind":"turn","round":1,"actions":[{"category":"attack","declaration":"watches","targets":[],"resolution":null}],"summary":"invalid candidate","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}` +} + +func combatTestTurnResponse(actor, turnKind, declaration, target string, round, unit int) string { + return fmt.Sprintf(`{"combat_turns":[{"actor":%q,"turn_kind":%q,"round":%d,"actions":[{"category":"attack","declaration":%q,"targets":[%q],"resolution":"observed"}],"summary":%q,"source_refs":[{"start_unit_id":%d,"end_unit_id":%d}]}]}`, actor, turnKind, round, declaration, target, declaration, 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)