Close out the D&D combat turn extraction roadmap
This commit is contained in:
@@ -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=<npc-run>/lanes/npcs.json
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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=<npc-run>/lanes/npcs.json` and
|
||||
`combat.normalize.npcs=<npc-run>/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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
58
internal/modules/dnd/extract/combatturns/model_test.go
Normal file
58
internal/modules/dnd/extract/combatturns/model_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user