From b3ebfcef37674e6cef6eb92541129b35c709783f Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 9 Aug 2026 02:31:28 +0000 Subject: [PATCH] Index enemy event duplicate identities --- .../modules/dnd/enemyevents/enemyevents.go | 44 ++++++++++++++++++- .../dnd/normalize/enemyevents/normalizer.go | 15 ++++--- .../normalize/enemyevents/normalizer_test.go | 15 +++++++ .../enemyevents/invariants/validator.go | 14 +++++- 4 files changed, 79 insertions(+), 9 deletions(-) diff --git a/internal/modules/dnd/enemyevents/enemyevents.go b/internal/modules/dnd/enemyevents/enemyevents.go index a7fde94..ba7f7eb 100644 --- a/internal/modules/dnd/enemyevents/enemyevents.go +++ b/internal/modules/dnd/enemyevents/enemyevents.go @@ -3,6 +3,8 @@ package enemyevents import ( + "encoding/json" + "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" @@ -58,9 +60,49 @@ func SourceRefsEqual(order shared.SourceRefOrder, left, right []source.SourceRef // ExactEqual reports whether events have the same subject identity, kind, and // complete canonical evidence sequence. func ExactEqual(order shared.SourceRefOrder, left, right dnd.EnemyEvent) bool { + left.SourceRefs = order.Canonicalize(left.SourceRefs) + right.SourceRefs = order.Canonicalize(right.SourceRefs) + return CanonicalExactEqual(left, right) +} + +type canonicalIdentity struct { + Subject string `json:"subject"` + Kind dnd.EnemyEventKind `json:"kind"` + SourceRefs []source.SourceRef `json:"source_refs"` +} + +// CanonicalIdentity returns the complete duplicate-lookup key for an event +// whose source references are already canonical. +func CanonicalIdentity(event dnd.EnemyEvent) string { + encoded, err := json.Marshal(canonicalIdentity{ + Subject: ComparisonKey(event.Name), + Kind: event.Kind, + SourceRefs: event.SourceRefs, + }) + if err != nil { + panic("encode enemy event identity") + } + return string(encoded) +} + +// CanonicalExactEqual reports whether events have the same complete duplicate +// identity without modifying or canonicalizing their source references. +func CanonicalExactEqual(left, right dnd.EnemyEvent) bool { return ComparisonKey(left.Name) == ComparisonKey(right.Name) && left.Kind == right.Kind && - SourceRefsEqual(order, left.SourceRefs, right.SourceRefs) + canonicalSourceRefsEqual(left.SourceRefs, right.SourceRefs) +} + +func canonicalSourceRefsEqual(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 } // Less defines the canonical stable event order. Invalid source references diff --git a/internal/modules/dnd/normalize/enemyevents/normalizer.go b/internal/modules/dnd/normalize/enemyevents/normalizer.go index b526bf8..4662e55 100644 --- a/internal/modules/dnd/normalize/enemyevents/normalizer.go +++ b/internal/modules/dnd/normalize/enemyevents/normalizer.go @@ -111,6 +111,7 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize type normalizedRecord struct { event dnd.EnemyEvent + identity string inputIndex int } @@ -127,7 +128,7 @@ func normalizeList(input dnd.EnemyEventList, order shared.SourceRefOrder, regist warnings := make([]contracts.Warning, 0) for index, inputEvent := range input.Events { event, nameChange, refsChanged := normalizeEvent(inputEvent, order, registry) - records[index] = normalizedRecord{event: event, inputIndex: index} + records[index] = normalizedRecord{event: event, identity: enemyeventmodel.CanonicalIdentity(event), inputIndex: index} if nameChange != nil { warnings = append(warnings, contracts.Warning{ Scope: eventScope(index), @@ -160,7 +161,7 @@ func normalizeList(input dnd.EnemyEventList, order shared.SourceRefOrder, regist }) } - output, duplicateWarnings := collapseDuplicates(records, order) + output, duplicateWarnings := collapseDuplicates(records) warnings = append(warnings, duplicateWarnings...) return dnd.EnemyEventList{Events: output}, diagnostics.LimitWarnings(warnings, "enemy_events", ReasonCodeWarningsOmitted) } @@ -204,23 +205,25 @@ type duplicateGroup struct { removed []int } -func collapseDuplicates(records []normalizedRecord, order shared.SourceRefOrder) ([]dnd.EnemyEvent, []contracts.Warning) { +func collapseDuplicates(records []normalizedRecord) ([]dnd.EnemyEvent, []contracts.Warning) { if len(records) == 0 { return make([]dnd.EnemyEvent, 0), nil } kept := make([]normalizedRecord, 0, len(records)) groups := make([]duplicateGroup, 0) + groupPositions := make(map[string][]int, len(records)) for _, record := range records { match := -1 - for index := range kept { - if enemyeventmodel.ExactEqual(order, kept[index].event, record.event) { - match = index + for _, groupIndex := range groupPositions[record.identity] { + if enemyeventmodel.CanonicalExactEqual(kept[groupIndex].event, record.event) { + match = groupIndex break } } if match < 0 { kept = append(kept, record) groups = append(groups, duplicateGroup{retainedIndex: record.inputIndex}) + groupPositions[record.identity] = append(groupPositions[record.identity], len(kept)-1) continue } groups[match].removed = append(groups[match].removed, record.inputIndex) diff --git a/internal/modules/dnd/normalize/enemyevents/normalizer_test.go b/internal/modules/dnd/normalize/enemyevents/normalizer_test.go index e9378c2..a20141f 100644 --- a/internal/modules/dnd/normalize/enemyevents/normalizer_test.go +++ b/internal/modules/dnd/normalize/enemyevents/normalizer_test.go @@ -83,6 +83,21 @@ func TestNormalizeUsesExplicitKindOrderAndPreservesDistinctObservations(t *testi } } +func TestNormalizePreservesDistinctEvidenceWhenEncodedKeysCoincide(t *testing.T) { + invalidSourceID := string([]byte{'s', 0xff}) + replacementSourceID := "s\uFFFD" + document := &source.SourceDocument{ID: invalidSourceID, Units: []source.SourceUnit{{ID: 1}}} + input := dnd.EnemyEventList{Events: []dnd.EnemyEvent{ + {Name: "Ashfang", Kind: dnd.EnemyEventKindEngaged, SourceRefs: []source.SourceRef{{SourceID: invalidSourceID, StartUnitID: 1, EndUnitID: 1}}}, + {Name: "Ashfang", Kind: dnd.EnemyEventKindEngaged, SourceRefs: []source.SourceRef{{SourceID: replacementSourceID, StartUnitID: 1, EndUnitID: 1}}}, + }} + + result, err := newNormalizer(t, npcReferences(t)).Normalize(context.Background(), normalizeRequest(document, input, contracts.ReferenceSet{})) + if err != nil || len(result.Value.Events) != 2 || hasWarning(result.Warnings, ReasonCodeDuplicateCollapsed) { + t.Fatalf("Normalize() = %#v, %v; want distinct evidence observations retained", result, err) + } +} + func TestNormalizeIsIdempotentAndPreservesEmptyRepresentation(t *testing.T) { normalizer := newNormalizer(t, npcReferences(t)) for _, input := range []dnd.EnemyEventList{{}, {Events: []dnd.EnemyEvent{}}} { diff --git a/internal/modules/dnd/validate/enemyevents/invariants/validator.go b/internal/modules/dnd/validate/enemyevents/invariants/validator.go index 5539439..f3d0285 100644 --- a/internal/modules/dnd/validate/enemyevents/invariants/validator.go +++ b/internal/modules/dnd/validate/enemyevents/invariants/validator.go @@ -112,8 +112,10 @@ func allSourceRefsValid(index source.DocumentIndex, value dnd.EnemyEventList) bo func issuesFor(order shared.SourceRefOrder, value dnd.EnemyEventList, registry *npcregistry.Registry) []string { issues := make([]string, 0) + canonicalEvidence := make([]bool, len(value.Events)) for eventIndex, event := range value.Events { prefix := fmt.Sprintf("events[%d]", eventIndex) + canonicalEvidence[eventIndex] = true if normalized := enemyeventmodel.NormalizeDisplay(event.Name); event.Name != normalized { issues = append(issues, prefix+".name is not whitespace-normalized: "+diagnostics.Quote(event.Name)) } @@ -125,8 +127,10 @@ func issuesFor(order shared.SourceRefOrder, value dnd.EnemyEventList, registry * current := event.SourceRefs[refIndex] if order.Less(current, previous) { issues = append(issues, fmt.Sprintf("%s.source_refs are not in canonical order at index %d", prefix, refIndex)) + canonicalEvidence[eventIndex] = false } else if current == previous { issues = append(issues, fmt.Sprintf("%s.source_refs[%d] duplicates the previous reference", prefix, refIndex)) + canonicalEvidence[eventIndex] = false } } } @@ -135,13 +139,19 @@ func issuesFor(order shared.SourceRefOrder, value dnd.EnemyEventList, registry * }) { issues = append(issues, "events are not in canonical order") } + firstByIdentity := make(map[string][]int, len(value.Events)) for eventIndex, event := range value.Events { - for previousIndex := 0; previousIndex < eventIndex; previousIndex++ { - if enemyeventmodel.ExactEqual(order, value.Events[previousIndex], event) { + if !canonicalEvidence[eventIndex] { + continue + } + identity := enemyeventmodel.CanonicalIdentity(event) + for _, previousIndex := range firstByIdentity[identity] { + if enemyeventmodel.CanonicalExactEqual(value.Events[previousIndex], event) { issues = append(issues, fmt.Sprintf("events[%d] duplicates event %d", eventIndex, previousIndex)) break } } + firstByIdentity[identity] = append(firstByIdentity[identity], eventIndex) } return issues }