diff --git a/docs/config.md b/docs/config.md index f79996f..959432b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -423,14 +423,14 @@ 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: +The `dnd/combat-turns` extractor declares the optional campaign slots and the +structured `npcs` slot. Campaign references guide only the LLM extraction +stage. The deterministic normalizer declares only `npcs`, whose prepared +immutable registry supports the same actor and target canonicalization. Each +`npcs` slot accepts 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 diff --git a/docs/integrations/dnd-combat-turn-artifacts.md b/docs/integrations/dnd-combat-turn-artifacts.md index 42af1df..ff15cf7 100644 --- a/docs/integrations/dnd-combat-turn-artifacts.md +++ b/docs/integrations/dnd-combat-turn-artifacts.md @@ -93,13 +93,16 @@ inputs, and optionally the deprecated `roster` reference through the shared party mapping. The optional `npcs` reference is an approved normalized NPC artifact used only for identity grounding; it never supplies combat evidence. -The private response shape is the same as the durable turn/action shape except -that source references contain only `start_unit_id` and `end_unit_id`. The +The private response envelope has the same fields and JSON types as the durable +turn/action shape except that source references contain only `start_unit_id` +and `end_unit_id`. It enforces required and nullable field presence, types, and +unknown-field rejection, while deterministic validators own enum membership, +non-empty values and collections, and positive-number requirements. The extractor assigns the current source ID, removes exact duplicate ranges, and stable-sorts turns by the earliest valid source-document position. Numeric unit IDs are identifiers; source-document slice position determines chronology. -Malformed candidate fields remain in the typed result for deterministic -validators to report. +Semantically malformed candidate fields remain in the typed result for the +configured validation and retry boundary. ## Deterministic candidate validation @@ -125,10 +128,10 @@ schema, combat shape, source references, then source relatedness. ## Normalization boundary The standalone normalizer uses key `dnd/combat-turns`, requires `merged`, -provides `normalized`, accepts no options, and accepts the same optional -`players`, `party`, `glossary`, deprecated `roster`, and structured `npcs` -reference slots as extraction. The NPC registry is resolved during -preparation; runtime normalization uses that immutable prepared view. +provides `normalized`, accepts no options, and accepts only the optional +structured `npcs` reference. Campaign references are LLM extraction context and +are not normalizer inputs. The NPC registry is resolved during preparation; +runtime normalization uses that immutable prepared view. Normalization policy is `dnd.combat_turns.normalize.v1`. It display-normalizes actor, summary, declarations, targets, and non-null resolutions; canonicalizes diff --git a/docs/internal/llm.md b/docs/internal/llm.md index a5258bc..288dad3 100644 --- a/docs/internal/llm.md +++ b/docs/internal/llm.md @@ -104,9 +104,13 @@ The small framework registry contains only generic test schemas; production schemas remain package-owned. 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 +JSON inputs and private response schemas. The combat private schema owns the +transport envelope—required fields, JSON types, nullability, and unknown-field +rejection—while its deterministic validators own semantic constraints such as +enum membership, non-empty values and collections, and positive numbers. 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 for both extractors. When an NPC registry diff --git a/docs/internal/modules.md b/docs/internal/modules.md index 05b84ec..e3af44a 100644 --- a/docs/internal/modules.md +++ b/docs/internal/modules.md @@ -232,12 +232,17 @@ the shared transcript, campaign-reference, and NPC-grounding prompt inputs. It maps the private response to `dnd.CombatTurnList`, assigns the current source identity, removes exact duplicate source ranges, and orders turns by valid 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 +deterministic validators. Its package-owned private response schema enforces +only the structural JSON envelope; semantic artifact constraints remain with +the validator chain. Its prepared metadata and checkpoint fingerprints contain only prompt/schema/mapping identities plus an optional NPC registry digest. The package exposes typed registration and is included in the production D&D registrar with the default combat extraction chain. +The combat normalizer accepts only the optional structured NPC registry. +Campaign references remain extractor-only LLM context and are not materialized +for deterministic normalization. + ### `internal/modules/dnd/normalize/npcs` The NPC normalizer performs deterministic identity-aware consolidation in diff --git a/docs/roadmap/dnd-combat-turn-extraction.md b/docs/roadmap/dnd-combat-turn-extraction.md deleted file mode 100644 index 5fb97f3..0000000 --- a/docs/roadmap/dnd-combat-turn-extraction.md +++ /dev/null @@ -1,201 +0,0 @@ -# D&D Combat-Turn Extraction - -Status: Complete. - -## Purpose - -Add a production D&D combat-turn pipeline that converts session transcripts -into an ordered, evidence-backed account of combat activity. This is the next -recommended increment because spell and NPC extraction now provide the two -most important grounding vocabularies, while combat turns are the next useful -artifact explicitly identified by the sequential-pipeline strategy. - -The feature uses the existing fixed pipeline and scene chunks for a more -structurally demanding artifact without requiring automatic workflow -composition, richer scene routing, or generic semantic deduplication. - -## Target Outcome - -An operator can: - -1. run the NPC pipeline against a transcript; -2. run a combat-turn pipeline over the same transcript, explicitly binding the - normalized NPC artifact as a reference; -3. receive an ordered JSON list of validated combat turns and interrupting - combat events; and -4. trace every reported declaration and immediate resolution back to the - transcript units that support it. - -The runs remain independent CLI invocations. Notarius does not discover prior -outputs, schedule dependent pipelines, or reconcile spell and combat artifacts -automatically. - -## Combat-Turn Artifact - -The new typed artifact represents an ordered list of combat-turn records. Each -record contains: - -- the canonical in-world actor; -- a turn kind distinguishing an ordinary turn from a reaction, legendary - action, lair action, or other interrupting combat event; -- the combat round as a positive integer when it is explicit or unambiguous, - and `null` otherwise; -- one or more ordered actions; -- a concise turn-level summary; and -- one or more transcript source references that collectively support every - reported field. - -Each action contains a conservative category, a concise declaration, zero or -more targets, and its immediate observed resolution. Resolution is `null` when -the cited passage establishes the declaration but no immediate resolution. -Categories cover attacks, spells, movement, items, ability checks, saving -throws, condition or state changes, and an `other` fallback without requiring -the transcript to use formal rules terminology. - -Reactions and similar out-of-turn events appear at the point where they occur -in transcript chronology rather than being moved to the reacting creature's -later turn. Output order is derived from cited source position; numeric source -unit IDs are identifiers, not chronology. - -## Inclusion And Evidence Policy - -Include a record when the transcript establishes that an in-world participant -takes a combat turn or performs a discrete interrupting combat event. Report -only declarations and their immediate resolutions, including directly -associated rolls, damage, healing, movement, conditions, or target outcomes. -For every detail reported, cite all supporting transcript units. - -Exclude: - -- initiative setup that contains no turn or combat event; -- tactical planning, table talk, rules lookup, and hypothetical actions; -- corrected or abandoned declarations that never become an attempted action, - except where the correction is necessary to describe the final declaration; -- recap of combat that occurred outside the current source passage; and -- downstream consequences that occur on later turns or elsewhere in the - scene. - -Do not infer a round number, action-economy classification, target, roll, -amount, condition, or outcome merely from D&D rules knowledge. Preserve the -session as played, and attribute nonstandard rulings to the GM or table when -that detail is relevant to the immediate resolution. - -## Identity And Reference Grounding - -The extractor uses the existing D&D transcript, player, party, and glossary -prompt inputs. It also accepts the normalized NPC artifact through an optional -structured `npcs` reference slot with the same validation, size, provenance, -content-safety, and semantic-checkpoint rules used by spell extraction. - -The NPC registry helps select canonical actors and targets and recognize -aliases. It does not establish that combat occurred and never becomes source -evidence. Unmatched actors and targets remain permitted because a session may -introduce combatants that were omitted from an earlier NPC run. - -The deterministic normalizer also accepts the registry. Exact canonical-name -or alias matches are rewritten to the registry's canonical display name for -actors and targets; ambiguous aliases and unmatched values remain unchanged -for validation and human review. Opaque player and party references continue -to guide the LLM but are not parsed into a new roster contract in this scope. - -## Extraction, Validation, And Normalization - -The extractor uses one structured LLM call per supplied chunk and returns typed -combat-turn candidates. It must preserve malformed candidates for the normal -validation and retry boundary rather than silently repairing unsupported -content in mapping code. - -The production default validator chain is deterministic and covers: - -- required fields, arrays, nullable-round shape, and supported enum values; -- a required non-empty evidence collection, source identity, unit existence, - and range order; -- actor and declared-action relatedness to cited transcript text, expressed as - bounded warnings where deterministic substring checks are only advisory; and -- normalized identity and duplicate invariants. - -Normalization is deterministic and conservative. It normalizes display -whitespace, canonicalizes exact NPC identity matches, orders and deduplicates -exact source references, and collapses only exact duplicate records with the -same normalized actor, turn kind, round value, and complete valid evidence set. -The first record is retained without synthesizing or merging prose. Every -mutation or collapse emits a scoped warning. - -An LLM-backed validator and semantic reconciliation normalizer are outside the -production chain. Human evaluation owns judgments such as whether the -model grouped a long turn correctly or omitted a subtle reaction. - -## Scene Strategy - -The combat extractor processes every chunk delivered by the configured -chunker. Existing D&D scene annotations remain useful context, but a -`primary_mode` value does not suppress an LLM call. Avoiding a -call based on an imperfect non-combat classification could silently lose the -very turns this artifact is intended to recover. - -Scene-classification and routing improvements remain separate future work. The -combat pipeline stays compatible with generic chunks that carry no D&D -annotation. - -## Provenance And Checkpoints - -The extractor, normalizer, and validators report stable semantic identities -through the existing manifest and prepared-component fingerprint contracts. -Prompt, private response schema, artifact policy, normalization policy, and a -bound NPC registry's semantic digest must invalidate incompatible checkpoints. - -Metadata and fingerprints contain identities, counts, and digests only. They -must not contain transcript text, combat records, NPC names, reference paths, -or raw reference content. Preparation failures remain bounded and content-safe -and occur before checkpoint handlers or pipeline execution are constructed. - -## Evaluation - -Use human-reviewed development runs rather than exact model-output goldens. -Evaluate at least the existing transcripts used for spell and NPC development, -with separate attention to: - -- combat-turn detection precision and recall; -- actor and target identity; -- turn boundaries and chronological order; -- reactions and other interrupting events; -- declaration and immediate-resolution fidelity; -- round-number restraint; -- completeness and precision of evidence; and -- duplicate behavior at chunk or scene boundaries. - -Frontier and inexpensive development models may differ substantially in -semantic quality. Deterministic tests should protect structure, provenance, -identity, ordering, normalization, and orchestration rather than require exact -combat prose. - -## Out Of Scope - -- Initiative trackers, current hit points, complete encounter-state replay, or - rules-engine validation. -- Automatic comparison or reconciliation with spell artifacts. -- NPC discovery, campaign-wide entity persistence, or deterministic PC-roster - parsing. -- LLM-backed validation or generic LLM-assisted deduplication. -- Automatic pipeline scheduling, prior-output discovery, or DAG execution. -- Scene-classification changes or skipping provider calls for non-combat - chunks. -- Narrative summaries outside the immediate combat-turn scope. - -## Acceptance Criteria - -- A selectable D&D combat lane produces a typed, durable JSON artifact with the - turn, action, chronology, and evidence semantics above. -- The default deterministic validator chain rejects malformed or invalidly - sourced records and emits bounded advisory relatedness warnings. -- Deterministic normalization canonicalizes exact NPC identities and collapses - only safely identical combat records while preserving chronology and - evidence. -- A normalized NPC artifact can be bound explicitly to both extraction and - normalization without becoming combat evidence. -- Semantic contracts and structured references participate in checkpoint - identity without leaking application content. -- Maintained configuration and operational examples demonstrate independent - NPC and combat invocations over the same transcript. -- Human review on representative sessions demonstrates useful turn extraction - without requiring scene-based call suppression or semantic reconciliation. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md deleted file mode 100644 index 1822641..0000000 --- a/docs/roadmap/implementation.md +++ /dev/null @@ -1,515 +0,0 @@ -# D&D Combat-Turn Extraction Implementation Plan - -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 -owns implementation sequencing and concrete technical decisions. - -Do not introduce a DAG, prior-run discovery, encounter state engine, scene-based -provider-call suppression, LLM-backed validator, or semantic reconciliation. -Preserve the fixed `input -> chunk -> extract -> merge -> normalize -> output` -architecture and the existing framework retry, warning, rejection, checkpoint, -debug, and output contracts. - -## Cross-Stage Decisions - -### Production Identities - -Use these exact identities: - -- artifact kind: `dnd/combat-turn-list`; -- extractor key and normalizer key: `dnd/combat-turns`; -- extractor capability: `dnd.combat_turns`; -- prompt ID: `dnd.combat_turns`, version `v1`; -- private response-schema key: `dnd_combat_turns_llm`; -- private schema ID: `notarius.dnd.combat_turns.llm`; -- private schema name: `notarius_dnd_combat_turns_llm_v1`; -- durable schema ID: `notarius.dnd.combat_turns`; -- durable schema name: `notarius_dnd_combat_turns_v1`; -- durable schema version: `v1`; and -- durable media type: `application/json`. - -The private response schema omits `source_id`; mapping code assigns the current -source identity. No combat-turn ID is added in v1. - -### Canonical Go And JSON Shape - -Add these types to the canonical D&D model: - -```go -type CombatTurnList struct { - CombatTurns []CombatTurn `json:"combat_turns"` -} - -type CombatTurn struct { - Actor string `json:"actor"` - TurnKind CombatTurnKind `json:"turn_kind"` - Round *int `json:"round"` - Actions []CombatAction `json:"actions"` - Summary string `json:"summary"` - SourceRefs []source.SourceRef `json:"source_refs"` -} - -type CombatAction struct { - Category CombatActionCategory `json:"category"` - Declaration string `json:"declaration"` - Targets []string `json:"targets"` - Resolution *string `json:"resolution"` -} -``` - -Define string types and only these lowercase durable values: - -- `CombatTurnKind`: `turn`, `reaction`, `legendary_action`, `lair_action`, - `other`; -- `CombatActionCategory`: `attack`, `spell`, `movement`, `item`, - `ability_check`, `saving_throw`, `condition`, `other`. - -All object fields and the top-level `combat_turns` array are required. -`combat_turns` may be empty. Every turn requires a non-empty actor, supported -turn kind, at least one action, non-empty summary, and at least one source -reference. `round` is either `null` or a positive integer. Every action requires -a supported category, non-empty declaration, and present `targets` array; -targets may be empty but may not contain empty strings. `resolution` is either -`null` or non-empty after trimming. Unknown fields are rejected at every object -level. - -Strict raw decoding must distinguish a missing required `round` or `resolution` -key from an explicitly present JSON `null`, even though the accepted Go value -represents `null` with a nil pointer. Use presence-aware wire decoding or raw -key validation at codec and private-response boundaries; do not silently turn a -missing nullable field into an accepted null. - -One turn-level source-reference collection collectively supports the actor, -kind, round, actions, summary, targets, and resolutions. Do not add per-action -references in v1. - -### Ordering And Duplicate Identity - -Source-document slice position is chronology. Numeric unit IDs are identifiers -only. An item's earliest evidence position is the minimum valid start-unit -index across all its ranges. Stable ordering puts records with valid evidence -in ascending earliest-position order, preserves encounter order for ties, and -puts records without valid evidence after valid records without reordering -them. - -The normalizer may collapse two records only when all of these match: - -- actor under `npcs/identity.ComparisonKey`; -- exact `turn_kind`; -- both round values, including `nil` versus a value; and -- the complete, non-empty, canonically ordered set of exact source references. - -Every reference in the duplicate key must pass `source.ValidateRef` against the -current document. Invalid or empty evidence never participates in duplicate -collapse. Actions and prose do not participate in identity because two model -representations of the same cited turn should collapse; retain the complete -first record without merging prose or action lists. - -### NPC Registry Contract And Prompt Reuse - -Promote NPC registry preparation from the spell extractor into -`internal/modules/dnd/npcs/registry`. It owns: - -- slot name `npcs` and maximum size 1,048,576 bytes; -- exact-one-item and `application/json` validation when bound; -- approved NPC codec decoding, whole-registry identity validation, canonical - re-encoding, semantic SHA-256 digest, and content-safe bounded failures; -- the exact unbound prompt value `{"npcs":[]}`; -- immutable accessors for bound state, a defensive NPC list, canonical bytes, - digest, count, and prompt input; and -- exact canonical-name/alias lookup using the NPC identity comparison policy. - -Registry source references may identify another session and are not checked -against the current source. The resolver never returns content, names, aliases, -paths, or raw decoder errors in failures. Identity failures use issue codes and -record/alias indexes under the established 20-issue, 128-rune, and 4,096-byte -limits. - -Move the generic bounded diagnostic helper from `dnd/npcs/diagnostics` to -`dnd/shared/diagnostics`, updating existing NPC consumers. This is an internal -package move with no diagnostic-policy change. - -Add `common-dnd-npcs.md` to the shared D&D prompt assets. Its generic policy -allows exact canonical participant names and alias recognition while stating -that registry content is context, not event evidence. Both spell and combat -prompts use this exact fragment immediately after the existing shared campaign -reference message. Remove the spell-owned NPC fragment and include the shared -fragment in both prompt hashes. This intentionally changes the spell prompt -fingerprint once but must not change its request inputs, metadata shape, -registry semantics, or output contract. - -Both combat extraction and normalization declare the same optional registry -slot. A bound registry contributes manifest metadata `npc_registry_digest` and -`npc_count` and a local checkpoint fingerprint `npc_registry`. An absent slot -contributes neither metadata fields nor that fingerprint. - -### Validation And Diagnostics - -Add deterministic validators with these exact identities: - -| Validator key | Result reason | Policy fingerprint | -| --- | --- | --- | -| `extract/dnd/combat-turns/shape` | rejection `invalid_combat_turn_shape` | `dnd.combat_turns.validator.shape.v1` | -| `extract/dnd/combat-turns/source_refs` | rejection `invalid_combat_turn_source_refs` | `dnd.combat_turns.validator.source_refs.v1` | -| `extract/dnd/combat-turns/source_relatedness` | warning `combat_turn_not_near_source` | `dnd.combat_turns.validator.source_relatedness.v1` | -| `normalize/dnd/combat-turns/invariants` | rejection `invalid_combat_turn_normalization` | `dnd.combat_turns.validator.normalized.v1` | - -Shape owns required arrays, enum membership, nullable fields, positive rounds, -and non-empty strings. Source-reference validation owns source identity, unit -existence, and range order through `source.ValidateRef`. Later validators defer -when shape is invalid; relatedness also defers when any source range is invalid. - -Relatedness combines the turn's cited units once in document order, removing -overlap. The actor is related when its NPC comparison key is a substring of the -comparison-normalized cited text. For each action declaration, split its -comparison-normalized form on runes that are not Unicode letters or digits, -retain tokens of at least four Unicode code points, and require at least one -retained token to occur as a complete cited-text token. No retained token means -the declaration is unrelated. Emit at most one approved warning per turn, -listing whether the actor and which action indexes were not related. Do not -check targets deterministically. - -The normalize invariant validator requires display-normalized strings, -comparison-unique targets within each action, canonical exact source-reference -order without duplicates, chronological record order for valid evidence, and -absence of the duplicate identity defined above. It does not require every -actor or target to exist in the optional NPC registry. - -All rejection and warning messages use the shared diagnostic helper: at most 20 -displayed issues, at most 128 Unicode code points per displayed value, valid -UTF-8, at most 4,096 bytes, Go quoting for control characters, and the exact -total omitted count. Validators use strict empty-options decoders and provide -one local `policy` checkpoint fingerprint with the value in the table. - -### Normalization Policy - -Use normalization policy `dnd.combat_turns.normalize.v1` and these warning -reason codes: - -- `combat_turn_fields_normalized`; -- `combat_actor_canonicalized`; -- `combat_target_canonicalized`; -- `source_references_normalized`; -- `combat_turns_reordered`; and -- `duplicate_combat_turn_collapsed`. - -Deep-clone all nested slices and pointers. Normalize actor, summary, -declarations, targets, and non-null resolutions by collapsing Unicode -whitespace through `npcs/identity.NormalizeDisplay`. Preserve `nil` resolution. -Remove comparison-duplicate targets while retaining the first display value and -target order. Do not deduplicate or reorder actions. - -When a registry is bound, rewrite an actor or target only if its comparison key -matches exactly one validated canonical name or alias. Preserve unmatched -values. Registry validation makes ambiguous lookup impossible at preparation; -do not guess or perform fuzzy matching. - -Sort every source-reference list by exact `source_id`, `start_unit_id`, and -`end_unit_id`, then remove exact duplicates. Stable-sort records by the -chronology rule before duplicate detection. Collapse duplicates in that order -and retain the first record unchanged after its per-record normalization. - -Warning scopes use merged input indexes such as `combat_turns[3]`, even after -sorting or collapse. Emit one bounded warning for each affected record or -collapsed group; group warnings identify the retained and removed input indexes. -Only warnings from a validator-approved attempt become durable, under existing -framework policy. - -The normalizer exposes manifest metadata `normalization_policy`, -`identity_policy`, and optional registry digest/count. Its checkpoint -fingerprints are `normalization_policy`, `identity_policy`, and optional -`npc_registry`; bump the normalization policy when any transformation, -ordering, or duplicate rule changes. - -### Testing Rules - -Follow `docs/policy/testing.md`. Tests are offline and deterministic. Use a fake -structured LLM only at the completion boundary. Protect durable shapes, domain -invariants, preparation failures, reference sensitivity, registration, and one -representative assembled workflow. - -Do not add prompt-prose change detectors or tests requiring particular words or -phrases. Prompt tests may verify registration, message/input wiring, prompt and -schema identities, stable shared-fragment use, and absence of raw content from -metadata or diagnostics. Human evaluation owns semantic output quality. - -## Stage 1: Shared NPC Registry And Prompt Grounding - -### Goal - -Create one reusable, content-safe NPC registry boundary before adding a second -consumer, while preserving spell behavior. - -### Changes - -- Add the domain registry resolver and immutable lookup described above; migrate - spell extraction from its private resolver without changing the spell - extractor's public module contract. -- Relocate bounded D&D diagnostics to the shared domain package and update all - NPC imports and tests. -- Add the shared NPC prompt fragment, move the spell prompt to it, place it - immediately after shared campaign references, and remove the spell-owned - fragment. -- Keep raw reference provenance in framework identity independently from the - semantic registry fingerprint. Preserve the exact empty prompt input and all - 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 while the later combat work remains independently scoped. - -### Tests - -- Move resolver contract tests to the domain package: absent, valid, formatted- - equivalent, malformed, unknown-field, invalid-ID, collision, media type, - item count, byte limit, defensive copies, lookup, digest, and bounded - content-free diagnostics. -- Retain spell-level tests only for spell request wiring, metadata/fingerprint - behavior, and no-regression output; remove duplicated resolver cases. -- Verify the shared prompt asset registers for spells and hashes the shared - fragment without asserting its prose. - -### Completion Check - -Run `go test ./internal/modules/dnd/npcs/... ./internal/modules/dnd/extract/spells ./internal/modules/dnd/validate/npcs/...` -and `git diff --check`. - -## Stage 2: Combat Domain Contract And Codec - -### Goal - -Establish the typed artifact and durable serialization boundary without making -the combat module selectable. - -### Changes - -- Add the canonical combat types, enums, constants, and artifact kind using the - exact shape and values above. -- Add `internal/modules/dnd/codec/combatturns`, following the existing candidate - versus approved codec boundary: - - strict single-value JSON with unknown-field rejection; - - candidate encode/decode preserving validator-visible invalid values; - - approved encode/decode enforcing structural validity only; - - defensive schema and metadata values; and - - metadata containing `combat_turn_count` only. -- Add the durable v1 JSON Schema with required nullable fields, enum values, - source-reference shape, array rules, and `additionalProperties: false`. -- Add `docs/integrations/dnd-combat-turn-artifacts.md`, describing only the - implemented artifact and codec at this stage. - -### Tests - -- Test approved round trips and candidate preservation for every nullable, - enum, required-array, required-string, target, and source-reference boundary. -- Test malformed, trailing, unknown-field, and invalid structural input without - snapshotting complete errors. -- Test defensive schema/metadata copies, nil versus present-empty arrays, and - exact codec identity. -- Keep one compact durable v1 fixture for intentional compatibility coverage. - -### Completion Check - -Run `go test ./internal/modules/dnd/codec/combatturns` and `git diff --check`. - -## Stage 3: Combat Extractor And Extraction Validators - -### Goal - -Implement package-complete LLM extraction and deterministic candidate -validation without production composition. - -### Changes - -- Add `internal/modules/dnd/extract/combatturns` with the exact module, prompt, - schema, capability, artifact, and reference identities above. It accepts no - options and requires `chunks` and `source.transcript`. The prompt manifest is - `dnd.combat_turns.yaml`, uses default profile `gemini-2-flash`, JSON Schema - validation, and zero provider-side repair attempts so pipeline retries remain - the only extraction retry policy. -- Declare existing optional `players`, `party`, `glossary`, and deprecated - `roster` slots plus the optional structured NPC registry slot. Reuse shared - D&D prompt inputs and the shared NPC prompt fragment. -- Embed a private response schema matching the durable shape except that source - ranges omit `source_id`. Require present arrays and nullable round/resolution - values exactly as in the durable contract. -- Prompt for the inclusion, exclusion, chronology, identity, round restraint, - and immediate-resolution policy in the feature roadmap. Instruct the model - that campaign and NPC references disambiguate identities but are never combat - evidence. -- Preserve malformed response values for validators. Canonicalize and exactly - deduplicate source ranges, assign the current source ID, and stable-sort - records by earliest valid source-document position. Do not merge records, - canonicalize NPC names deterministically, or skip any chunk in the extractor. -- Expose prompt and private-schema manifest metadata plus optional registry - digest/count. Fingerprint local names `prompt`, `response_schema`, - `mapping_policy` with value `dnd.combat_turns.extract_mapping.v1`, and optional - `npc_registry`. -- Implement shape, source-reference, and source-relatedness validators with the - exact contracts and policies above. - -### Tests - -- Through a fake LLM, test request identity, profile/session propagation, - chunk-scoped source input, campaign and NPC inputs, response mapping, source - assignment, non-monotonic-unit chronology, invalid-candidate preservation, - cancellation, and contextual provider failures. -- Test prompt/schema registration, required input wiring, metadata and - fingerprint sensitivity, defensive copies, and content redaction without - asserting prompt prose. -- Test each validator's meaningful approval, rejection, deferral, and warning - categories, including Unicode comparison, overlapping evidence, declaration - tokenization, bounded diagnostics, strict options, and typed registration. -- Verify that a bound NPC registry affects grounding metadata and checkpoint - identity but never supplies combat source references. - -### Completion Check - -Run `go test ./internal/modules/dnd/extract/combatturns ./internal/modules/dnd/validate/combatturns/...` -and `git diff --check`. - -## Stage 4: Combat Normalization And Final Invariants - -### Goal - -Implement conservative identity canonicalization, chronological ordering, and -exact-evidence duplicate collapse. - -### Changes - -- Add `internal/modules/dnd/normalize/combatturns` with key - `dnd/combat-turns`, requirements `merged`, capability `normalized`, no - options, and the optional NPC registry slot. -- Resolve and retain the immutable registry during preparation. Runtime uses - the prepared view; it does not parse references repeatedly. -- Apply the exact per-record, registry, source-reference, chronology, duplicate, - warning, metadata, and fingerprint policies above. Never mutate or retain - caller-owned slices, pointers, registry data, or source data. -- Add the normalized-invariants validator. It remains deterministic, accepts no - references of its own, and defers malformed shape or invalid source evidence - to their owning validators. - -### Tests - -- Use table-driven cases for whitespace, nullable resolution, target - deduplication, actor/target registry matches, unmatched names, reference - normalization, non-monotonic source IDs, stable ties, and warning scopes. -- Cover duplicate collapse and non-collapse for every identity dimension, - especially invalid evidence, distinct ranges, different turn kinds, and - `nil` versus numbered rounds. -- Prove actions/prose come only from the first retained record, merged input is - immutable, and all nested output storage is independent. -- Test absent and bound registries, preparation failures, metadata, - fingerprints, policy sensitivity, cancellation, strict options, and module - registration. -- Test normalized-invariant rejection for each owned invariant without - duplicating shape and source-validator case matrices. - -### Completion Check - -Run `go test ./internal/modules/dnd/normalize/combatturns ./internal/modules/dnd/validate/combatturns/...` -and `git diff --check`. - -## Stage 5: Production Composition, Sequential Workflow, And Documentation - -### Goal - -Make the complete combat lane selectable, verify the assembled workflow, and -document current behavior in canonical locations. - -### Changes - -- Extend the D&D registrar with the combat codec, extractor and prompt assets, - typed append-order merger, combat normalizer, typed no-op normalizer, all four - validators, and typed always-accept/always-reject variants. -- Add append behavior that preserves present-empty versus nil output and chunk - record order before normalization. -- Register the extract default chain in this order: - `generic/valid_json`, `generic/valid_json_schema`, combat shape, combat source - references, combat source relatedness. -- Register the normalize default chain in this order: - `generic/valid_json`, `generic/valid_json_schema`, combat shape, normalized - invariants, combat source references, combat source relatedness. -- Do not add a merge validator chain or change framework defaults. -- Add `examples/dnd-combat-turns.config.yml` and - `examples/dnd-npc-combat-sequential.config.yml`. Use version 3, safe relative - state paths, explicit checkpoint `enabled: false`, a generic chunker, combat - extraction with `retries: 2`, and combat normalization. Keep the dynamic NPC - output unbound in the static sequential example. -- Demonstrate runtime binding to both stage-local slots with selectors - `combat.extract.npcs=/lanes/npcs.json` and - `combat.normalize.npcs=/lanes/npcs.json`. -- Update canonical current-behavior documentation: - - Configuration owns module/validator catalogs, reference slots, limits, - default chains, and maintained examples; - - CLI owns the explicit two-selector invocation syntax; - - Operations owns the independent NPC-then-combat workflow and state - sensitivity; - - the combat integration contract owns durable fields, enum values, - evidence, normalization, warnings, and manifest metadata; - - the NPC integration contract notes combat as a consumer without redefining - combat fields; - - JSON output links the new lane payload; and - - internal overview, module, and LLM docs describe concrete packages, - preparation, prompt reuse, and fingerprints. -- At completion, mark this plan and the feature roadmap complete. Remove the - implemented combat proposal from `future.md` while retaining scene-routing, - narrative, generic deduplication, and other unimplemented work. - -### Tests - -- Extend registrar tests for production keys, typed variants, prompt assets, - default chain order, nil dependencies, and duplicate registration behavior. -- Add config/example resolution coverage for stage-local NPC bindings, - capabilities, codec compatibility, strict options, and invalid validator - placement. -- Add one assembled pipeline integration covering extract, retry-capable - validation, append merge, registry-backed normalization, final validation, - JSON output, warnings, manifest metadata, and checkpoint fingerprints using a - fake LLM. -- Add one sequential integration that materializes normalized NPC output as - both combat references and verifies canonical actor/target output, reference - provenance, and that NPC source ranges never become combat evidence. -- Add preparation-boundary coverage showing malformed or oversized NPC input - fails before checkpoint construction or pipeline execution. Do not duplicate - the shared resolver's complete malformed-input matrix. -- Validate maintained examples and documentation links through existing test - mechanisms. - -### Completion Check - -Run the final verification suite. - -## Final Verification - -Run: - -```sh -git diff --check -go test ./... -go vet ./... -go build ./cmd/notarius -go test -race ./internal/modules/dnd/... ./internal/framework/pipeline \ - ./internal/cli ./internal/modules/integration -``` - -Review the final diff for: - -- D&D or provider behavior leaking into generic framework packages; -- accidental scene-based call suppression or automatic pipeline composition; -- mutation or aliasing of typed artifacts, schemas, references, metadata, or - fingerprints; -- transcript, registry, prompt, schema, path, or decoder content leaking into - errors, manifests, fingerprints, or redacted summaries; -- prompt-prose change-detector tests, redundant cross-layer cases, or exact LLM - output goldens; -- current-behavior documentation claiming features before the implementing - stage lands; and -- unrelated worktree changes. - -## Open Questions - -None. Artifact fields and enums, nullable behavior, chronology, duplicate -identity, reference reuse, prompt composition, validator placement, -normalization, checkpoint semantics, documentation ownership, and stage -boundaries are fixed by this plan. diff --git a/internal/modules/dnd/extract/combatturns/assets/schemas/dnd_combat_turns_llm.v1.json b/internal/modules/dnd/extract/combatturns/assets/schemas/dnd_combat_turns_llm.v1.json index 6425411..c323d37 100644 --- a/internal/modules/dnd/extract/combatturns/assets/schemas/dnd_combat_turns_llm.v1.json +++ b/internal/modules/dnd/extract/combatturns/assets/schemas/dnd_combat_turns_llm.v1.json @@ -13,66 +13,54 @@ "required": ["actor", "turn_kind", "round", "actions", "summary", "source_refs"], "properties": { "actor": { - "type": "string", - "minLength": 1 + "type": "string" }, "turn_kind": { - "type": "string", - "enum": ["turn", "reaction", "legendary_action", "lair_action", "other"] + "type": "string" }, "round": { - "type": ["integer", "null"], - "minimum": 1 + "type": ["integer", "null"] }, "actions": { "type": "array", - "minItems": 1, "items": { "type": "object", "additionalProperties": false, "required": ["category", "declaration", "targets", "resolution"], "properties": { "category": { - "type": "string", - "enum": ["attack", "spell", "movement", "item", "ability_check", "saving_throw", "condition", "other"] + "type": "string" }, "declaration": { - "type": "string", - "minLength": 1 + "type": "string" }, "targets": { "type": "array", "items": { - "type": "string", - "minLength": 1 + "type": "string" } }, "resolution": { - "type": ["string", "null"], - "minLength": 1 + "type": ["string", "null"] } } } }, "summary": { - "type": "string", - "minLength": 1 + "type": "string" }, "source_refs": { "type": "array", - "minItems": 1, "items": { "type": "object", "additionalProperties": false, "required": ["start_unit_id", "end_unit_id"], "properties": { "start_unit_id": { - "type": "integer", - "minimum": 1 + "type": "integer" }, "end_unit_id": { - "type": "integer", - "minimum": 1 + "type": "integer" } } } diff --git a/internal/modules/dnd/extract/combatturns/canonicalize.go b/internal/modules/dnd/extract/combatturns/canonicalize.go index 27a960d..1446772 100644 --- a/internal/modules/dnd/extract/combatturns/canonicalize.go +++ b/internal/modules/dnd/extract/combatturns/canonicalize.go @@ -59,7 +59,7 @@ func dedupeSourceRefs(refs []combatSourceRefResponse) []combatSourceRefResponse } func sameSourceRef(left combatSourceRefResponse, right combatSourceRefResponse) bool { - return left.StartUnitID.Int() == right.StartUnitID.Int() && left.EndUnitID.Int() == right.EndUnitID.Int() + return left == right } func earliestSourcePosition(doc *source.SourceDocument, turn combatTurnResponse) (int, bool) { @@ -69,7 +69,7 @@ func earliestSourcePosition(doc *source.SourceDocument, turn combatTurnResponse) earliest := 0 found := false for _, ref := range turn.SourceRefs { - candidate := source.SourceRef{SourceID: doc.ID, StartUnitID: ref.StartUnitID.Int(), EndUnitID: ref.EndUnitID.Int()} + candidate := source.SourceRef{SourceID: doc.ID, StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID} if err := source.ValidateRef(doc, candidate); err != nil { continue } @@ -83,8 +83,7 @@ func earliestSourcePosition(doc *source.SourceDocument, turn combatTurnResponse) return earliest, found } -func unitSortValue(ref interface{ Int() int }) int { - value := ref.Int() +func unitSortValue(value int) int { if value <= 0 { return int(^uint(0) >> 1) } @@ -121,9 +120,6 @@ func canonicalActions(actions []combatActionResponse) []dnd.CombatAction { Targets: append([]string(nil), action.Targets...), Resolution: cloneStringPointer(action.Resolution), } - if action.Targets != nil { - out[index].Targets = append([]string{}, action.Targets...) - } } return out } @@ -134,7 +130,7 @@ func canonicalSourceRefs(refs []combatSourceRefResponse, sourceID string) []sour } out := make([]source.SourceRef, len(refs)) for index, ref := range refs { - out[index] = source.SourceRef{SourceID: sourceID, StartUnitID: ref.StartUnitID.Int(), EndUnitID: ref.EndUnitID.Int()} + out[index] = source.SourceRef{SourceID: sourceID, StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID} } return out } diff --git a/internal/modules/dnd/extract/combatturns/extractor_test.go b/internal/modules/dnd/extract/combatturns/extractor_test.go index 4692864..6898277 100644 --- a/internal/modules/dnd/extract/combatturns/extractor_test.go +++ b/internal/modules/dnd/extract/combatturns/extractor_test.go @@ -14,7 +14,6 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" - "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" ) func TestExtractMapsAndOrdersCombatTurnsBySourcePosition(t *testing.T) { @@ -24,20 +23,20 @@ func TestExtractMapsAndOrdersCombatTurnsBySourcePosition(t *testing.T) { { Actor: "Borin", TurnKind: "turn", Round: &round, Actions: []combatActionResponse{{Category: "movement", Declaration: "Borin retreats", Targets: []string{"ogre"}, Resolution: nil}}, - Summary: "Borin retreats.", SourceRefs: []combatSourceRefResponse{{StartUnitID: shared.UnitRefFromInt(2), EndUnitID: shared.UnitRefFromInt(2)}}, + Summary: "Borin retreats.", SourceRefs: []combatSourceRefResponse{{StartUnitID: 2, EndUnitID: 2}}, }, { Actor: "Aria", TurnKind: "reaction", Round: nil, Actions: []combatActionResponse{{Category: "attack", Declaration: "Aria strikes", Targets: []string{"ogre"}, Resolution: &resolution}}, Summary: "Aria reacts.", SourceRefs: []combatSourceRefResponse{ - {StartUnitID: shared.UnitRefFromInt(10), EndUnitID: shared.UnitRefFromInt(10)}, - {StartUnitID: shared.UnitRefFromInt(10), EndUnitID: shared.UnitRefFromInt(10)}, + {StartUnitID: 10, EndUnitID: 10}, + {StartUnitID: 10, EndUnitID: 10}, }, }, { Actor: "Unknown", TurnKind: "other", Round: nil, Actions: []combatActionResponse{{Category: "other", Declaration: "something", Targets: []string{}, Resolution: nil}}, - Summary: "Uncited event.", SourceRefs: []combatSourceRefResponse{{StartUnitID: shared.UnitRefFromString("missing"), EndUnitID: shared.UnitRefFromString("missing")}}, + Summary: "Uncited event.", SourceRefs: []combatSourceRefResponse{{StartUnitID: 0, EndUnitID: 0}}, }, }}} @@ -79,7 +78,7 @@ func TestExtractPreservesInvalidCandidatesForValidators(t *testing.T) { { Actor: " ", TurnKind: "unsupported", Round: &negativeRound, Actions: []combatActionResponse{{Category: "unsupported", Declaration: " ", Targets: nil, Resolution: &emptyResolution}}, - Summary: " ", SourceRefs: []combatSourceRefResponse{{StartUnitID: shared.UnitRefFromInt(99), EndUnitID: shared.UnitRefFromString("not-a-unit")}}, + Summary: " ", SourceRefs: []combatSourceRefResponse{{StartUnitID: 99, EndUnitID: 0}}, }, }}} result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest()) diff --git a/internal/modules/dnd/extract/combatturns/model.go b/internal/modules/dnd/extract/combatturns/model.go index 1ee2363..3b59378 100644 --- a/internal/modules/dnd/extract/combatturns/model.go +++ b/internal/modules/dnd/extract/combatturns/model.go @@ -1,6 +1,10 @@ package combatturns -import "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" +import ( + "bytes" + "encoding/json" + "fmt" +) type extractionResponse struct { CombatTurns []combatTurnResponse `json:"combat_turns"` @@ -23,6 +27,79 @@ type combatActionResponse struct { } type combatSourceRefResponse struct { - StartUnitID shared.UnitRef `json:"start_unit_id"` - EndUnitID shared.UnitRef `json:"end_unit_id"` + StartUnitID int `json:"start_unit_id"` + EndUnitID int `json:"end_unit_id"` +} + +func (response *combatTurnResponse) UnmarshalJSON(content []byte) error { + type responseWire struct { + Actor string `json:"actor"` + TurnKind string `json:"turn_kind"` + Round json.RawMessage `json:"round"` + Actions []combatActionResponse `json:"actions"` + Summary string `json:"summary"` + SourceRefs []combatSourceRefResponse `json:"source_refs"` + } + var wire responseWire + if err := json.Unmarshal(content, &wire); err != nil { + return err + } + round, err := decodeRequiredNullableInt(wire.Round, "round") + if err != nil { + return err + } + *response = combatTurnResponse{ + Actor: wire.Actor, TurnKind: wire.TurnKind, Round: round, Actions: wire.Actions, + Summary: wire.Summary, SourceRefs: wire.SourceRefs, + } + return nil +} + +func (response *combatActionResponse) UnmarshalJSON(content []byte) error { + type responseWire struct { + Category string `json:"category"` + Declaration string `json:"declaration"` + Targets []string `json:"targets"` + Resolution json.RawMessage `json:"resolution"` + } + var wire responseWire + if err := json.Unmarshal(content, &wire); err != nil { + return err + } + resolution, err := decodeRequiredNullableString(wire.Resolution, "resolution") + if err != nil { + return err + } + *response = combatActionResponse{ + Category: wire.Category, Declaration: wire.Declaration, Targets: wire.Targets, Resolution: resolution, + } + return nil +} + +func decodeRequiredNullableInt(raw json.RawMessage, field string) (*int, error) { + if len(raw) == 0 { + return nil, fmt.Errorf("%s must be present", field) + } + if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return nil, nil + } + var value int + if err := json.Unmarshal(raw, &value); err != nil { + return nil, fmt.Errorf("%s must be an integer or null: %w", field, err) + } + return &value, nil +} + +func decodeRequiredNullableString(raw json.RawMessage, field string) (*string, error) { + if len(raw) == 0 { + return nil, fmt.Errorf("%s must be present", field) + } + if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return nil, nil + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return nil, fmt.Errorf("%s must be a string or null: %w", field, err) + } + return &value, nil } diff --git a/internal/modules/dnd/extract/combatturns/model_test.go b/internal/modules/dnd/extract/combatturns/model_test.go new file mode 100644 index 0000000..1973b89 --- /dev/null +++ b/internal/modules/dnd/extract/combatturns/model_test.go @@ -0,0 +1,58 @@ +package combatturns + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestExtractionResponseDecodingPreservesValidatorOwnedSemantics(t *testing.T) { + content := []byte(`{"combat_turns":[{"actor":"","turn_kind":"unsupported","round":-1,"actions":[{"category":"unsupported","declaration":"","targets":[],"resolution":""}],"summary":"","source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`) + var response extractionResponse + if err := json.Unmarshal(content, &response); err != nil { + t.Fatalf("json.Unmarshal() error = %v, want semantic candidate", err) + } + turn := response.CombatTurns[0] + if turn.Round == nil || *turn.Round != -1 || turn.TurnKind != "unsupported" || turn.Actions[0].Category != "unsupported" || turn.Actions[0].Resolution == nil || *turn.Actions[0].Resolution != "" { + t.Fatalf("decoded turn = %#v, want validator-owned values preserved", turn) + } + if turn.SourceRefs[0] != (combatSourceRefResponse{StartUnitID: 0, EndUnitID: -1}) { + t.Fatalf("decoded source reference = %#v, want nonpositive values preserved", turn.SourceRefs[0]) + } +} + +func TestExtractionResponseDecodingDistinguishesMissingAndNullNullableFields(t *testing.T) { + validNulls := []byte(`{"combat_turns":[{"actor":"Aria","turn_kind":"turn","round":null,"actions":[{"category":"attack","declaration":"attacks","targets":[],"resolution":null}],"summary":"attacks","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`) + var response extractionResponse + if err := json.Unmarshal(validNulls, &response); err != nil { + t.Fatalf("json.Unmarshal(nulls) error = %v", err) + } + if response.CombatTurns[0].Round != nil || response.CombatTurns[0].Actions[0].Resolution != nil { + t.Fatalf("decoded nullables = %#v, want explicit null", response.CombatTurns[0]) + } + + for _, test := range []struct { + name string + content string + field string + }{ + { + name: "missing round", + content: `{"combat_turns":[{"actor":"Aria","turn_kind":"turn","actions":[],"summary":"attacks","source_refs":[]}]}`, + field: "round", + }, + { + name: "missing resolution", + content: `{"combat_turns":[{"actor":"Aria","turn_kind":"turn","round":null,"actions":[{"category":"attack","declaration":"attacks","targets":[]}],"summary":"attacks","source_refs":[]}]}`, + field: "resolution", + }, + } { + t.Run(test.name, func(t *testing.T) { + var candidate extractionResponse + err := json.Unmarshal([]byte(test.content), &candidate) + if err == nil || !strings.Contains(err.Error(), test.field) { + t.Fatalf("json.Unmarshal() error = %v, want missing %s failure", err, test.field) + } + }) + } +} diff --git a/internal/modules/dnd/extract/combatturns/schema_test.go b/internal/modules/dnd/extract/combatturns/schema_test.go index 1868e83..ea3fd36 100644 --- a/internal/modules/dnd/extract/combatturns/schema_test.go +++ b/internal/modules/dnd/extract/combatturns/schema_test.go @@ -37,6 +37,73 @@ func TestLoadResponseSchemaUsesPrivateCombatShape(t *testing.T) { } } +func TestResponseSchemaLeavesSemanticConstraintsToDeterministicValidators(t *testing.T) { + schema, err := loadResponseSchema() + if err != nil { + t.Fatal(err) + } + semanticCandidate := validCombatResponse() + turn := semanticCandidate["combat_turns"].([]any)[0].(map[string]any) + turn["actor"] = "" + turn["turn_kind"] = "unsupported" + turn["round"] = -1 + turn["summary"] = "" + action := turn["actions"].([]any)[0].(map[string]any) + action["category"] = "unsupported" + action["declaration"] = "" + action["targets"] = []any{""} + action["resolution"] = "" + ref := turn["source_refs"].([]any)[0].(map[string]any) + ref["start_unit_id"] = 0 + ref["end_unit_id"] = -1 + content, err := json.Marshal(semanticCandidate) + if err != nil { + t.Fatal(err) + } + if err := validateJSONSchema(content, schema.JSONSchema); err != nil { + t.Fatalf("private schema rejected validator-owned semantics: %v", err) + } + turn["actions"] = []any{} + turn["source_refs"] = []any{} + content, err = json.Marshal(semanticCandidate) + if err != nil { + t.Fatal(err) + } + if err := validateJSONSchema(content, schema.JSONSchema); err != nil { + t.Fatalf("private schema rejected empty validator-owned collections: %v", err) + } +} + +func TestResponseSchemaRetainsStructuralBoundary(t *testing.T) { + schema, err := loadResponseSchema() + if err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name string + mutate func(map[string]any) + }{ + {name: "missing nullable round", mutate: func(turn map[string]any) { delete(turn, "round") }}, + {name: "wrong round type", mutate: func(turn map[string]any) { turn["round"] = "one" }}, + {name: "unknown field", mutate: func(turn map[string]any) { turn["unexpected"] = true }}, + {name: "missing nullable resolution", mutate: func(turn map[string]any) { + delete(turn["actions"].([]any)[0].(map[string]any), "resolution") + }}, + } { + t.Run(test.name, func(t *testing.T) { + candidate := validCombatResponse() + test.mutate(candidate["combat_turns"].([]any)[0].(map[string]any)) + content, err := json.Marshal(candidate) + if err != nil { + t.Fatal(err) + } + if err := validateJSONSchema(content, schema.JSONSchema); err == nil { + t.Fatal("private schema accepted structurally invalid response") + } + }) + } +} + func TestResponseSchemaJSONIsMutationSafe(t *testing.T) { first, err := loadResponseSchema() if err != nil { diff --git a/internal/modules/dnd/normalize/combatturns/normalizer.go b/internal/modules/dnd/normalize/combatturns/normalizer.go index 2f4b0bd..ec770c1 100644 --- a/internal/modules/dnd/normalize/combatturns/normalizer.go +++ b/internal/modules/dnd/normalize/combatturns/normalizer.go @@ -14,7 +14,6 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "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/dnd/shared" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" ) @@ -38,13 +37,6 @@ const ( var requiredCapabilities = []string{"merged"} var providedCapabilities = []string{"normalized"} -var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{ - Glossary: "Optional campaign glossary reference material used only for disambiguation.", - Party: "Optional party roster reference material used only for disambiguation.", - Players: "Optional player list reference material used only for disambiguation.", - Roster: "Deprecated alias for party roster reference material used only for disambiguation.", -} - var _ contracts.Normalizer[dnd.CombatTurnList] = (*Normalizer)(nil) var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil) @@ -483,39 +475,29 @@ func writeKeyInt(builder *strings.Builder, value int) { } func duplicateWarning(retainedIndex int, removed []int) contracts.Warning { - const maxDisplayedIndices = 20 - displayed := removed - if len(displayed) > maxDisplayedIndices { - displayed = displayed[:maxDisplayedIndices] - } - indices := make([]string, len(displayed)) - for index, removedIndex := range displayed { - indices[index] = strconv.Itoa(removedIndex) - } - - message := fmt.Sprintf("retained input index %d; removed input indices [%s]", retainedIndex, strings.Join(indices, ", ")) - if omitted := len(removed) - len(displayed); omitted > 0 { - message += fmt.Sprintf("; %d additional removed input indices omitted", omitted) + issues := make([]string, len(removed)) + for index, removedIndex := range removed { + issues[index] = fmt.Sprintf("removed input index %d", removedIndex) } return contracts.Warning{ Scope: turnScope(retainedIndex), ReasonCode: ReasonCodeDuplicateCollapsed, - Message: diagnostics.Truncate(message), + Message: diagnostics.Aggregate( + fmt.Sprintf("duplicate combat turn collapsed; retained input index %d", retainedIndex), + issues, + ), } } func turnScope(index int) string { return fmt.Sprintf("combat_turns[%d]", index) } func referenceSlots() []contracts.ReferenceSlot { - slots := shared.ReferenceSlots(referenceSlotDescriptions) - slots = append(slots, contracts.ReferenceSlot{ + return []contracts.ReferenceSlot{{ Name: NPCRegistryReferenceSlot, Description: "Optional normalized NPC registry used for canonical actor and target grounding.", AcceptedMediaTypes: []string{"application/json"}, MaxBytes: NPCRegistryMaxBytes, - }) - sort.Slice(slots, func(i, j int) bool { return slots[i].Name < slots[j].Name }) - return slots + }} } func ModuleSpec() pipeline.ModuleSpec { diff --git a/internal/modules/dnd/normalize/combatturns/normalizer_test.go b/internal/modules/dnd/normalize/combatturns/normalizer_test.go index 5b6b674..f14a4a8 100644 --- a/internal/modules/dnd/normalize/combatturns/normalizer_test.go +++ b/internal/modules/dnd/normalize/combatturns/normalizer_test.go @@ -2,8 +2,12 @@ package combatturns import ( "context" + "fmt" + "math" "reflect" + "strings" "testing" + "unicode/utf8" "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" @@ -203,7 +207,7 @@ func TestNormalizerPreparationMetadataFingerprintsAndModuleContract(t *testing.T if got := bound.CheckpointFingerprints(); len(got) != 3 || got[2].Name != "npc_registry" || got[2].Value == "" { t.Fatalf("bound fingerprints = %#v", got) } - if spec := ModuleSpec(); spec.Key != Key || spec.Stage != pipeline.StageNormalize || spec.ArtifactKind != dnd.CombatTurnListKind || !reflect.DeepEqual(spec.Requires, []string{"merged"}) || !reflect.DeepEqual(spec.Provides, []string{"normalized"}) || len(spec.ReferenceSlots) != 5 { + if spec := ModuleSpec(); spec.Key != Key || spec.Stage != pipeline.StageNormalize || spec.ArtifactKind != dnd.CombatTurnListKind || !reflect.DeepEqual(spec.Requires, []string{"merged"}) || !reflect.DeepEqual(spec.Provides, []string{"normalized"}) || len(spec.ReferenceSlots) != 1 || spec.ReferenceSlots[0].Name != NPCRegistryReferenceSlot { t.Fatalf("ModuleSpec() = %#v", spec) } registry := pipeline.NewNormalizerRegistry() @@ -218,6 +222,26 @@ func TestNormalizerPreparationMetadataFingerprintsAndModuleContract(t *testing.T } } +func TestDuplicateWarningBoundsDisplayedIndexesAndReportsAllOmissions(t *testing.T) { + removed := make([]int, 25) + for index := range removed { + removed[index] = math.MaxInt - index + } + warning := duplicateWarning(7, removed) + if warning.Scope != "combat_turns[7]" || warning.ReasonCode != ReasonCodeDuplicateCollapsed { + t.Fatalf("duplicate warning = %#v, want retained-record scope and reason", warning) + } + if !strings.Contains(warning.Message, "retained input index 7") || !strings.Contains(warning.Message, fmt.Sprintf("removed input index %d", removed[0])) { + t.Fatalf("duplicate warning = %q, want retained and displayed removed indexes", warning.Message) + } + if !strings.Contains(warning.Message, "5 additional issue(s) omitted") { + t.Fatalf("duplicate warning = %q, want exact omitted count", warning.Message) + } + if !utf8.ValidString(warning.Message) || len([]byte(warning.Message)) > 4096 { + t.Fatalf("duplicate warning length/encoding = %d/%t", len([]byte(warning.Message)), utf8.ValidString(warning.Message)) + } +} + func TestNormalizerRejectsNilAndCanceledCalls(t *testing.T) { normalizer, err := New(Options{}) if err != nil { diff --git a/internal/modules/integration/dnd_combat_runner_test.go b/internal/modules/integration/dnd_combat_runner_test.go index bcadae7..a6acc64 100644 --- a/internal/modules/integration/dnd_combat_runner_test.go +++ b/internal/modules/integration/dnd_combat_runner_test.go @@ -125,6 +125,33 @@ func TestProductionCombatPipelineRetriesMergesNormalizesAndWritesJSON(t *testing } } +func TestCombatNormalizerRejectsCampaignReferenceBinding(t *testing.T) { + registries := productionNPCRegistries(t) + catalog := moduleCatalog(registries) + referencePath := filepath.Join(t.TempDir(), "party.yml") + if err := os.WriteFile(referencePath, []byte("Aria: cleric\n"), 0o600); err != nil { + t.Fatal(err) + } + effective, err := combatOnlyConfig().Resolve(config.ResolveInput{ + PipelineID: "dnd-combat-fixture", + Catalog: catalog, + ReferenceOverrides: []pipeline.ReferenceBinding{{ + Stage: pipeline.StageNormalize, LaneID: "combat", SlotName: "party", Source: referencePath, + BindingSource: contracts.ReferenceBindingSourceCLI, + }}, + }) + if err != nil { + if !strings.Contains(err.Error(), "party") { + t.Fatalf("Resolve() error = %v, want undeclared party context", err) + } + return + } + _, _, err = pipeline.MaterializeReferences(effective.ResolvedPipeline, catalog, pipeline.ReferenceMaterializationOptions{}) + if err == nil || !strings.Contains(err.Error(), "party") || !strings.Contains(err.Error(), "not declared") { + t.Fatalf("MaterializeReferences() error = %v, want undeclared normalize party binding", err) + } +} + func TestSequentialNPCOutputGroundsCombatAtBothStageLocalReferences(t *testing.T) { registries := productionNPCRegistries(t) catalog := moduleCatalog(registries)