// 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...) }