From 06c0259788753926c8d8ec4f74f552dfc01968bd Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 21 Jul 2026 02:36:59 +0000 Subject: [PATCH] Add NPC normalization and identity validation --- .../modules/dnd/normalize/npcs/normalizer.go | 546 ++++++++++++++++++ .../dnd/normalize/npcs/normalizer_test.go | 277 +++++++++ .../dnd/validate/npcs/identity/validator.go | 84 +++ .../validate/npcs/identity/validator_test.go | 107 ++++ 4 files changed, 1014 insertions(+) create mode 100644 internal/modules/dnd/normalize/npcs/normalizer.go create mode 100644 internal/modules/dnd/normalize/npcs/normalizer_test.go create mode 100644 internal/modules/dnd/validate/npcs/identity/validator.go create mode 100644 internal/modules/dnd/validate/npcs/identity/validator_test.go diff --git a/internal/modules/dnd/normalize/npcs/normalizer.go b/internal/modules/dnd/normalize/npcs/normalizer.go new file mode 100644 index 0000000..b16d0f7 --- /dev/null +++ b/internal/modules/dnd/normalize/npcs/normalizer.go @@ -0,0 +1,546 @@ +// Package npcs normalizes merged D&D non-player character records. +package npcs + +import ( + "context" + "fmt" + "reflect" + "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" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/diagnostics" +) + +const ( + Key = "dnd/npcs" + NormalizationPolicy = "dnd.npcs.normalize.v1" + + ReasonCodeNPCFieldsNormalized = "npc_fields_normalized" + ReasonCodeNPCIDRecomputed = "npc_id_recomputed" + ReasonCodeSourceReferencesNormalized = "source_references_normalized" + ReasonCodeDuplicateNPCCollapsed = "duplicate_npc_collapsed" + ReasonCodeRelationshipTargetCanonicalized = "relationship_target_canonicalized" +) + +var requiredCapabilities = []string{"merged"} +var providedCapabilities = []string{"normalized"} + +var _ contracts.Normalizer[dnd.NPCList] = (*Normalizer)(nil) +var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil) +var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil) + +type Options struct{} + +type Normalizer struct{} + +func New(Options) *Normalizer { return &Normalizer{} } + +func (n *Normalizer) Key() string { return Key } + +func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil } + +func (n *Normalizer) ManifestMetadata() map[string]any { + if n == nil { + return nil + } + return map[string]any{ + "identity_policy": identity.Policy, + "normalization_policy": NormalizationPolicy, + } +} + +func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint { + if n == nil { + return nil + } + return []pipeline.CheckpointFingerprint{ + {Name: "identity_policy", Value: identity.Policy}, + {Name: "normalization_policy", Value: NormalizationPolicy}, + } +} + +func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.NPCList]) (contracts.TypedNormalizeResult[dnd.NPCList], error) { + if n == nil { + return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("normalizer must not be nil") + } + if ctx == nil { + return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("context must not be nil") + } + if err := ctx.Err(); err != nil { + return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("context error before normalize: %w", err) + } + + value, warnings := normalizeList(req.MergeOutput.Value) + return contracts.TypedNormalizeResult[dnd.NPCList]{Value: value, Warnings: warnings}, nil +} + +type normalizedRecord struct { + npc dnd.NPC + fieldsChanged bool + referencesChanged bool +} + +func normalizeList(input dnd.NPCList) (dnd.NPCList, []contracts.Warning) { + if input.NPCs == nil { + return dnd.NPCList{}, nil + } + + records := make([]normalizedRecord, len(input.NPCs)) + warnings := make([]contracts.Warning, 0) + for index, inputNPC := range input.NPCs { + npc, fieldsChanged, referencesChanged := normalizeRecord(inputNPC) + records[index] = normalizedRecord{ + npc: npc, + fieldsChanged: fieldsChanged, + referencesChanged: referencesChanged, + } + if fieldsChanged { + warnings = append(warnings, contracts.Warning{ + Scope: npcScope(index), + ReasonCode: ReasonCodeNPCFieldsNormalized, + Message: fmt.Sprintf("input index %d: NPC fields normalized for %s", + index, diagnostics.Quote(inputNPC.Name)), + }) + } + if referencesChanged { + warnings = append(warnings, contracts.Warning{ + Scope: npcScope(index), + ReasonCode: ReasonCodeSourceReferencesNormalized, + Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", + index, len(inputNPC.SourceRefs), len(npc.SourceRefs)), + }) + } + if inputNPC.ID != npc.ID { + warnings = append(warnings, contracts.Warning{ + Scope: npcScope(index), + ReasonCode: ReasonCodeNPCIDRecomputed, + Message: fmt.Sprintf("input index %d: NPC ID recomputed from %s", + index, diagnostics.Quote(npc.Name)), + }) + } + } + + components := identityComponents(records) + output := dnd.NPCList{NPCs: make([]dnd.NPC, 0, len(components))} + retainedIndexes := make([]int, 0, len(components)) + for _, members := range components { + consolidated, sourceChanged := consolidate(records, members) + retainedIndex := members[0] + output.NPCs = append(output.NPCs, consolidated) + retainedIndexes = append(retainedIndexes, retainedIndex) + + if sourceChanged { + warnings = append(warnings, contracts.Warning{ + Scope: npcScope(retainedIndex), + ReasonCode: ReasonCodeSourceReferencesNormalized, + Message: fmt.Sprintf("input index %d: source references normalized during identity consolidation (final count %d)", + retainedIndex, len(consolidated.SourceRefs)), + }) + } + if len(members) > 1 { + warnings = append(warnings, duplicateWarning(retainedIndex, members[1:])) + } + } + + warnings = append(warnings, canonicalizeRelationshipTargets(&output, retainedIndexes)...) + return output, warnings +} + +func normalizeRecord(input dnd.NPC) (dnd.NPC, bool, bool) { + output := cloneNPC(input) + output.Name = identity.NormalizeDisplay(input.Name) + output.Aliases = normalizeAliases(input.Aliases, output.Name) + output.Description = strings.TrimSpace(input.Description) + output.Relationships = normalizeRelationships(input.Relationships) + output.SourceRefs, _, _ = canonicalizeSourceRefs(input.SourceRefs) + output.ID = identity.DeriveID(output.Name) + + fieldsChanged := input.Name != output.Name || + !reflect.DeepEqual(input.Aliases, output.Aliases) || + input.Description != output.Description || + !reflect.DeepEqual(input.Relationships, output.Relationships) + referencesChanged := !reflect.DeepEqual(input.SourceRefs, output.SourceRefs) + return output, fieldsChanged, referencesChanged +} + +func cloneNPC(input dnd.NPC) dnd.NPC { + output := input + if input.Aliases != nil { + output.Aliases = append([]string(nil), input.Aliases...) + } + if input.Relationships != nil { + output.Relationships = make([]dnd.NPCRelationship, len(input.Relationships)) + copy(output.Relationships, input.Relationships) + } + if input.SourceRefs != nil { + output.SourceRefs = make([]source.SourceRef, len(input.SourceRefs)) + copy(output.SourceRefs, input.SourceRefs) + } + return output +} + +func normalizeAliases(input []string, canonicalName string) []string { + if input == nil { + return nil + } + + canonicalKey := identity.ComparisonKey(canonicalName) + output := make([]string, 0, len(input)) + seen := make(map[string]struct{}, len(input)) + for _, alias := range input { + normalized := identity.NormalizeDisplay(alias) + key := identity.ComparisonKey(normalized) + if canonicalKey != "" && key == canonicalKey { + continue + } + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + output = append(output, normalized) + } + return output +} + +func normalizeRelationships(input []dnd.NPCRelationship) []dnd.NPCRelationship { + if input == nil { + return nil + } + + output := make([]dnd.NPCRelationship, 0, len(input)) + seen := make(map[relationshipIdentity]struct{}, len(input)) + for _, relationship := range input { + normalized := dnd.NPCRelationship{ + Target: identity.NormalizeDisplay(relationship.Target), + Relationship: strings.TrimSpace(relationship.Relationship), + } + key := relationshipKey(normalized) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + output = append(output, normalized) + } + return output +} + +type relationshipIdentity struct { + target string + relationship string +} + +func relationshipKey(relationship dnd.NPCRelationship) relationshipIdentity { + return relationshipIdentity{ + target: identity.ComparisonKey(relationship.Target), + relationship: identity.ComparisonKey(relationship.Relationship), + } +} + +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 identityComponents(records []normalizedRecord) [][]int { + parent := make([]int, len(records)) + for index := range parent { + parent[index] = index + } + + for left := 0; left < len(records); left++ { + for right := left + 1; right < len(records); right++ { + if recordsCanMerge(records[left].npc, records[right].npc) { + union(parent, left, right) + } + } + } + + byRoot := make(map[int][]int, len(records)) + for index := range records { + root := find(parent, index) + byRoot[root] = append(byRoot[root], index) + } + roots := make([]int, 0, len(byRoot)) + for root := range byRoot { + roots = append(roots, root) + } + sort.Slice(roots, func(left, right int) bool { + return byRoot[roots[left]][0] < byRoot[roots[right]][0] + }) + + components := make([][]int, 0, len(roots)) + for _, root := range roots { + components = append(components, byRoot[root]) + } + return components +} + +func recordsCanMerge(left, right dnd.NPC) bool { + leftCanonical := identity.ComparisonKey(left.Name) + rightCanonical := identity.ComparisonKey(right.Name) + return leftCanonical == rightCanonical || + containsAliasKey(right.Aliases, leftCanonical) || + containsAliasKey(left.Aliases, rightCanonical) +} + +func containsAliasKey(aliases []string, wanted string) bool { + for _, alias := range aliases { + if identity.ComparisonKey(alias) == wanted { + return true + } + } + return false +} + +func find(parent []int, index int) int { + for parent[index] != index { + parent[index] = parent[parent[index]] + index = parent[index] + } + return index +} + +func union(parent []int, left, right int) { + leftRoot := find(parent, left) + rightRoot := find(parent, right) + if leftRoot == rightRoot { + return + } + if leftRoot < rightRoot { + parent[rightRoot] = leftRoot + } else { + parent[leftRoot] = rightRoot + } +} + +func consolidate(records []normalizedRecord, members []int) (dnd.NPC, bool) { + output := cloneNPC(records[members[0]].npc) + originalRefs := cloneSourceRefs(output.SourceRefs) + canonicalKey := identity.ComparisonKey(output.Name) + + for _, member := range members[1:] { + candidate := records[member].npc + appendAlias(&output.Aliases, candidate.Name, canonicalKey) + for _, alias := range candidate.Aliases { + appendAlias(&output.Aliases, alias, canonicalKey) + } + for _, relationship := range candidate.Relationships { + appendRelationship(&output.Relationships, relationship) + } + output.SourceRefs = append(output.SourceRefs, candidate.SourceRefs...) + } + + output.SourceRefs, _, _ = canonicalizeSourceRefs(output.SourceRefs) + output.ID = identity.DeriveID(output.Name) + return output, !reflect.DeepEqual(originalRefs, output.SourceRefs) +} + +func cloneSourceRefs(input []source.SourceRef) []source.SourceRef { + if input == nil { + return nil + } + output := make([]source.SourceRef, len(input)) + copy(output, input) + return output +} + +func appendAlias(aliases *[]string, value, canonicalKey string) { + key := identity.ComparisonKey(value) + if canonicalKey != "" && key == canonicalKey { + return + } + for _, existing := range *aliases { + if identity.ComparisonKey(existing) == key { + return + } + } + *aliases = append(*aliases, value) +} + +func appendRelationship(relationships *[]dnd.NPCRelationship, relationship dnd.NPCRelationship) { + key := relationshipKey(relationship) + for _, existing := range *relationships { + if relationshipKey(existing) == key { + return + } + } + *relationships = append(*relationships, relationship) +} + +func canonicalizeRelationshipTargets(list *dnd.NPCList, retainedIndexes []int) []contracts.Warning { + if list == nil || len(list.NPCs) == 0 { + return nil + } + + owners := make(map[string][]int) + for index, npc := range list.NPCs { + if key := identity.ComparisonKey(npc.Name); key != "" { + owners[key] = append(owners[key], index) + } + for _, alias := range npc.Aliases { + if key := identity.ComparisonKey(alias); key != "" { + owners[key] = appendUniqueIndex(owners[key], index) + } + } + } + + warnings := make([]contracts.Warning, 0) + for outputIndex := range list.NPCs { + npc := &list.NPCs[outputIndex] + originalRelationshipCount := len(npc.Relationships) + for relationshipIndex := range npc.Relationships { + relationship := &npc.Relationships[relationshipIndex] + key := identity.ComparisonKey(relationship.Target) + if key == "" || len(owners[key]) != 1 { + continue + } + target := list.NPCs[owners[key][0]].Name + if relationship.Target == target { + continue + } + oldTarget := relationship.Target + relationship.Target = target + warnings = append(warnings, contracts.Warning{ + Scope: npcScope(retainedIndexes[outputIndex]), + ReasonCode: ReasonCodeRelationshipTargetCanonicalized, + Message: fmt.Sprintf("input index %d: relationship target canonicalized from %s to %s", + retainedIndexes[outputIndex], diagnostics.Quote(oldTarget), diagnostics.Quote(target)), + }) + } + npc.Relationships = deduplicateRelationships(npc.Relationships) + if len(npc.Relationships) != originalRelationshipCount { + warnings = append(warnings, contracts.Warning{ + Scope: npcScope(retainedIndexes[outputIndex]), + ReasonCode: ReasonCodeNPCFieldsNormalized, + Message: fmt.Sprintf("input index %d: duplicate relationships removed after target canonicalization", + retainedIndexes[outputIndex]), + }) + } + } + return warnings +} + +func deduplicateRelationships(input []dnd.NPCRelationship) []dnd.NPCRelationship { + if input == nil { + return nil + } + output := make([]dnd.NPCRelationship, 0, len(input)) + seen := make(map[relationshipIdentity]struct{}, len(input)) + for _, relationship := range input { + key := relationshipKey(relationship) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + output = append(output, relationship) + } + return output +} + +func appendUniqueIndex(values []int, wanted int) []int { + for _, value := range values { + if value == wanted { + return values + } + } + return append(values, wanted) +} + +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: npcScope(retainedIndex), + ReasonCode: ReasonCodeDuplicateNPCCollapsed, + Message: message, + } +} + +func npcScope(index int) string { return fmt.Sprintf("npcs[%d]", index) } + +func ModuleSpec() pipeline.ModuleSpec { + return pipeline.ModuleSpec{ + Key: Key, + Stage: pipeline.StageNormalize, + Requires: append([]string(nil), requiredCapabilities...), + Provides: append([]string(nil), providedCapabilities...), + ArtifactKind: dnd.NPCListKind, + } +} + +func Register(registry *pipeline.NormalizerRegistry) error { + return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.NPCList], error) { + options, err := DecodeOptions(request.Options) + if err != nil { + return nil, err + } + return New(options), nil + }) +} + +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 npcs normalizer: "+format, args...) +} diff --git a/internal/modules/dnd/normalize/npcs/normalizer_test.go b/internal/modules/dnd/normalize/npcs/normalizer_test.go new file mode 100644 index 0000000..34ac5c0 --- /dev/null +++ b/internal/modules/dnd/normalize/npcs/normalizer_test.go @@ -0,0 +1,277 @@ +package npcs + +import ( + "context" + "reflect" + "strings" + "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" + domainidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" +) + +func TestModuleContractAndIdentity(t *testing.T) { + if _, err := DecodeOptions(nil); err != nil { + t.Fatalf("DecodeOptions(nil) error = %v, want nil", err) + } + if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") { + t.Fatalf("DecodeOptions() error = %v, want unknown option error", err) + } + + want := pipeline.ModuleSpec{ + Key: Key, + Stage: pipeline.StageNormalize, + Requires: []string{"merged"}, + Provides: []string{"normalized"}, + ArtifactKind: dnd.NPCListKind, + } + if got := ModuleSpec(); !reflect.DeepEqual(got, want) { + t.Fatalf("ModuleSpec() = %#v, want %#v", got, want) + } + if slots := New(Options{}).ReferenceSlots(); slots != nil { + t.Fatalf("ReferenceSlots() = %#v, want nil", slots) + } + + registry := pipeline.NewNormalizerRegistry() + if err := Register(registry); err != nil { + t.Fatalf("Register() error = %v, want nil", err) + } + if registered, ok := registry.SpecForArtifact(Key, dnd.NPCListKind); !ok || !reflect.DeepEqual(registered, want) { + t.Fatalf("registered spec = %#v, ok = %t, want %#v", registered, ok, want) + } + if err := Register(nil); err == nil || !strings.Contains(err.Error(), "normalizer registry") { + t.Fatalf("Register(nil) error = %v, want registry error", err) + } + + normalizer := New(Options{}) + metadata := normalizer.ManifestMetadata() + if metadata["identity_policy"] != domainidentity.Policy || metadata["normalization_policy"] != NormalizationPolicy { + t.Fatalf("metadata = %#v, want identity and normalization policies", metadata) + } + fingerprints := normalizer.CheckpointFingerprints() + wantFingerprints := []pipeline.CheckpointFingerprint{ + {Name: "identity_policy", Value: domainidentity.Policy}, + {Name: "normalization_policy", Value: NormalizationPolicy}, + } + if !reflect.DeepEqual(fingerprints, wantFingerprints) { + t.Fatalf("fingerprints = %#v, want %#v", fingerprints, wantFingerprints) + } + fingerprints[0].Value = "changed" + if got := normalizer.CheckpointFingerprints(); !reflect.DeepEqual(got, wantFingerprints) { + t.Fatalf("fingerprints were not defensive: %#v", got) + } +} + +func TestNormalizePerRecordFieldsAndEvidence(t *testing.T) { + input := dnd.NPCList{NPCs: []dnd.NPC{{ + ID: "wrong", + Name: " Lady\tAsh ", + Aliases: []string{" Ash ", " L.A. ", "l.a.", " Lady Ash "}, + Description: " first description\n", + Relationships: []dnd.NPCRelationship{ + {Target: " Lord\nOak ", Relationship: " friend "}, + {Target: "lord oak", Relationship: "friend"}, + }, + SourceRefs: []source.SourceRef{ + {SourceID: "b", StartUnitID: 2, EndUnitID: 3}, + {SourceID: "a", StartUnitID: 4, EndUnitID: 4}, + {SourceID: "b", StartUnitID: 2, EndUnitID: 3}, + }, + }}} + + result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(input)) + if err != nil { + t.Fatalf("Normalize() error = %v, want nil", err) + } + got := result.Value.NPCs[0] + if got.Name != "Lady Ash" || got.Description != "first description" { + t.Fatalf("normalized fields = %#v, want normalized display and description", got) + } + if !reflect.DeepEqual(got.Aliases, []string{"Ash", "L.A."}) { + t.Fatalf("aliases = %#v, want canonical alias removal and deduplication", got.Aliases) + } + wantRelationships := []dnd.NPCRelationship{{Target: "Lord Oak", Relationship: "friend"}} + if !reflect.DeepEqual(got.Relationships, wantRelationships) { + t.Fatalf("relationships = %#v, want %#v", got.Relationships, wantRelationships) + } + wantRefs := []source.SourceRef{ + {SourceID: "a", StartUnitID: 4, EndUnitID: 4}, + {SourceID: "b", StartUnitID: 2, EndUnitID: 3}, + } + if !reflect.DeepEqual(got.SourceRefs, wantRefs) { + t.Fatalf("source refs = %#v, want %#v", got.SourceRefs, wantRefs) + } + if got.ID != domainidentity.DeriveID("Lady Ash") { + t.Fatalf("ID = %q, want derived ID", got.ID) + } + if !hasWarning(result.Warnings, ReasonCodeNPCFieldsNormalized, "npcs[0]") || + !hasWarning(result.Warnings, ReasonCodeSourceReferencesNormalized, "npcs[0]") || + !hasWarning(result.Warnings, ReasonCodeNPCIDRecomputed, "npcs[0]") { + t.Fatalf("warnings = %#v, want field, source, and ID warnings", result.Warnings) + } +} + +func TestNormalizeConsolidatesIdentityComponentsInStableOrder(t *testing.T) { + input := dnd.NPCList{NPCs: []dnd.NPC{ + { + Name: " Captain Vale ", + Description: "first description", + Aliases: []string{"Vale"}, + Relationships: []dnd.NPCRelationship{{Target: "Archivist", Relationship: "knows"}}, + SourceRefs: []source.SourceRef{{SourceID: "a", StartUnitID: 1, EndUnitID: 1}}, + }, + { + Name: "Captain Vale", + Description: "second description", + Aliases: []string{"CV"}, + SourceRefs: []source.SourceRef{{SourceID: "b", StartUnitID: 1, EndUnitID: 1}}, + }, + { + Name: "The Sage", + Description: "sage description", + Aliases: []string{"Archivist"}, + SourceRefs: []source.SourceRef{{SourceID: "c", StartUnitID: 1, EndUnitID: 1}}, + }, + { + Name: "Archivist", + Description: "later sage description", + Aliases: []string{"Chronicler"}, + SourceRefs: []source.SourceRef{{SourceID: "d", StartUnitID: 1, EndUnitID: 1}}, + }, + { + Name: "North", + Description: "north description", + SourceRefs: []source.SourceRef{{SourceID: "e", StartUnitID: 1, EndUnitID: 1}}, + }, + { + Name: "North Old", + Description: "old north description", + Aliases: []string{"North"}, + SourceRefs: []source.SourceRef{{SourceID: "f", StartUnitID: 1, EndUnitID: 1}}, + }, + { + Name: "North Renamed", + Description: "renamed north description", + Aliases: []string{"North Old"}, + SourceRefs: []source.SourceRef{{SourceID: "g", StartUnitID: 1, EndUnitID: 1}}, + }, + {Name: "Red", Description: "red", Aliases: []string{"Shared"}, SourceRefs: []source.SourceRef{{SourceID: "h", StartUnitID: 1, EndUnitID: 1}}}, + {Name: "Blue", Description: "blue", Aliases: []string{"Shared"}, SourceRefs: []source.SourceRef{{SourceID: "i", StartUnitID: 1, EndUnitID: 1}}}, + }} + + result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(input)) + if err != nil { + t.Fatalf("Normalize() error = %v, want nil", err) + } + if got := len(result.Value.NPCs); got != 5 { + t.Fatalf("normalized NPC count = %d, want five components", got) + } + + first := result.Value.NPCs[0] + if first.Name != "Captain Vale" || first.Description != "first description" || !reflect.DeepEqual(first.Aliases, []string{"Vale", "CV"}) { + t.Fatalf("first component = %#v, want first description and ordered aliases", first) + } + if !reflect.DeepEqual(first.SourceRefs, []source.SourceRef{ + {SourceID: "a", StartUnitID: 1, EndUnitID: 1}, + {SourceID: "b", StartUnitID: 1, EndUnitID: 1}, + }) { + t.Fatalf("first provenance = %#v, want unioned refs", first.SourceRefs) + } + + second := result.Value.NPCs[1] + if second.Name != "The Sage" || !reflect.DeepEqual(second.Aliases, []string{"Archivist", "Chronicler"}) || second.Description != "sage description" { + t.Fatalf("canonical-to-alias component = %#v, want consolidated sage", second) + } + if first.Relationships[0].Target != "The Sage" { + t.Fatalf("relationship target = %q, want The Sage", first.Relationships[0].Target) + } + if !hasWarning(result.Warnings, ReasonCodeRelationshipTargetCanonicalized, "npcs[0]") || + !hasWarning(result.Warnings, ReasonCodeDuplicateNPCCollapsed, "npcs[0]") || + !hasWarning(result.Warnings, ReasonCodeDuplicateNPCCollapsed, "npcs[2]") { + t.Fatalf("warnings = %#v, want collapse and target warnings", result.Warnings) + } + + if got := result.Value.NPCs[2].Aliases; !reflect.DeepEqual(got, []string{"North Old", "North Renamed"}) { + t.Fatalf("transitive aliases = %#v, want ordered canonical members", got) + } + if result.Value.NPCs[3].Name != "Red" || result.Value.NPCs[4].Name != "Blue" { + t.Fatalf("shared-alias ordering = %#v, want Red then Blue", result.Value.NPCs[3:]) + } +} + +func TestNormalizePreservesInvalidEvidenceAndDoesNotAliasInput(t *testing.T) { + invalid := dnd.NPCList{NPCs: []dnd.NPC{{ + Name: " ", + Aliases: []string{""}, + Description: " ", + Relationships: []dnd.NPCRelationship{{Target: " ", Relationship: " "}}, + SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 9, EndUnitID: 9}}, + }}} + original := cloneNPCListForTest(invalid) + result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(invalid)) + if err != nil { + t.Fatalf("Normalize() error = %v, want nil", err) + } + if !reflect.DeepEqual(invalid, original) { + t.Fatalf("normalizer mutated input: got %#v, want %#v", invalid, original) + } + if result.Value.NPCs[0].Name != "" || result.Value.NPCs[0].Aliases[0] != "" || result.Value.NPCs[0].Relationships[0].Target != "" { + t.Fatalf("invalid evidence was unexpectedly removed: %#v", result.Value.NPCs[0]) + } + + result.Value.NPCs[0].Aliases[0] = "changed" + result.Value.NPCs[0].Relationships[0].Target = "changed" + result.Value.NPCs[0].SourceRefs[0].SourceID = "changed" + if invalid.NPCs[0].Aliases[0] != "" || invalid.NPCs[0].Relationships[0].Target != " " || invalid.NPCs[0].SourceRefs[0].SourceID != "source" { + t.Fatalf("output aliases input storage: input = %#v", invalid) + } +} + +func TestNormalizeHandlesNilAndCanceledCalls(t *testing.T) { + normalizer := New(Options{}) + result, err := normalizer.Normalize(context.Background(), normalizeRequest(dnd.NPCList{NPCs: nil})) + if err != nil || result.Value.NPCs != nil { + t.Fatalf("nil list result = %#v, error = %v, want nil NPC slice", result.Value, err) + } + canceled, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := normalizer.Normalize(canceled, normalizeRequest(dnd.NPCList{})); err == nil || !strings.Contains(err.Error(), "context error") { + t.Fatalf("canceled Normalize() error = %v, want context error", err) + } + if _, err := normalizer.Normalize(nil, normalizeRequest(dnd.NPCList{})); err == nil || !strings.Contains(err.Error(), "context must not be nil") { + t.Fatalf("nil context Normalize() error = %v, want context error", err) + } + var nilNormalizer *Normalizer + if _, err := nilNormalizer.Normalize(context.Background(), normalizeRequest(dnd.NPCList{})); err == nil { + t.Fatal("nil normalizer Normalize() error = nil, want error") + } +} + +func normalizeRequest(value dnd.NPCList) contracts.TypedNormalizeRequest[dnd.NPCList] { + return contracts.TypedNormalizeRequest[dnd.NPCList]{ + MergeOutput: contracts.MergeArtifact[dnd.NPCList]{Value: value}, + } +} + +func cloneNPCListForTest(input dnd.NPCList) dnd.NPCList { + output := dnd.NPCList{} + if input.NPCs != nil { + output.NPCs = make([]dnd.NPC, len(input.NPCs)) + for index, npc := range input.NPCs { + output.NPCs[index] = cloneNPC(npc) + } + } + return output +} + +func hasWarning(warnings []contracts.Warning, reason, scope string) bool { + for _, warning := range warnings { + if warning.ReasonCode == reason && warning.Scope == scope { + return true + } + } + return false +} diff --git a/internal/modules/dnd/validate/npcs/identity/validator.go b/internal/modules/dnd/validate/npcs/identity/validator.go new file mode 100644 index 0000000..8241e83 --- /dev/null +++ b/internal/modules/dnd/validate/npcs/identity/validator.go @@ -0,0 +1,84 @@ +// Package identity validates the stable identity invariants of D&D NPC lists. +package identity + +import ( + "context" + "fmt" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + domainidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/diagnostics" + npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape" +) + +const ( + Key = "normalize/dnd/npcs/identity" + ReasonCode = "invalid_npc_identity" + policy = domainidentity.Policy +) + +type Options struct{} +type Validator struct{} + +var _ contracts.TypedValidator[dnd.NPCList] = (*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.NPCList]) (contracts.ValidationResult, error) { + if err := npcshape.Validate(req.Value); err != nil { + return contracts.ValidationResult{Approved: true}, nil + } + identityIssues := domainidentity.ValidateRegistry(req.Value.NPCs) + if len(identityIssues) == 0 { + return contracts.ValidationResult{Approved: true}, nil + } + + issues := make([]string, len(identityIssues)) + for index, issue := range identityIssues { + location := fmt.Sprintf("npcs[%d]", issue.RecordIndex) + if issue.AliasIndex >= 0 { + location += fmt.Sprintf(".aliases[%d]", issue.AliasIndex) + } + issues[index] = fmt.Sprintf("%s %s: %s", location, issue.Code, diagnostics.Quote(issue.Value)) + } + return contracts.ValidationResult{ + Approved: false, + ReasonCode: ReasonCode, + Message: diagnostics.Aggregate("invalid NPC identity", issues), + }, nil +} + +func Spec() pipeline.ValidatorSpec { + return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} +} + +func Register(registry *pipeline.ValidatorRegistry) error { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCList], 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/npcs/identity/validator_test.go b/internal/modules/dnd/validate/npcs/identity/validator_test.go new file mode 100644 index 0000000..7aa16e4 --- /dev/null +++ b/internal/modules/dnd/validate/npcs/identity/validator_test.go @@ -0,0 +1,107 @@ +package identity + +import ( + "context" + "fmt" + "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 TestValidatorContractAndRegistration(t *testing.T) { + if _, err := DecodeOptions(nil); err != nil { + t.Fatalf("DecodeOptions(nil) error = %v, want nil", err) + } + if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") { + t.Fatalf("DecodeOptions() error = %v, want unknown option error", err) + } + if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy { + t.Fatalf("fingerprints = %#v, want identity policy", got) + } + + want := pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} + if got := Spec(); !reflect.DeepEqual(got, want) { + t.Fatalf("Spec() = %#v, want %#v", got, want) + } + registry := pipeline.NewValidatorRegistry() + if err := Register(registry); err != nil { + t.Fatalf("Register() error = %v, want nil", err) + } + if registered, ok := registry.Spec(Key); !ok || !reflect.DeepEqual(registered, want) { + t.Fatalf("registered spec = %#v, ok = %t, want %#v", registered, ok, want) + } + if err := Register(nil); err == nil || !strings.Contains(err.Error(), "validator registry") { + t.Fatalf("Register(nil) error = %v, want registry error", err) + } +} + +func TestValidatorDefersShapeAndRejectsIdentityIssues(t *testing.T) { + validator := New(Options{}) + shapeInvalid := dnd.NPCList{NPCs: []dnd.NPC{{Name: "missing description"}}} + result, err := validator.Validate(context.Background(), validationRequest(shapeInvalid)) + if err != nil || !result.Approved { + t.Fatalf("shape-invalid result = %#v, error = %v, want deferred approval", result, err) + } + + value := validNPCList(2) + value.NPCs[0].ID = "not-an-id" + value.NPCs[1].Aliases = []string{"Shared Alias"} + value.NPCs[0].Aliases = []string{"Shared Alias"} + result, err = validator.Validate(context.Background(), validationRequest(value)) + if err != nil || result.Approved || result.ReasonCode != ReasonCode { + t.Fatalf("identity result = %#v, error = %v, want rejection", result, err) + } + for _, want := range []string{"invalid_id", "alias_owned_by_multiple_records", "npcs[0]", "npcs[1]"} { + if !strings.Contains(result.Message, want) { + t.Fatalf("identity message %q missing %q", result.Message, want) + } + } +} + +func TestValidatorBoundsUnicodeDiagnostics(t *testing.T) { + value := validNPCList(30) + for index := range value.NPCs { + value.NPCs[index].ID = fmt.Sprintf("bad-%d", index) + value.NPCs[index].Name = strings.Repeat("火", 140) + fmt.Sprintf("-%d", index) + } + result, err := New(Options{}).Validate(context.Background(), validationRequest(value)) + if err != nil || result.Approved { + t.Fatalf("bounded result = %#v, error = %v, want rejection", result, err) + } + if !utf8.ValidString(result.Message) || len([]rune(result.Message)) == 0 || len([]byte(result.Message)) > 4096 { + t.Fatalf("bounded message has invalid size/encoding: bytes=%d message=%q", len([]byte(result.Message)), result.Message) + } + if !strings.Contains(result.Message, "additional issue(s) omitted") || strings.Contains(result.Message, strings.Repeat("火", 140)) { + t.Fatalf("bounded message = %q, want omission and truncation", result.Message) + } +} + +func validationRequest(value dnd.NPCList) contracts.TypedValidationRequest[dnd.NPCList] { + return contracts.TypedValidationRequest[dnd.NPCList]{Value: value} +} + +func validNPCList(count int) dnd.NPCList { + value := dnd.NPCList{NPCs: make([]dnd.NPC, count)} + for index := range value.NPCs { + name := fmt.Sprintf("NPC %d", index) + value.NPCs[index] = dnd.NPC{ + ID: "npc:sha256:0000000000000000000000000000000000000000000000000000000000000000", + Name: name, + Aliases: []string{}, + Description: "description", + Relationships: []dnd.NPCRelationship{}, + SourceRefs: []source.SourceRef{sourceRefForTest()}, + } + } + return value +} + +func sourceRefForTest() source.SourceRef { + return source.SourceRef{SourceID: "source", StartUnitID: 1, EndUnitID: 1} +}