From ed2b6f4580684000a3d6de9cd0ab5232e960398b Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 23 Jul 2026 13:42:37 +0000 Subject: [PATCH] Add NPC interaction normalization --- .../normalize/npcinteractions/normalizer.go | 439 ++++++++++++++++++ .../npcinteractions/normalizer_test.go | 169 +++++++ internal/modules/dnd/register/merge.go | 29 ++ .../modules/dnd/register/register_test.go | 27 ++ .../npcinteractions/invariants/validator.go | 33 +- .../invariants/validator_test.go | 12 + 6 files changed, 700 insertions(+), 9 deletions(-) create mode 100644 internal/modules/dnd/normalize/npcinteractions/normalizer.go create mode 100644 internal/modules/dnd/normalize/npcinteractions/normalizer_test.go diff --git a/internal/modules/dnd/normalize/npcinteractions/normalizer.go b/internal/modules/dnd/normalize/npcinteractions/normalizer.go new file mode 100644 index 0000000..89ea765 --- /dev/null +++ b/internal/modules/dnd/normalize/npcinteractions/normalizer.go @@ -0,0 +1,439 @@ +// Package npcinteractions normalizes merged D&D NPC interaction candidates. +package npcinteractions + +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/npc-interactions" + normalizationPolicy = "dnd.npc_interactions.normalize.v1" + NormalizationPolicy = normalizationPolicy + + ReasonCodeNameCanonicalized = "npc_interaction_name_canonicalized" + ReasonCodeSourceRefsNormalized = "source_references_normalized" + ReasonCodeInteractionsReordered = "npc_interactions_reordered" + ReasonCodeDuplicateCollapsed = "duplicate_npc_interaction_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 interaction disambiguation.", + Party: "Optional party roster reference material used only for interaction disambiguation.", + Players: "Optional player list reference material used only for interaction disambiguation.", + Roster: "Deprecated alias for party roster reference material used only for interaction disambiguation.", +} + +var _ contracts.Normalizer[dnd.NPCInteractionList] = (*Normalizer)(nil) +var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil) +var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil) + +type Options struct{} + +type Normalizer struct { + npcResolver *npcregistry.Resolver +} + +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] + } + resolver, err := npcregistry.NewResolver(referenceSet) + if err != nil { + return nil, normalizerErrorf("prepare NPC registry: %w", err) + } + return &Normalizer{npcResolver: resolver}, 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 || n.npcResolver == nil { + return nil + } + metadata := map[string]any{ + "normalization_policy": normalizationPolicy, + "identity_policy": identity.Policy, + } + seeded := n.npcResolver.Seeded() + if seeded.Bound() { + metadata["npc_registry_digest"] = seeded.Digest() + metadata["npc_count"] = seeded.Count() + } + return metadata +} + +func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint { + if n == nil || n.npcResolver == nil { + return nil + } + return []pipeline.CheckpointFingerprint{ + {Name: "normalization_policy", Value: normalizationPolicy}, + {Name: "identity_policy", Value: identity.Policy}, + {Name: "npc_registry", Value: n.npcResolver.Seeded().ProjectionDigest()}, + } +} + +func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.NPCInteractionList]) (contracts.TypedNormalizeResult[dnd.NPCInteractionList], error) { + if n == nil || n.npcResolver == nil { + return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("normalizer must not be nil") + } + if ctx == nil { + return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("context must not be nil") + } + if err := ctx.Err(); err != nil { + return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("context error before normalize: %w", err) + } + + registry, err := n.npcResolver.Resolve(req.References) + if err != nil { + return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("resolve NPC registry: %w", err) + } + if !registry.Bound() { + return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("NPC registry reference is required") + } + value, warnings := normalizeList(req.MergeOutput.Value, req.Source, registry) + return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{Value: value, Warnings: warnings}, nil +} + +type normalizedRecord struct { + interaction dnd.NPCInteraction + inputIndex int + earliest int + hasEvidence bool +} + +type nameCanonicalization struct { + from string + to string +} + +func normalizeList(input dnd.NPCInteractionList, doc *source.SourceDocument, registry *npcregistry.Registry) (dnd.NPCInteractionList, []contracts.Warning) { + if input.Interactions == nil { + return dnd.NPCInteractionList{}, nil + } + + records := make([]normalizedRecord, len(input.Interactions)) + warnings := make([]contracts.Warning, 0) + for index, inputInteraction := range input.Interactions { + interaction, nameChange, refsChanged := normalizeInteraction(inputInteraction, doc, registry) + earliest, hasEvidence := earliestSourcePosition(doc, interaction) + records[index] = normalizedRecord{interaction: interaction, inputIndex: index, earliest: earliest, hasEvidence: hasEvidence} + if nameChange != nil { + warnings = append(warnings, contracts.Warning{ + Scope: interactionScope(index), + ReasonCode: ReasonCodeNameCanonicalized, + Message: fmt.Sprintf("input index %d: NPC name canonicalized from %s to %s", + index, diagnostics.Quote(nameChange.from), diagnostics.Quote(nameChange.to)), + }) + } + if refsChanged { + warnings = append(warnings, contracts.Warning{ + Scope: interactionScope(index), + ReasonCode: ReasonCodeSourceRefsNormalized, + Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", + index, len(inputInteraction.SourceRefs), len(interaction.SourceRefs)), + }) + } + } + + sort.SliceStable(records, func(left, right int) bool { return recordLess(doc, records[left], records[right]) }) + for position, record := range records { + if position == record.inputIndex { + continue + } + warnings = append(warnings, contracts.Warning{ + Scope: interactionScope(record.inputIndex), + ReasonCode: ReasonCodeInteractionsReordered, + 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.NPCInteractionList{Interactions: output}, warnings +} + +func normalizeInteraction(input dnd.NPCInteraction, doc *source.SourceDocument, registry *npcregistry.Registry) (dnd.NPCInteraction, *nameCanonicalization, bool) { + output := cloneInteraction(input) + if canonical, ok := registry.Lookup(identity.NormalizeDisplay(input.Name)); ok { + output.Name = canonical.Name + } + var nameChange *nameCanonicalization + if input.Name != output.Name { + nameChange = &nameCanonicalization{from: input.Name, to: output.Name} + } + output.SourceRefs = canonicalizeSourceRefs(doc, input.SourceRefs) + return output, nameChange, !sourceRefsEqual(input.SourceRefs, output.SourceRefs) +} + +func cloneInteraction(input dnd.NPCInteraction) dnd.NPCInteraction { + output := input + if input.SourceRefs != nil { + output.SourceRefs = append([]source.SourceRef(nil), input.SourceRefs...) + } + return output +} + +func canonicalizeSourceRefs(doc *source.SourceDocument, input []source.SourceRef) []source.SourceRef { + if input == nil { + return nil + } + canonical := append([]source.SourceRef(nil), input...) + sort.SliceStable(canonical, func(left, right int) bool { return sourceRefLess(doc, canonical[left], canonical[right]) }) + 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 +} + +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 sourceRefLess(doc *source.SourceDocument, left, right source.SourceRef) bool { + if left.SourceID != right.SourceID { + return left.SourceID < right.SourceID + } + leftStart, leftStartOK := source.UnitIndex(doc, left.StartUnitID) + rightStart, rightStartOK := source.UnitIndex(doc, right.StartUnitID) + if leftStartOK != rightStartOK { + return leftStartOK + } + if leftStartOK && leftStart != rightStart { + return leftStart < rightStart + } + if left.StartUnitID != right.StartUnitID { + return left.StartUnitID < right.StartUnitID + } + leftEnd, leftEndOK := source.UnitIndex(doc, left.EndUnitID) + rightEnd, rightEndOK := source.UnitIndex(doc, right.EndUnitID) + if leftEndOK != rightEndOK { + return leftEndOK + } + if leftEndOK && leftEnd != rightEnd { + return leftEnd < rightEnd + } + return left.EndUnitID < right.EndUnitID +} + +func earliestSourcePosition(doc *source.SourceDocument, interaction dnd.NPCInteraction) (int, bool) { + found := false + earliest := 0 + for _, ref := range interaction.SourceRefs { + if source.ValidateRef(doc, ref) != nil { + continue + } + position, ok := source.UnitIndex(doc, ref.StartUnitID) + if !ok || (found && position >= earliest) { + continue + } + earliest = position + found = true + } + return earliest, found +} + +func recordLess(doc *source.SourceDocument, left, right normalizedRecord) bool { + if left.hasEvidence != right.hasEvidence { + return left.hasEvidence + } + if left.hasEvidence && left.earliest != right.earliest { + return left.earliest < right.earliest + } + leftKey := identity.ComparisonKey(left.interaction.Name) + rightKey := identity.ComparisonKey(right.interaction.Name) + if leftKey != rightKey { + return leftKey < rightKey + } + if left.interaction.Name != right.interaction.Name { + return left.interaction.Name < right.interaction.Name + } + if left.interaction.Kind != right.interaction.Kind { + return left.interaction.Kind < right.interaction.Kind + } + return sourceRefsLess(doc, left.interaction.SourceRefs, right.interaction.SourceRefs) +} + +func sourceRefsLess(doc *source.SourceDocument, left, right []source.SourceRef) bool { + for index := 0; index < len(left) && index < len(right); index++ { + if left[index] == right[index] { + continue + } + return sourceRefLess(doc, left[index], right[index]) + } + return len(left) < len(right) +} + +type duplicateGroup struct { + retainedIndex int + removed []int +} + +func collapseDuplicates(records []normalizedRecord, doc *source.SourceDocument) ([]dnd.NPCInteraction, []contracts.Warning) { + if len(records) == 0 { + return make([]dnd.NPCInteraction, 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.interaction, 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.NPCInteraction, 0, len(records)) + for index, record := range records { + if keep[index] { + output = append(output, cloneInteraction(record.interaction)) + } + } + warnings := make([]contracts.Warning, 0) + for _, group := range groups { + if len(group.removed) != 0 { + warnings = append(warnings, duplicateWarning(group.retainedIndex, group.removed)) + } + } + return output, warnings +} + +func duplicateKey(interaction dnd.NPCInteraction, doc *source.SourceDocument) (string, bool) { + if len(interaction.SourceRefs) == 0 { + return "", false + } + for _, ref := range interaction.SourceRefs { + if source.ValidateRef(doc, ref) != nil { + return "", false + } + } + var key strings.Builder + writeKeyString(&key, interaction.Name) + writeKeyString(&key, string(interaction.Kind)) + for _, ref := range interaction.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 { + issues := make([]string, len(removed)) + for index, removedIndex := range removed { + issues[index] = fmt.Sprintf("removed input index %d", removedIndex) + } + return contracts.Warning{ + Scope: interactionScope(retainedIndex), + ReasonCode: ReasonCodeDuplicateCollapsed, + Message: diagnostics.Aggregate( + fmt.Sprintf("duplicate NPC interaction collapsed; retained input index %d", retainedIndex), issues), + } +} + +func interactionScope(index int) string { return fmt.Sprintf("interactions[%d]", index) } + +func referenceSlots() []contracts.ReferenceSlot { + slots := shared.ReferenceSlots(referenceSlotDescriptions) + slots = append(slots, contracts.ReferenceSlot{ + Name: NPCRegistryReferenceSlot, + Description: "Required normalized NPC registry used only for interaction identity grounding, never as interaction evidence.", + Required: true, + AcceptedMediaTypes: []string{"application/json"}, + AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind}, + MaxBytes: NPCRegistryMaxBytes, + }) + sort.Slice(slots, func(left, right int) bool { return slots[left].Name < slots[right].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.NPCInteractionListKind, + ReferenceSlots: referenceSlots(), + } +} + +func Register(registry *pipeline.NormalizerRegistry) error { + return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.NPCInteractionList], error) { + options, err := DecodeOptions(request.Options) + if err != nil { + return nil, err + } + return New(options, request.References) + }) +} + +func DecodeOptions(options map[string]any) (Options, error) { + if err := pipeline.RejectUnknownOptions(options); err != nil { + return Options{}, normalizerErrorf("%w", err) + } + return Options{}, nil +} + +func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err } + +func normalizerErrorf(format string, args ...any) error { + return fmt.Errorf("dnd NPC interactions normalizer: "+format, args...) +} diff --git a/internal/modules/dnd/normalize/npcinteractions/normalizer_test.go b/internal/modules/dnd/normalize/npcinteractions/normalizer_test.go new file mode 100644 index 0000000..9768e1d --- /dev/null +++ b/internal/modules/dnd/normalize/npcinteractions/normalizer_test.go @@ -0,0 +1,169 @@ +package npcinteractions + +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 TestNormalizeCanonicalizesAndClones(t *testing.T) { + doc := testDocument() + normalizer, err := New(Options{}, npcReferences(t)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + input := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{ + Name: " áRIA ", Kind: dnd.NPCInteractionKindDialogue, + SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}, + }}} + original := append([]source.SourceRef(nil), input.Interactions[0].SourceRefs...) + result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}}) + if err != nil { + t.Fatalf("Normalize() error = %v", err) + } + got := result.Value.Interactions[0] + if got.Name != "Ária" || !reflect.DeepEqual(got.SourceRefs, []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}) { + t.Fatalf("normalized interaction = %#v", got) + } + if !hasWarning(result.Warnings, ReasonCodeNameCanonicalized) || !hasWarning(result.Warnings, ReasonCodeSourceRefsNormalized) { + t.Fatalf("warnings = %#v", result.Warnings) + } + if !reflect.DeepEqual(input.Interactions[0].SourceRefs, original) { + t.Fatalf("Normalize() mutated input: %#v", input) + } + second, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: result.Value}}) + if err != nil || !reflect.DeepEqual(second.Value, result.Value) || len(second.Warnings) != 0 { + t.Fatalf("second normalization = %#v, %v; want idempotent output without warnings", second, err) + } + result.Value.Interactions[0].SourceRefs[0].StartUnitID = 999 + if input.Interactions[0].SourceRefs[0].StartUnitID == 999 { + t.Fatal("normalized source refs share input storage") + } +} + +func TestNormalizeRequiresOperationRegistryAndPreservesEmptyRepresentation(t *testing.T) { + normalizer, err := New(Options{}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if _, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{}); err == nil { + t.Fatal("Normalize() accepted an unbound NPC registry") + } + for _, input := range []dnd.NPCInteractionList{{}, {Interactions: []dnd.NPCInteraction{}}} { + result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{ + MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}, References: npcReferences(t), + }) + if err != nil || (result.Value.Interactions == nil) != (input.Interactions == nil) { + t.Fatalf("Normalize() = %#v, %v for input %#v", result, err, input) + } + } + if metadata := normalizer.ManifestMetadata(); metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil { + t.Fatalf("operation registry leaked into metadata: %#v", metadata) + } +} + +func TestNormalizeLeavesUnrecognizedNamesUntouched(t *testing.T) { + doc := testDocument() + normalizer, err := New(Options{}, npcReferences(t)) + if err != nil { + t.Fatal(err) + } + input := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{interaction(" Unknown NPC ", dnd.NPCInteractionKindOther, source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10})}} + result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}}) + if err != nil || result.Value.Interactions[0].Name != input.Interactions[0].Name || hasWarning(result.Warnings, ReasonCodeNameCanonicalized) { + t.Fatalf("Normalize() = %#v, %v; want untouched unrecognized name", result, err) + } +} + +func TestNormalizeOrdersAndCollapsesExactDuplicatesOnly(t *testing.T) { + doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 50}, {ID: 10}, {ID: 90}}} + ref := func(unit int) source.SourceRef { + return source.SourceRef{SourceID: doc.ID, StartUnitID: unit, EndUnitID: unit} + } + first := interaction("Ária", dnd.NPCInteractionKindDialogue, ref(50)) + input := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{ + interaction("Borin", dnd.NPCInteractionKindMentioned, ref(90)), + first, + first, + interaction("Ária", dnd.NPCInteractionKindCombatAlly, ref(50)), + interaction("Ária", dnd.NPCInteractionKindDialogue, ref(10)), + interaction("Ária", dnd.NPCInteractionKindDialogue, ref(999)), + }} + normalizer, err := New(Options{}, npcReferences(t)) + if err != nil { + t.Fatal(err) + } + result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}}) + if err != nil { + t.Fatal(err) + } + got := result.Value.Interactions + if len(got) != 5 { + t.Fatalf("interaction count = %d, want 5: %#v", len(got), got) + } + if got[0].Kind != dnd.NPCInteractionKindCombatAlly || got[0].SourceRefs[0].StartUnitID != 50 || got[1].SourceRefs[0].StartUnitID != 50 || got[2].SourceRefs[0].StartUnitID != 10 || got[3].SourceRefs[0].StartUnitID != 90 || got[4].SourceRefs[0].StartUnitID != 999 { + t.Fatalf("canonical order = %#v", got) + } + if !hasWarning(result.Warnings, ReasonCodeInteractionsReordered) || !hasWarning(result.Warnings, ReasonCodeDuplicateCollapsed) { + t.Fatalf("warnings = %#v", result.Warnings) + } +} + +func TestNormalizerContractAndDeterministicWarnings(t *testing.T) { + normalizer, err := New(Options{}, npcReferences(t)) + if err != nil { + t.Fatal(err) + } + if spec := ModuleSpec(); spec.Key != Key || spec.Stage != pipeline.StageNormalize || spec.ArtifactKind != dnd.NPCInteractionListKind || len(spec.ReferenceSlots) == 0 { + t.Fatalf("ModuleSpec() = %#v", spec) + } + if metadata := normalizer.ManifestMetadata(); metadata["normalization_policy"] != normalizationPolicy || metadata["identity_policy"] != identity.Policy || metadata["npc_registry_digest"] == "" || metadata["npc_count"] != 2 { + t.Fatalf("metadata = %#v", metadata) + } + if fingerprints := normalizer.CheckpointFingerprints(); len(fingerprints) != 3 || fingerprints[2].Name != "npc_registry" || fingerprints[2].Value == "" { + t.Fatalf("fingerprints = %#v", fingerprints) + } + if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil { + t.Fatal("DecodeOptions() accepted an unknown option") + } +} + +func interaction(name string, kind dnd.NPCInteractionKind, ref source.SourceRef) dnd.NPCInteraction { + return dnd.NPCInteraction{Name: name, Kind: kind, SourceRefs: []source.SourceRef{ref}} +} + +func testDocument() *source.SourceDocument { + return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}, {ID: 30}}} +} + +func npcReferences(t *testing.T) contracts.ReferenceSet { + t.Helper() + value := dnd.NPCList{NPCs: []dnd.NPC{ + {ID: identity.DeriveID("Ária"), Name: "Ária", SourceRefs: []source.SourceRef{{SourceID: "registry", StartUnitID: 1, EndUnitID: 1}}}, + {ID: identity.DeriveID("Borin"), Name: "Borin", SourceRefs: []source.SourceRef{{SourceID: "registry", StartUnitID: 1, EndUnitID: 1}}}, + }} + content, err := npccodec.New().Encode(value) + if err != nil { + t.Fatalf("encode registry: %v", err) + } + return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{NPCRegistryReferenceSlot: { + Slot: contracts.ReferenceSlot{Name: NPCRegistryReferenceSlot, Required: true, AcceptedMediaTypes: []string{npccodec.MediaType}, AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind}, MaxBytes: NPCRegistryMaxBytes}, + Items: []contracts.ReferenceItem{{SlotName: NPCRegistryReferenceSlot, MediaType: npccodec.MediaType, Content: content}}, + }}} +} + +func hasWarning(warnings []contracts.Warning, reason string) bool { + for _, warning := range warnings { + if warning.ReasonCode == reason { + return true + } + } + return false +} diff --git a/internal/modules/dnd/register/merge.go b/internal/modules/dnd/register/merge.go index b832c34..3b5d781 100644 --- a/internal/modules/dnd/register/merge.go +++ b/internal/modules/dnd/register/merge.go @@ -57,6 +57,27 @@ func appendCombatTurnLists(values []dnd.CombatTurnList) (dnd.CombatTurnList, err return combined, nil } +func appendNPCInteractionLists(values []dnd.NPCInteractionList) (dnd.NPCInteractionList, error) { + count := 0 + present := false + for _, value := range values { + if value.Interactions != nil { + present = true + } + count += len(value.Interactions) + } + if !present { + return dnd.NPCInteractionList{}, nil + } + combined := dnd.NPCInteractionList{Interactions: make([]dnd.NPCInteraction, 0, count)} + for _, value := range values { + for _, interaction := range value.Interactions { + combined.Interactions = append(combined.Interactions, cloneNPCInteraction(interaction)) + } + } + return combined, nil +} + func cloneCombatTurn(value dnd.CombatTurn) dnd.CombatTurn { clone := value if value.SourceRefs != nil { @@ -64,3 +85,11 @@ func cloneCombatTurn(value dnd.CombatTurn) dnd.CombatTurn { } return clone } + +func cloneNPCInteraction(value dnd.NPCInteraction) dnd.NPCInteraction { + clone := value + if value.SourceRefs != nil { + clone.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...) + } + return clone +} diff --git a/internal/modules/dnd/register/register_test.go b/internal/modules/dnd/register/register_test.go index d5199a4..3e6e6d1 100644 --- a/internal/modules/dnd/register/register_test.go +++ b/internal/modules/dnd/register/register_test.go @@ -185,6 +185,33 @@ func TestAppendNPCListsPreservesOrderAndArrayPresence(t *testing.T) { } } +func TestAppendNPCInteractionListsPreservesOrderPresenceAndOwnership(t *testing.T) { + refs := []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}} + input := []dnd.NPCInteractionList{ + {}, + {Interactions: []dnd.NPCInteraction{}}, + {Interactions: []dnd.NPCInteraction{{Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: refs}}}, + {Interactions: []dnd.NPCInteraction{{Name: "Borin", Kind: dnd.NPCInteractionKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}}}, + } + got, err := appendNPCInteractionLists(input) + if err != nil { + t.Fatalf("appendNPCInteractionLists() error = %v", err) + } + if got.Interactions == nil || !reflect.DeepEqual([]string{got.Interactions[0].Name, got.Interactions[1].Name}, []string{"Aria", "Borin"}) { + t.Fatalf("combined interactions = %#v", got) + } + got.Interactions[0].SourceRefs[0].StartUnitID = 999 + if input[2].Interactions[0].SourceRefs[0].StartUnitID == 999 { + t.Fatal("merged interactions share source reference storage") + } + for _, values := range [][]dnd.NPCInteractionList{nil, []dnd.NPCInteractionList{{}, {}}} { + result, err := appendNPCInteractionLists(values) + if err != nil || result.Interactions != nil { + t.Fatalf("nil-only merge = %#v, %v; want nil interactions", result, err) + } + } +} + func TestAppendCombatTurnListsPreservesOrderPresenceAndOwnership(t *testing.T) { refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}} input := []dnd.CombatTurnList{ diff --git a/internal/modules/dnd/validate/npcinteractions/invariants/validator.go b/internal/modules/dnd/validate/npcinteractions/invariants/validator.go index 3f0548d..a11f3da 100644 --- a/internal/modules/dnd/validate/npcinteractions/invariants/validator.go +++ b/internal/modules/dnd/validate/npcinteractions/invariants/validator.go @@ -149,9 +149,12 @@ func issuesFor(doc *source.SourceDocument, value dnd.NPCInteractionList, npcRegi } func interactionLess(doc *source.SourceDocument, left, right dnd.NPCInteraction) bool { - leftPosition, _ := earliestSourcePosition(doc, left) - rightPosition, _ := earliestSourcePosition(doc, right) - if leftPosition != rightPosition { + leftPosition, leftHasEvidence := earliestSourcePosition(doc, left) + rightPosition, rightHasEvidence := earliestSourcePosition(doc, right) + if leftHasEvidence != rightHasEvidence { + return leftHasEvidence + } + if leftHasEvidence && leftPosition != rightPosition { return leftPosition < rightPosition } leftKey := identity.ComparisonKey(left.Name) @@ -182,14 +185,26 @@ func sourceRefLess(doc *source.SourceDocument, left, right source.SourceRef) boo if left.SourceID != right.SourceID { return left.SourceID < right.SourceID } - leftStart, _ := source.UnitIndex(doc, left.StartUnitID) - rightStart, _ := source.UnitIndex(doc, right.StartUnitID) - if leftStart != rightStart { + leftStart, leftStartOK := source.UnitIndex(doc, left.StartUnitID) + rightStart, rightStartOK := source.UnitIndex(doc, right.StartUnitID) + if leftStartOK != rightStartOK { + return leftStartOK + } + if leftStartOK && leftStart != rightStart { return leftStart < rightStart } - leftEnd, _ := source.UnitIndex(doc, left.EndUnitID) - rightEnd, _ := source.UnitIndex(doc, right.EndUnitID) - return leftEnd < rightEnd + if left.StartUnitID != right.StartUnitID { + return left.StartUnitID < right.StartUnitID + } + leftEnd, leftEndOK := source.UnitIndex(doc, left.EndUnitID) + rightEnd, rightEndOK := source.UnitIndex(doc, right.EndUnitID) + if leftEndOK != rightEndOK { + return leftEndOK + } + if leftEndOK && leftEnd != rightEnd { + return leftEnd < rightEnd + } + return left.EndUnitID < right.EndUnitID } func earliestSourcePosition(doc *source.SourceDocument, interaction dnd.NPCInteraction) (int, bool) { diff --git a/internal/modules/dnd/validate/npcinteractions/invariants/validator_test.go b/internal/modules/dnd/validate/npcinteractions/invariants/validator_test.go index 3ba945a..6c54989 100644 --- a/internal/modules/dnd/validate/npcinteractions/invariants/validator_test.go +++ b/internal/modules/dnd/validate/npcinteractions/invariants/validator_test.go @@ -60,6 +60,18 @@ func TestValidatorRejectsOwnedCanonicalNameReferenceOrderListOrderAndDuplicates( } } +func TestValidatorAcceptsValidEvidenceBeforeInvalidEvidence(t *testing.T) { + references := registryReferences(t, "Aria", "Borin") + value := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{ + {Name: "Borin", Kind: dnd.NPCInteractionKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}, + {Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}, + }} + result, err := newValidator(t, references).Validate(context.Background(), request(references, value)) + if err != nil || !result.Approved { + t.Fatalf("Validate() = %#v, %v", result, err) + } +} + func TestValidatorDefersShapeAndSourceReferenceFailuresAndRequiresRegistry(t *testing.T) { references := registryReferences(t, "Aria", "Borin") for _, value := range []dnd.NPCInteractionList{