diff --git a/docs/integrations/dnd-combat-turn-artifacts.md b/docs/integrations/dnd-combat-turn-artifacts.md index c3d2927..2cde835 100644 --- a/docs/integrations/dnd-combat-turn-artifacts.md +++ b/docs/integrations/dnd-combat-turn-artifacts.md @@ -1,8 +1,9 @@ # D&D Combat-Turn Artifact Contract -This document defines the durable artifact, serialization, extraction, and -candidate-validation boundaries for D&D combat turns. Normalization and a -selectable production pipeline lane are not part of this contract yet. +This document defines the durable artifact, serialization, extraction, +candidate-validation, and standalone normalization boundaries for D&D combat +turns. Production composition and a selectable pipeline lane are not part of +this contract yet. ## Artifact identity @@ -119,5 +120,34 @@ at least four Unicode code points against complete cited-text tokens. Targets are not checked deterministically. The extractor and validators are package-complete but are not registered by the -production D&D family registrar yet. Normalization, production composition, -and selectable configuration are defined when implemented. +production D&D family registrar yet. + +## 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. + +Normalization policy is `dnd.combat_turns.normalize.v1`. It display-normalizes +actor, summary, declarations, targets, and non-null resolutions; canonicalizes +exact registry actor and target matches; orders and deduplicates exact source +references; stable-sorts records by earliest valid source-document position; and +collapses only records with the same actor identity, turn kind, round value, and +complete valid evidence set. The first normalized record is retained without +merging its actions or prose. Invalid evidence is never eligible for duplicate +collapse. Every mutation and collapse emits a bounded warning using the merged +input index in its scope. + +The normalizer reports `normalization_policy` and `identity_policy` metadata and +fingerprints, plus `npc_registry_digest`, `npc_count`, and `npc_registry` only +when a registry is bound. The normalized-invariants validator is +`normalize/dnd/combat-turns/invariants`; it defers shape and source-reference +failures, then checks display normalization, target identity uniqueness, +canonical evidence ordering, chronology, and duplicate identity. It rejects +with `invalid_combat_turn_normalization` under policy +`dnd.combat_turns.validator.normalized.v1`. + +The normalizer and normalized-invariants validator are package-complete but are +not registered by the production D&D family registrar yet. diff --git a/docs/internal/modules.md b/docs/internal/modules.md index c077d3d..03b443a 100644 --- a/docs/internal/modules.md +++ b/docs/internal/modules.md @@ -284,6 +284,19 @@ raw overlay bytes are not included in either surface. The normalize-stage reference is stage-local, so an overlay-capable pipeline binds the catalog independently for extraction and normalization. +### `internal/modules/dnd/normalize/combatturns` + +The combat normalizer prepares the optional NPC registry once and uses the +immutable prepared view during runtime. It display-normalizes combat fields, +rewrites exact canonical-name or alias matches for actors and targets, orders +and deduplicates source references, stable-sorts records by source-document +position, and collapses only exact duplicate identities with fully valid +evidence. It deep-clones output storage and emits bounded warnings scoped to +merged input indexes. Its metadata and fingerprints identify the normalization +and NPC identity policies, with registry digest/count only when bound. The +normalizer is package-complete but is not registered in the production D&D +registrar. + ## Output Encoder ### `internal/modules/generic/output/json` @@ -348,9 +361,12 @@ rounds, and supported enums. Combat source-reference validation defers invalid shape and checks source identity, unit existence, and range order. Combat source-relatedness defers invalid shape or ranges, combines overlapping cited units in document order, and emits at most one bounded advisory warning per -turn for unrelated actor or declaration text. All three validators are -deterministic and expose local policy fingerprints; they are package-complete -but not yet in a production validator chain. +turn for unrelated actor or declaration text. The normalized-invariants +validator owns display normalization, comparison-unique targets, canonical +source-reference order, chronology, and exact duplicate identity; it defers +shape and source-reference failures. All four validators are deterministic and +expose local policy fingerprints; they are package-complete but not yet in a +production validator chain. ## Production Registration diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 23e81af..11fce37 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -91,7 +91,8 @@ Configuration. The implemented module packages are: | `internal/modules/dnd/extract/spells` | Maps private structured model output to canonical source-grounded D&D spell lists. | | `internal/modules/dnd/extract/npcs` | Maps private structured model output to canonical source-grounded D&D NPC lists. | | `internal/modules/dnd/extract/combatturns` | Maps private structured model output to source-grounded D&D combat-turn candidates and preserves chronology and invalid candidate values for validators. | -| `internal/modules/dnd/validate/combatturns` | Provides deterministic shape, source-reference, and source-relatedness validation for combat-turn candidates without production composition. | +| `internal/modules/dnd/normalize/combatturns` | Canonicalizes and orders merged combat turns, applies exact NPC identity matches, and collapses only exact valid-evidence duplicates without production composition. | +| `internal/modules/dnd/validate/combatturns` | Provides deterministic shape, source-reference, source-relatedness, and normalized-invariant validation for combat turns without production composition. | | `internal/modules/dnd/npcs/registry` | Resolves validated normalized NPC references into immutable grounding data and exact identity lookup. | | `internal/modules/dnd/npcs/identity` | Owns Unicode-aware NPC identity, ID derivation, and registry collision validation. | | `internal/modules/dnd/spells/catalog` | Embeds and validates the versioned D&D 5e 2014 SRD catalog, composes optional overlays, and provides immutable effective lookup. | diff --git a/internal/modules/dnd/normalize/combatturns/normalizer.go b/internal/modules/dnd/normalize/combatturns/normalizer.go new file mode 100644 index 0000000..2f4b0bd --- /dev/null +++ b/internal/modules/dnd/normalize/combatturns/normalizer.go @@ -0,0 +1,556 @@ +// Package combatturns normalizes merged D&D combat-turn candidates. +package combatturns + +import ( + "context" + "fmt" + "sort" + "strconv" + "strings" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + "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" +) + +const ( + Key = "dnd/combat-turns" + NormalizationPolicy = "dnd.combat_turns.normalize.v1" + + ReasonCodeFieldsNormalized = "combat_turn_fields_normalized" + ReasonCodeActorCanonicalized = "combat_actor_canonicalized" + ReasonCodeTargetCanonicalized = "combat_target_canonicalized" + ReasonCodeSourceRefsNormalized = "source_references_normalized" + ReasonCodeTurnsReordered = "combat_turns_reordered" + ReasonCodeDuplicateCollapsed = "duplicate_combat_turn_collapsed" +) + +const ( + NPCRegistryReferenceSlot = npcregistry.ReferenceSlot + NPCRegistryMaxBytes = npcregistry.MaxBytes +) + +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) + +type Options struct{} + +type Normalizer struct { + npcRegistry *npcregistry.Registry +} + +func New(_ Options, references ...contracts.ReferenceSet) (*Normalizer, error) { + if len(references) > 1 { + return nil, normalizerErrorf("at most one reference set may be supplied") + } + var referenceSet contracts.ReferenceSet + if len(references) == 1 { + referenceSet = references[0] + } + npcRegistry, err := npcregistry.Resolve(referenceSet) + if err != nil { + return nil, normalizerErrorf("prepare NPC registry: %w", err) + } + return &Normalizer{npcRegistry: npcRegistry}, nil +} + +func (n *Normalizer) Key() string { return Key } + +func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return referenceSlots() } + +func (n *Normalizer) ManifestMetadata() map[string]any { + if n == nil { + return nil + } + metadata := map[string]any{ + "normalization_policy": NormalizationPolicy, + "identity_policy": identity.Policy, + } + if n.npcRegistry.Bound() { + metadata["npc_registry_digest"] = n.npcRegistry.Digest() + metadata["npc_count"] = n.npcRegistry.Count() + } + return metadata +} + +func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint { + if n == nil { + return nil + } + fingerprints := []pipeline.CheckpointFingerprint{ + {Name: "normalization_policy", Value: NormalizationPolicy}, + {Name: "identity_policy", Value: identity.Policy}, + } + if n.npcRegistry.Bound() { + fingerprints = append(fingerprints, pipeline.CheckpointFingerprint{Name: "npc_registry", Value: n.npcRegistry.Digest()}) + } + return fingerprints +} + +func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.CombatTurnList]) (contracts.TypedNormalizeResult[dnd.CombatTurnList], error) { + if n == nil { + return contracts.TypedNormalizeResult[dnd.CombatTurnList]{}, normalizerErrorf("normalizer must not be nil") + } + if ctx == nil { + return contracts.TypedNormalizeResult[dnd.CombatTurnList]{}, normalizerErrorf("context must not be nil") + } + if err := ctx.Err(); err != nil { + return contracts.TypedNormalizeResult[dnd.CombatTurnList]{}, normalizerErrorf("context error before normalize: %w", err) + } + + value, warnings := normalizeList(req.MergeOutput.Value, req.Source, n.npcRegistry) + return contracts.TypedNormalizeResult[dnd.CombatTurnList]{Value: value, Warnings: warnings}, nil +} + +type normalizedRecord struct { + turn dnd.CombatTurn + inputIndex int + earliest int + hasEvidence bool +} + +type targetCanonicalization struct { + actionIndex int + targetIndex int + from string + to string +} + +func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, registry *npcregistry.Registry) (dnd.CombatTurnList, []contracts.Warning) { + if input.CombatTurns == nil { + return dnd.CombatTurnList{}, nil + } + + records := make([]normalizedRecord, len(input.CombatTurns)) + warnings := make([]contracts.Warning, 0) + for index, inputTurn := range input.CombatTurns { + turn, fieldsChanged, actorChange, targetChanges, refsChanged := normalizeTurn(inputTurn, registry) + earliest, hasEvidence := earliestSourcePosition(doc, turn) + records[index] = normalizedRecord{ + turn: turn, + inputIndex: index, + earliest: earliest, + hasEvidence: hasEvidence, + } + if fieldsChanged { + warnings = append(warnings, contracts.Warning{ + Scope: turnScope(index), + ReasonCode: ReasonCodeFieldsNormalized, + Message: fmt.Sprintf("input index %d: combat turn fields normalized", index), + }) + } + if actorChange != nil { + warnings = append(warnings, contracts.Warning{ + Scope: turnScope(index), + ReasonCode: ReasonCodeActorCanonicalized, + Message: fmt.Sprintf("input index %d: actor canonicalized from %s to %s", + index, diagnostics.Quote(actorChange.from), diagnostics.Quote(actorChange.to)), + }) + } + for _, targetChange := range targetChanges { + warnings = append(warnings, contracts.Warning{ + Scope: turnScope(index), + ReasonCode: ReasonCodeTargetCanonicalized, + Message: fmt.Sprintf("input index %d: action %d target %d canonicalized from %s to %s", + index, targetChange.actionIndex, targetChange.targetIndex, + diagnostics.Quote(targetChange.from), diagnostics.Quote(targetChange.to)), + }) + } + if refsChanged { + warnings = append(warnings, contracts.Warning{ + Scope: turnScope(index), + ReasonCode: ReasonCodeSourceRefsNormalized, + Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", + index, len(inputTurn.SourceRefs), len(turn.SourceRefs)), + }) + } + } + + sort.SliceStable(records, func(left, right int) bool { + if records[left].hasEvidence != records[right].hasEvidence { + return records[left].hasEvidence + } + if !records[left].hasEvidence { + return false + } + return records[left].earliest < records[right].earliest + }) + for position, record := range records { + if position == record.inputIndex { + continue + } + warnings = append(warnings, contracts.Warning{ + Scope: turnScope(record.inputIndex), + ReasonCode: ReasonCodeTurnsReordered, + Message: fmt.Sprintf("input index %d moved to normalized position %d by source chronology", + record.inputIndex, position), + }) + } + + output, duplicateWarnings := collapseDuplicates(records, doc) + warnings = append(warnings, duplicateWarnings...) + return dnd.CombatTurnList{CombatTurns: output}, warnings +} + +func normalizeTurn(input dnd.CombatTurn, registry *npcregistry.Registry) (dnd.CombatTurn, bool, *targetCanonicalization, []targetCanonicalization, bool) { + output := cloneCombatTurn(input) + output.Actor = identity.NormalizeDisplay(input.Actor) + output.Summary = identity.NormalizeDisplay(input.Summary) + + var actorChange *targetCanonicalization + if canonical, ok := registry.Lookup(output.Actor); ok { + canonicalName := identity.NormalizeDisplay(canonical.Name) + if output.Actor != canonicalName { + actorChange = &targetCanonicalization{from: output.Actor, to: canonicalName} + output.Actor = canonicalName + } + } + + targetChanges := make([]targetCanonicalization, 0) + for actionIndex := range output.Actions { + action := &output.Actions[actionIndex] + action.Declaration = identity.NormalizeDisplay(action.Declaration) + if action.Resolution != nil { + resolution := identity.NormalizeDisplay(*action.Resolution) + action.Resolution = &resolution + } + if action.Targets == nil { + continue + } + targets := make([]string, 0, len(action.Targets)) + seen := make(map[string]struct{}, len(action.Targets)) + for targetIndex, target := range action.Targets { + normalized := identity.NormalizeDisplay(target) + if canonical, ok := registry.Lookup(normalized); ok { + canonicalName := identity.NormalizeDisplay(canonical.Name) + if normalized != canonicalName { + targetChanges = append(targetChanges, targetCanonicalization{ + actionIndex: actionIndex, + targetIndex: targetIndex, + from: normalized, + to: canonicalName, + }) + } + normalized = canonicalName + } + key := identity.ComparisonKey(normalized) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + targets = append(targets, normalized) + } + action.Targets = targets + } + + fieldsChanged := input.Actor != output.Actor || input.Summary != output.Summary + if len(input.Actions) != len(output.Actions) { + fieldsChanged = true + } + for index := range output.Actions { + if input.Actions[index].Declaration != output.Actions[index].Declaration || + !stringSlicesEqual(input.Actions[index].Targets, output.Actions[index].Targets) || + !stringPointersEqual(input.Actions[index].Resolution, output.Actions[index].Resolution) { + fieldsChanged = true + break + } + } + canonicalRefs, _, _ := canonicalizeSourceRefs(input.SourceRefs) + output.SourceRefs = canonicalRefs + refsChanged := !sourceRefsEqual(input.SourceRefs, output.SourceRefs) + return output, fieldsChanged, actorChange, targetChanges, refsChanged +} + +func cloneCombatTurn(input dnd.CombatTurn) dnd.CombatTurn { + output := input + if input.Round != nil { + round := *input.Round + output.Round = &round + } + if input.Actions != nil { + output.Actions = make([]dnd.CombatAction, len(input.Actions)) + for index, action := range input.Actions { + output.Actions[index] = cloneCombatAction(action) + } + } + if input.SourceRefs != nil { + output.SourceRefs = make([]source.SourceRef, len(input.SourceRefs)) + copy(output.SourceRefs, input.SourceRefs) + } + return output +} + +func cloneCombatAction(input dnd.CombatAction) dnd.CombatAction { + output := input + if input.Targets != nil { + output.Targets = make([]string, len(input.Targets)) + copy(output.Targets, input.Targets) + } + if input.Resolution != nil { + resolution := *input.Resolution + output.Resolution = &resolution + } + return output +} + +func stringSlicesEqual(left, right []string) bool { + if (left == nil) != (right == nil) || len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} + +func stringPointersEqual(left, right *string) bool { + if (left == nil) != (right == nil) { + return false + } + return left == nil || *left == *right +} + +func sourceRefsEqual(left, right []source.SourceRef) bool { + if (left == nil) != (right == nil) || len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} + +func canonicalizeSourceRefs(input []source.SourceRef) ([]source.SourceRef, bool, int) { + if input == nil { + return nil, false, 0 + } + + canonical := make([]source.SourceRef, len(input)) + copy(canonical, input) + sort.SliceStable(canonical, func(left, right int) bool { + return sourceRefLess(canonical[left], canonical[right]) + }) + + orderChanged := false + for index := range input { + if input[index] != canonical[index] { + orderChanged = true + break + } + } + + unique := make([]source.SourceRef, 0, len(canonical)) + for _, ref := range canonical { + if len(unique) == 0 || unique[len(unique)-1] != ref { + unique = append(unique, ref) + } + } + return unique, orderChanged, len(input) - len(unique) +} + +func sourceRefLess(left, right source.SourceRef) bool { + if left.SourceID != right.SourceID { + return left.SourceID < right.SourceID + } + if left.StartUnitID != right.StartUnitID { + return left.StartUnitID < right.StartUnitID + } + return left.EndUnitID < right.EndUnitID +} + +func earliestSourcePosition(doc *source.SourceDocument, turn dnd.CombatTurn) (int, bool) { + if doc == nil { + return 0, false + } + earliest := 0 + found := false + for _, ref := range turn.SourceRefs { + if source.ValidateRef(doc, ref) != nil { + continue + } + index, ok := source.UnitIndex(doc, ref.StartUnitID) + if !ok || (found && index >= earliest) { + continue + } + earliest = index + found = true + } + return earliest, found +} + +type duplicateGroup struct { + retainedIndex int + removed []int +} + +func collapseDuplicates(records []normalizedRecord, doc *source.SourceDocument) ([]dnd.CombatTurn, []contracts.Warning) { + if len(records) == 0 { + return make([]dnd.CombatTurn, 0), nil + } + + keep := make([]bool, len(records)) + groups := make([]duplicateGroup, 0) + groupByKey := make(map[string]int) + for index, record := range records { + key, eligible := duplicateKey(record.turn, doc) + if !eligible { + keep[index] = true + continue + } + groupIndex, exists := groupByKey[key] + if !exists { + groupByKey[key] = len(groups) + groups = append(groups, duplicateGroup{retainedIndex: record.inputIndex}) + keep[index] = true + continue + } + groups[groupIndex].removed = append(groups[groupIndex].removed, record.inputIndex) + } + + output := make([]dnd.CombatTurn, 0, len(records)) + for index, record := range records { + if keep[index] { + output = append(output, cloneCombatTurn(record.turn)) + } + } + + warnings := make([]contracts.Warning, 0) + for _, group := range groups { + if len(group.removed) == 0 { + continue + } + warnings = append(warnings, duplicateWarning(group.retainedIndex, group.removed)) + } + return output, warnings +} + +func duplicateKey(turn dnd.CombatTurn, doc *source.SourceDocument) (string, bool) { + if len(turn.SourceRefs) == 0 { + return "", false + } + for _, ref := range turn.SourceRefs { + if source.ValidateRef(doc, ref) != nil { + return "", false + } + } + + var key strings.Builder + writeKeyString(&key, identity.ComparisonKey(turn.Actor)) + writeKeyString(&key, string(turn.TurnKind)) + if turn.Round == nil { + key.WriteByte('0') + } else { + key.WriteByte('1') + writeKeyInt(&key, *turn.Round) + } + for _, ref := range turn.SourceRefs { + writeKeyString(&key, ref.SourceID) + writeKeyInt(&key, ref.StartUnitID) + writeKeyInt(&key, ref.EndUnitID) + } + return key.String(), true +} + +func writeKeyString(builder *strings.Builder, value string) { + builder.WriteString(strconv.Itoa(len(value))) + builder.WriteByte(':') + builder.WriteString(value) +} + +func writeKeyInt(builder *strings.Builder, value int) { + builder.WriteString(strconv.Itoa(value)) + builder.WriteByte(';') +} + +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) + } + return contracts.Warning{ + Scope: turnScope(retainedIndex), + ReasonCode: ReasonCodeDuplicateCollapsed, + Message: diagnostics.Truncate(message), + } +} + +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{ + 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 { + return pipeline.ModuleSpec{ + Key: Key, + Stage: pipeline.StageNormalize, + Requires: append([]string(nil), requiredCapabilities...), + Provides: append([]string(nil), providedCapabilities...), + ArtifactKind: dnd.CombatTurnListKind, + ReferenceSlots: referenceSlots(), + } +} + +func Register(registry *pipeline.NormalizerRegistry) error { + return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.CombatTurnList], error) { + options, err := DecodeOptions(request.Options) + if err != nil { + return nil, err + } + return New(options, request.References) + }) +} + +func validateOptions(options map[string]any) error { + _, err := DecodeOptions(options) + return err +} + +func DecodeOptions(options map[string]any) (Options, error) { + if err := pipeline.RejectUnknownOptions(options); err != nil { + return Options{}, normalizerErrorf("%w", err) + } + return Options{}, nil +} + +func normalizerErrorf(format string, args ...any) error { + return fmt.Errorf("dnd combat turns normalizer: "+format, args...) +} diff --git a/internal/modules/dnd/normalize/combatturns/normalizer_test.go b/internal/modules/dnd/normalize/combatturns/normalizer_test.go new file mode 100644 index 0000000..5b6b674 --- /dev/null +++ b/internal/modules/dnd/normalize/combatturns/normalizer_test.go @@ -0,0 +1,302 @@ +package combatturns + +import ( + "context" + "reflect" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" +) + +func TestNormalizeCanonicalizesFieldsAndRegistryIdentities(t *testing.T) { + doc := testDocument() + references := npcReferences(t) + normalizer, err := New(Options{}, references) + if err != nil { + t.Fatalf("New() error = %v", err) + } + resolution := " the target is hit " + input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ + Actor: " storm ", + TurnKind: dnd.CombatTurnKindTurn, + Actions: []dnd.CombatAction{{ + Category: dnd.CombatActionCategoryAttack, + Declaration: " attacks\n with a sword ", + Targets: []string{" minion ", "goblin", " unknown combatant "}, + Resolution: &resolution, + }}, + Summary: " Aria\n attacks ", + SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}, + }}} + original := cloneCombatTurn(input.CombatTurns[0]) + + result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{ + Source: doc, + MergeOutput: contracts.MergeArtifact[dnd.CombatTurnList]{Value: input}, + }) + if err != nil { + t.Fatalf("Normalize() error = %v", err) + } + if got := result.Value.CombatTurns[0]; got.Actor != "Aria" || got.Summary != "Aria attacks" || got.Actions[0].Declaration != "attacks with a sword" || got.Actions[0].Resolution == nil || *got.Actions[0].Resolution != "the target is hit" { + t.Fatalf("normalized turn = %#v, want display-normalized fields", got) + } + if got := result.Value.CombatTurns[0].Actions[0].Targets; !reflect.DeepEqual(got, []string{"Goblin", "unknown combatant"}) { + t.Fatalf("normalized targets = %#v, want canonical deduplicated target and preserved unmatched target", got) + } + wantRefs := []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}} + if !reflect.DeepEqual(result.Value.CombatTurns[0].SourceRefs, wantRefs) { + t.Fatalf("normalized refs = %#v, want %#v", result.Value.CombatTurns[0].SourceRefs, wantRefs) + } + for _, reason := range []string{ReasonCodeFieldsNormalized, ReasonCodeActorCanonicalized, ReasonCodeTargetCanonicalized, ReasonCodeSourceRefsNormalized} { + if !hasWarningReason(result.Warnings, reason) { + t.Fatalf("warnings = %#v, missing reason %q", result.Warnings, reason) + } + } + if !reflect.DeepEqual(input.CombatTurns[0], original) { + t.Fatalf("Normalize() mutated input: got %#v, want %#v", input.CombatTurns[0], original) + } + result.Value.CombatTurns[0].Actions[0].Targets[0] = "changed" + if input.CombatTurns[0].Actions[0].Targets[0] == "changed" { + t.Fatal("normalized targets share input storage") + } +} + +func TestNormalizeOrdersBySourcePositionAndCollapsesExactDuplicates(t *testing.T) { + doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 50}, {ID: 10}, {ID: 90}}} + first := validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 50, EndUnitID: 50}) + first.Summary = "first record" + second := validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 90, EndUnitID: 90}) + second.Summary = "later record" + duplicate := cloneCombatTurn(first) + duplicate.Summary = "must not replace first" + duplicate.Actions[0].Declaration = "replacement action" + invalid := validTurn("Unknown", source.SourceRef{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999}) + input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{second, first, duplicate, invalid}} + + normalizer, err := New(Options{}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{ + Source: doc, + MergeOutput: contracts.MergeArtifact[dnd.CombatTurnList]{Value: input}, + }) + if err != nil { + t.Fatalf("Normalize() error = %v", err) + } + if len(result.Value.CombatTurns) != 3 { + t.Fatalf("normalized turn count = %d, want 3", len(result.Value.CombatTurns)) + } + if result.Value.CombatTurns[0].Summary != "first record" || result.Value.CombatTurns[0].Actions[0].Declaration != "Aria attacks" || result.Value.CombatTurns[1].Summary != "later record" || result.Value.CombatTurns[2].Actor != "Unknown" { + t.Fatalf("normalized order/value = %#v, want chronology then invalid evidence", result.Value.CombatTurns) + } + if !hasWarningReason(result.Warnings, ReasonCodeTurnsReordered) || !hasWarningReason(result.Warnings, ReasonCodeDuplicateCollapsed) { + t.Fatalf("warnings = %#v, want reorder and duplicate warnings", result.Warnings) + } + for _, warning := range result.Warnings { + if warning.ReasonCode == ReasonCodeDuplicateCollapsed && warning.Scope != "combat_turns[1]" { + t.Fatalf("duplicate warning = %#v, want retained input scope combat_turns[1]", warning) + } + } +} + +func TestNormalizePreservesStableOrderForEqualEvidencePositions(t *testing.T) { + doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 50}}} + input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{ + validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 50, EndUnitID: 50}), + validTurn("Borin", source.SourceRef{SourceID: doc.ID, StartUnitID: 50, EndUnitID: 50}), + }} + normalizer, err := New(Options{}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.CombatTurnList]{Value: input}}) + if err != nil { + t.Fatalf("Normalize() error = %v", err) + } + if got := []string{result.Value.CombatTurns[0].Actor, result.Value.CombatTurns[1].Actor}; !reflect.DeepEqual(got, []string{"Aria", "Borin"}) { + t.Fatalf("equal-position order = %#v, want stable input order", got) + } + if hasWarningReason(result.Warnings, ReasonCodeTurnsReordered) { + t.Fatalf("warnings = %#v, equal-position stable sort should not warn", result.Warnings) + } +} + +func TestNormalizeDoesNotCollapseDifferentIdentityDimensions(t *testing.T) { + doc := testDocument() + base := validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}) + base.Round = nil + tests := []struct { + name string + other dnd.CombatTurn + }{ + {name: "different actor", other: withActor(base, "Borin")}, + {name: "different turn kind", other: withKind(base, dnd.CombatTurnKindReaction)}, + {name: "different round", other: withRound(base, 2)}, + {name: "different evidence", other: withRef(base, source.SourceRef{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20})}, + {name: "invalid evidence", other: withRef(base, source.SourceRef{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999})}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + normalizer, err := New(Options{}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{ + Source: doc, + MergeOutput: contracts.MergeArtifact[dnd.CombatTurnList]{Value: dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{base, test.other}}}, + }) + if err != nil { + t.Fatalf("Normalize() error = %v", err) + } + if len(result.Value.CombatTurns) != 2 { + t.Fatalf("normalized turn count = %d, want distinct records", len(result.Value.CombatTurns)) + } + }) + } +} + +func TestNormalizePreservesNilAndPresentEmptyStorage(t *testing.T) { + normalizer, err := New(Options{}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + for _, input := range []dnd.CombatTurnList{ + {}, + {CombatTurns: []dnd.CombatTurn{}}, + } { + result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{MergeOutput: contracts.MergeArtifact[dnd.CombatTurnList]{Value: input}}) + if err != nil { + t.Fatalf("Normalize() error = %v", err) + } + if (result.Value.CombatTurns == nil) != (input.CombatTurns == nil) { + t.Fatalf("nil/present-empty distinction lost: input %#v output %#v", input.CombatTurns, result.Value.CombatTurns) + } + } +} + +func TestNormalizerPreparationMetadataFingerprintsAndModuleContract(t *testing.T) { + unbound, err := New(Options{}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if metadata := unbound.ManifestMetadata(); metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil || metadata["normalization_policy"] != NormalizationPolicy || metadata["identity_policy"] != identity.Policy { + t.Fatalf("unbound metadata = %#v", metadata) + } + if got := unbound.CheckpointFingerprints(); len(got) != 2 || got[0].Name != "normalization_policy" || got[1].Name != "identity_policy" { + t.Fatalf("unbound fingerprints = %#v", got) + } + + bound, err := New(Options{}, npcReferences(t)) + if err != nil { + t.Fatalf("bound New() error = %v", err) + } + metadata := bound.ManifestMetadata() + if metadata["npc_registry_digest"] == "" || metadata["npc_count"] != 2 { + t.Fatalf("bound metadata = %#v", metadata) + } + 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 { + t.Fatalf("ModuleSpec() = %#v", spec) + } + registry := pipeline.NewNormalizerRegistry() + if err := Register(registry); err != nil { + t.Fatalf("Register() error = %v", err) + } + if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil { + t.Fatal("DecodeOptions() accepted unknown option") + } + if _, err := New(Options{}, contracts.ReferenceSet{}, contracts.ReferenceSet{}); err == nil { + t.Fatal("New() accepted multiple reference sets") + } +} + +func TestNormalizerRejectsNilAndCanceledCalls(t *testing.T) { + normalizer, err := New(Options{}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if _, err := normalizer.Normalize(nil, contracts.TypedNormalizeRequest[dnd.CombatTurnList]{}); err == nil { + t.Fatal("Normalize() accepted nil context") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := normalizer.Normalize(ctx, contracts.TypedNormalizeRequest[dnd.CombatTurnList]{}); err == nil { + t.Fatal("Normalize() accepted canceled context") + } +} + +func validTurn(actor string, ref source.SourceRef) dnd.CombatTurn { + return dnd.CombatTurn{ + Actor: actor, + TurnKind: dnd.CombatTurnKindTurn, + Round: intPointer(1), + Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: actor + " attacks", Targets: []string{}, Resolution: nil}}, + Summary: actor + " attacks", + SourceRefs: []source.SourceRef{ref}, + } +} + +func testDocument() *source.SourceDocument { + return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10, Text: "Aria attacks the goblin."}, {ID: 20, Text: "The goblin is hit."}, {ID: 30, Text: "The goblin falls."}}} +} + +func npcReferences(t *testing.T) contracts.ReferenceSet { + t.Helper() + npcs := dnd.NPCList{NPCs: []dnd.NPC{ + {ID: identity.DeriveID("Aria"), Name: "Aria", Aliases: []string{"Storm"}, Description: "a fighter", Relationships: []dnd.NPCRelationship{{Target: "Goblin", Relationship: "fights"}}, SourceRefs: []source.SourceRef{{SourceID: "npc-run", StartUnitID: 1, EndUnitID: 1}}}, + {ID: identity.DeriveID("Goblin"), Name: "Goblin", Aliases: []string{"Minion"}, Description: "a goblin", Relationships: []dnd.NPCRelationship{{Target: "Aria", Relationship: "fights"}}, SourceRefs: []source.SourceRef{{SourceID: "npc-run", StartUnitID: 1, EndUnitID: 1}}}, + }} + content, err := npccodec.New().Encode(npcs) + if err != nil { + t.Fatalf("encode NPC references: %v", err) + } + return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ + NPCRegistryReferenceSlot: { + Slot: contracts.ReferenceSlot{Name: NPCRegistryReferenceSlot, AcceptedMediaTypes: []string{npccodec.MediaType}, MaxBytes: NPCRegistryMaxBytes}, + Items: []contracts.ReferenceItem{{SlotName: NPCRegistryReferenceSlot, MediaType: npccodec.MediaType, Content: content}}, + }, + }} +} + +func hasWarningReason(warnings []contracts.Warning, reason string) bool { + for _, warning := range warnings { + if warning.ReasonCode == reason { + return true + } + } + return false +} + +func intPointer(value int) *int { return &value } + +func withActor(turn dnd.CombatTurn, actor string) dnd.CombatTurn { + turn.Actor = actor + turn.Actions = cloneCombatTurn(turn).Actions + return turn +} + +func withKind(turn dnd.CombatTurn, kind dnd.CombatTurnKind) dnd.CombatTurn { + turn.TurnKind = kind + turn.Actions = cloneCombatTurn(turn).Actions + return turn +} + +func withRound(turn dnd.CombatTurn, round int) dnd.CombatTurn { + turn.Round = intPointer(round) + turn.Actions = cloneCombatTurn(turn).Actions + return turn +} + +func withRef(turn dnd.CombatTurn, ref source.SourceRef) dnd.CombatTurn { + turn.SourceRefs = []source.SourceRef{ref} + turn.Actions = cloneCombatTurn(turn).Actions + return turn +} diff --git a/internal/modules/dnd/validate/combatturns/invariants/validator.go b/internal/modules/dnd/validate/combatturns/invariants/validator.go new file mode 100644 index 0000000..d797e12 --- /dev/null +++ b/internal/modules/dnd/validate/combatturns/invariants/validator.go @@ -0,0 +1,218 @@ +// Package invariants validates normalized D&D combat-turn artifacts. +package invariants + +import ( + "context" + "fmt" + "strconv" + "strings" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" + combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape" +) + +const ( + Key = "normalize/dnd/combat-turns/invariants" + ReasonCode = "invalid_combat_turn_normalization" + policy = "dnd.combat_turns.validator.normalized.v1" +) + +type Options struct{} +type Validator struct{} + +var _ contracts.TypedValidator[dnd.CombatTurnList] = (*Validator)(nil) +var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) + +func New(Options) *Validator { return &Validator{} } +func (v *Validator) Name() string { return Key } +func (v *Validator) ExecutionClass() contracts.ExecutionClass { + return contracts.ExecutionClassDeterministic +} +func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint { + return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}} +} + +func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.CombatTurnList]) (contracts.ValidationResult, error) { + if err := Validate(req.Source, req.Value); err != nil { + return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error()}, nil + } + return contracts.ValidationResult{Approved: true}, nil +} + +// Validate checks only invariants owned by normalized combat-turn output. A +// shape or source-reference failure is deliberately deferred to its owner. +func Validate(doc *source.SourceDocument, value dnd.CombatTurnList) error { + if combatshape.Validate(value) != nil || !sourceRefsValid(doc, value) { + return nil + } + issues := issuesFor(doc, value) + if len(issues) == 0 { + return nil + } + return fmt.Errorf("%s", diagnostics.Aggregate("invalid combat turn normalization", issues)) +} + +func issuesFor(doc *source.SourceDocument, value dnd.CombatTurnList) []string { + issues := make([]string, 0) + seenIdentity := make(map[string]int) + previousPosition := -1 + for turnIndex, turn := range value.CombatTurns { + prefix := fmt.Sprintf("combat_turns[%d]", turnIndex) + if turn.Actor != identity.NormalizeDisplay(turn.Actor) { + issues = append(issues, prefix+".actor is not display-normalized: "+diagnostics.Quote(turn.Actor)) + } + if turn.Summary != identity.NormalizeDisplay(turn.Summary) { + issues = append(issues, prefix+".summary is not display-normalized: "+diagnostics.Quote(turn.Summary)) + } + for actionIndex, action := range turn.Actions { + actionPrefix := fmt.Sprintf("%s.actions[%d]", prefix, actionIndex) + if action.Declaration != identity.NormalizeDisplay(action.Declaration) { + issues = append(issues, actionPrefix+".declaration is not display-normalized: "+diagnostics.Quote(action.Declaration)) + } + seenTargets := make(map[string]int, len(action.Targets)) + for targetIndex, target := range action.Targets { + if target != identity.NormalizeDisplay(target) { + issues = append(issues, fmt.Sprintf("%s.targets[%d] is not display-normalized: %s", actionPrefix, targetIndex, diagnostics.Quote(target))) + } + key := identity.ComparisonKey(target) + if previous, exists := seenTargets[key]; exists { + issues = append(issues, fmt.Sprintf("%s.targets[%d] duplicates target %d under comparison identity", actionPrefix, targetIndex, previous)) + } else { + seenTargets[key] = targetIndex + } + } + if action.Resolution != nil && *action.Resolution != identity.NormalizeDisplay(*action.Resolution) { + issues = append(issues, actionPrefix+".resolution is not display-normalized: "+diagnostics.Quote(*action.Resolution)) + } + } + + for refIndex := 1; refIndex < len(turn.SourceRefs); refIndex++ { + previous := turn.SourceRefs[refIndex-1] + current := turn.SourceRefs[refIndex] + if sourceRefLess(current, previous) { + issues = append(issues, fmt.Sprintf("%s.source_refs are not in canonical order at index %d", prefix, refIndex)) + } else if current == previous { + issues = append(issues, fmt.Sprintf("%s.source_refs[%d] duplicates the previous reference", prefix, refIndex)) + } + } + + position, ok := earliestSourcePosition(doc, turn) + if !ok { + continue + } + if previousPosition > position { + issues = append(issues, fmt.Sprintf("%s is out of chronological order: evidence position %d follows %d", prefix, position, previousPosition)) + } + previousPosition = position + + if key, ok := duplicateKey(turn); ok { + if previous, exists := seenIdentity[key]; exists { + issues = append(issues, fmt.Sprintf("%s duplicates combat turn %d under normalized identity", prefix, previous)) + } else { + seenIdentity[key] = turnIndex + } + } + } + return issues +} + +func sourceRefsValid(doc *source.SourceDocument, value dnd.CombatTurnList) bool { + for _, turn := range value.CombatTurns { + for _, ref := range turn.SourceRefs { + if source.ValidateRef(doc, ref) != nil { + return false + } + } + } + return true +} + +func sourceRefLess(left, right source.SourceRef) bool { + if left.SourceID != right.SourceID { + return left.SourceID < right.SourceID + } + if left.StartUnitID != right.StartUnitID { + return left.StartUnitID < right.StartUnitID + } + return left.EndUnitID < right.EndUnitID +} + +func earliestSourcePosition(doc *source.SourceDocument, turn dnd.CombatTurn) (int, bool) { + if doc == nil { + return 0, false + } + earliest := 0 + found := false + for _, ref := range turn.SourceRefs { + if source.ValidateRef(doc, ref) != nil { + continue + } + index, ok := source.UnitIndex(doc, ref.StartUnitID) + if !ok || (found && index >= earliest) { + continue + } + earliest = index + found = true + } + return earliest, found +} + +func duplicateKey(turn dnd.CombatTurn) (string, bool) { + if len(turn.SourceRefs) == 0 { + return "", false + } + var key strings.Builder + writeKeyString(&key, identity.ComparisonKey(turn.Actor)) + writeKeyString(&key, string(turn.TurnKind)) + if turn.Round == nil { + key.WriteByte('0') + } else { + key.WriteByte('1') + writeKeyInt(&key, *turn.Round) + } + for _, ref := range turn.SourceRefs { + writeKeyString(&key, ref.SourceID) + writeKeyInt(&key, ref.StartUnitID) + writeKeyInt(&key, ref.EndUnitID) + } + return key.String(), true +} + +func writeKeyString(builder *strings.Builder, value string) { + builder.WriteString(strconv.Itoa(len(value))) + builder.WriteByte(':') + builder.WriteString(value) +} + +func writeKeyInt(builder *strings.Builder, value int) { + builder.WriteString(strconv.Itoa(value)) + builder.WriteByte(';') +} + +func Spec() pipeline.ValidatorSpec { + return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} +} + +func Register(registry *pipeline.ValidatorRegistry) error { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.CombatTurnListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.CombatTurnList], error) { + options, err := DecodeOptions(request.Options) + if err != nil { + return nil, err + } + return New(options), nil + }) +} + +func DecodeOptions(options map[string]any) (Options, error) { + if err := pipeline.RejectUnknownOptions(options); err != nil { + return Options{}, err + } + return Options{}, nil +} + +func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err } diff --git a/internal/modules/dnd/validate/combatturns/invariants/validator_test.go b/internal/modules/dnd/validate/combatturns/invariants/validator_test.go new file mode 100644 index 0000000..19ab7cf --- /dev/null +++ b/internal/modules/dnd/validate/combatturns/invariants/validator_test.go @@ -0,0 +1,141 @@ +package invariants + +import ( + "context" + "reflect" + "strings" + "testing" + "unicode/utf8" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" +) + +func TestValidatorApprovesNormalizedCombatTurns(t *testing.T) { + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: invariantDocument(), Value: normalizedList()}) + if err != nil || !result.Approved || len(result.Warnings) != 0 { + t.Fatalf("Validate() = %#v, %v; want approval without warnings", result, err) + } +} + +func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) { + tests := []struct { + name string + mutate func(*dnd.CombatTurnList, *source.SourceDocument) + want string + }{ + {name: "actor display whitespace", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) { value.CombatTurns[0].Actor = " Aria " }, want: "actor is not display-normalized"}, + {name: "summary display whitespace", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) { + value.CombatTurns[0].Summary = "Aria attacks" + }, want: "summary is not display-normalized"}, + {name: "declaration display whitespace", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) { + value.CombatTurns[0].Actions[0].Declaration = "Aria attacks" + }, want: "declaration is not display-normalized"}, + {name: "target display whitespace", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) { + value.CombatTurns[0].Actions[0].Targets = []string{" Goblin"} + }, want: "targets[0] is not display-normalized"}, + {name: "duplicate target identity", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) { + value.CombatTurns[0].Actions[0].Targets = []string{"Goblin", " goblin"} + }, want: "duplicates target"}, + {name: "resolution display whitespace", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) { + resolution := " hit " + value.CombatTurns[0].Actions[0].Resolution = &resolution + }, want: "resolution is not display-normalized"}, + {name: "reference order", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) { + value.CombatTurns[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}} + }, want: "not in canonical order"}, + {name: "duplicate reference", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) { + value.CombatTurns[0].SourceRefs = append(value.CombatTurns[0].SourceRefs, value.CombatTurns[0].SourceRefs[0]) + }, want: "duplicates the previous reference"}, + {name: "chronology", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) { + value.CombatTurns = []dnd.CombatTurn{withRef(value.CombatTurns[0], source.SourceRef{SourceID: "session", StartUnitID: 20, EndUnitID: 20}), value.CombatTurns[0]} + }, want: "out of chronological order"}, + {name: "duplicate identity", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) { + duplicate := value.CombatTurns[0] + duplicate.Summary = "different prose" + value.CombatTurns = append(value.CombatTurns, duplicate) + }, want: "duplicates combat turn"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + value := normalizedList() + doc := invariantDocument() + test.mutate(&value, doc) + err := Validate(doc, value) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Validate() error = %v, want %q", err, test.want) + } + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: doc, Value: value}) + if err != nil || result.Approved || result.ReasonCode != ReasonCode { + t.Fatalf("Validator result = %#v, %v; want normalized-invariant rejection", result, err) + } + }) + } +} + +func TestValidatorDefersShapeAndSourceReferenceFailures(t *testing.T) { + doc := invariantDocument() + shapeInvalid := normalizedList() + shapeInvalid.CombatTurns[0].Actor = " " + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: doc, Value: shapeInvalid}) + if err != nil || !result.Approved { + t.Fatalf("shape-invalid result = %#v, %v; want deferral", result, err) + } + sourceInvalid := normalizedList() + sourceInvalid.CombatTurns[0].SourceRefs[0].StartUnitID = 999 + result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: doc, Value: sourceInvalid}) + if err != nil || !result.Approved { + t.Fatalf("source-invalid result = %#v, %v; want deferral", result, err) + } +} + +func TestValidatorBoundsDiagnosticsAndRegistration(t *testing.T) { + value := normalizedList() + value.CombatTurns = make([]dnd.CombatTurn, 30) + for index := range value.CombatTurns { + value.CombatTurns[index] = normalizedList().CombatTurns[0] + value.CombatTurns[index].Actor = strings.Repeat("火", 300) + } + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: invariantDocument(), Value: value}) + if err != nil || result.Approved || !utf8.ValidString(result.Message) || len([]byte(result.Message)) > 4096 || !strings.Contains(result.Message, "additional issue(s) omitted") { + t.Fatalf("bounded result = %#v, %v; want bounded rejection", result, err) + } + if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy { + t.Fatalf("CheckpointFingerprints() = %#v, want policy fingerprint", got) + } + if spec := Spec(); spec.Key != Key || spec.ExecutionClass != contracts.ExecutionClassDeterministic { + t.Fatalf("Spec() = %#v", spec) + } + registry := pipeline.NewValidatorRegistry() + if err := Register(registry); err != nil { + t.Fatalf("Register() error = %v", err) + } + if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil { + t.Fatal("DecodeOptions() accepted unknown option") + } + if !reflect.DeepEqual(New(Options{}).CheckpointFingerprints(), []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}) { + t.Fatal("policy fingerprint changed unexpectedly") + } +} + +func normalizedList() dnd.CombatTurnList { + resolution := "hit" + return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ + Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn, Round: intPointer(1), + Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "Aria attacks", Targets: []string{"Goblin"}, Resolution: &resolution}}, + Summary: "Aria attacks", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}, + }}} +} + +func invariantDocument() *source.SourceDocument { + return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}}} +} + +func intPointer(value int) *int { return &value } + +func withRef(turn dnd.CombatTurn, ref source.SourceRef) dnd.CombatTurn { + turn.SourceRefs = []source.SourceRef{ref} + return turn +}