Compose production D&D combat pipeline

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

View File

@@ -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:

View File

@@ -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=<npc-run>/lanes/npcs.json
combat.normalize.npcs=<npc-run>/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

View File

@@ -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/<lane-id>.json` by the JSON output module, and warnings and rejection
summaries remain in their shared companion files.

View File

@@ -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

View File

@@ -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`

View File

@@ -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

View File

@@ -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

View File

@@ -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. |

View File

@@ -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/<run-id>/lanes/npcs.json \
--reference combat.normalize.npcs=./npc-output/<run-id>/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:

View File

@@ -1,6 +1,6 @@
# D&D Combat-Turn Extraction
Status: Accepted.
Status: Complete.
## Purpose

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

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

View File

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

View File

@@ -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:

View File

@@ -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

View File

@@ -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)