// Package npcoccurrences normalizes merged D&D NPC occurrence candidates. package npcoccurrences import ( "context" "fmt" "sort" "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" occurrencemodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcoccurrences" 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-occurrences" normalizationPolicy = "dnd.npc_occurrences.normalize.v2" NormalizationPolicy = normalizationPolicy ReasonCodeSourceRefsNormalized = "source_references_normalized" ReasonCodeOccurrencesReordered = "npc_occurrences_reordered" ReasonCodeDuplicateCollapsed = "duplicate_npc_occurrence_collapsed" ReasonCodeWarningsOmitted = "npc_occurrence_normalization_warnings_omitted" ) const ( NPCRegistryReferenceSlot = npcregistry.ReferenceSlot NPCRegistryMaxBytes = npcregistry.MaxBytes ) var requiredCapabilities = []string{"merged"} var providedCapabilities = []string{"normalized"} var _ contracts.Normalizer[dnd.NPCOccurrenceList] = (*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, } 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: "npc_registry", Value: n.npcResolver.Seeded().IdentityDigest()}, } } func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]) (contracts.TypedNormalizeResult[dnd.NPCOccurrenceList], error) { if n == nil || n.npcResolver == nil { return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{}, normalizerErrorf("normalizer must not be nil") } if ctx == nil { return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{}, normalizerErrorf("context must not be nil") } if err := ctx.Err(); err != nil { return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{}, normalizerErrorf("context error before normalize: %w", err) } registry, err := n.npcResolver.Resolve(req.References) if err != nil { return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{}, normalizerErrorf("resolve NPC registry: %w", err) } if !registry.Bound() { return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{}, normalizerErrorf("NPC registry reference is required") } index := source.NewDocumentIndex(req.Source) order := shared.NewSourceRefOrderFromIndex(index) value, warnings, err := normalizeList(req.MergeOutput.Value, index, order, registry) if err != nil { return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{}, normalizerErrorf("validate NPC registry pairs: %w", err) } return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{Value: value, Warnings: warnings}, nil } type normalizedRecord struct { occurrence dnd.NPCOccurrence inputIndex int } func normalizeList(input dnd.NPCOccurrenceList, documentIndex source.DocumentIndex, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCOccurrenceList, []contracts.Warning, error) { if input.Occurrences == nil { return dnd.NPCOccurrenceList{}, nil, nil } records := make([]normalizedRecord, len(input.Occurrences)) warnings := make([]contracts.Warning, 0) for index, inputOccurrence := range input.Occurrences { occurrence, refsChanged, err := normalizeOccurrence(inputOccurrence, order, registry) if err != nil { return dnd.NPCOccurrenceList{}, nil, fmt.Errorf("occurrences[%d]: %w", index, err) } records[index] = normalizedRecord{occurrence: occurrence, inputIndex: index} if refsChanged { warnings = append(warnings, contracts.Warning{ Scope: occurrenceScope(index), ReasonCode: ReasonCodeSourceRefsNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", index, len(inputOccurrence.SourceRefs), len(occurrence.SourceRefs)), }) } } sort.SliceStable(records, func(left, right int) bool { return occurrencemodel.Less(order, records[left].occurrence, records[right].occurrence) }) for position, record := range records { if position == record.inputIndex { continue } warnings = append(warnings, contracts.Warning{ Scope: occurrenceScope(record.inputIndex), ReasonCode: ReasonCodeOccurrencesReordered, Message: fmt.Sprintf("input index %d moved to normalized position %d by source chronology", record.inputIndex, position), }) } output, duplicateWarnings := collapseDuplicates(records, documentIndex) warnings = append(warnings, duplicateWarnings...) return dnd.NPCOccurrenceList{Occurrences: output}, diagnostics.LimitWarnings(warnings, "npc_occurrences", ReasonCodeWarningsOmitted), nil } func normalizeOccurrence(input dnd.NPCOccurrence, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCOccurrence, bool, error) { canonical, ok := registry.LookupID(input.NPCID) if !ok { return dnd.NPCOccurrence{}, false, fmt.Errorf("npc_id is not in the NPC registry") } if input.Name != canonical.Name { return dnd.NPCOccurrence{}, false, fmt.Errorf("name does not match npc_id") } output := cloneOccurrence(input) output.SourceRefs = order.Canonicalize(input.SourceRefs) return output, !occurrencemodel.SourceRefsEqual(input.SourceRefs, output.SourceRefs), nil } func cloneOccurrence(input dnd.NPCOccurrence) dnd.NPCOccurrence { output := input if input.SourceRefs != nil { output.SourceRefs = append([]source.SourceRef(nil), input.SourceRefs...) } return output } type duplicateGroup struct { retainedIndex int removed []int } func collapseDuplicates(records []normalizedRecord, documentIndex source.DocumentIndex) ([]dnd.NPCOccurrence, []contracts.Warning) { if len(records) == 0 { return make([]dnd.NPCOccurrence, 0), nil } keep := make([]bool, len(records)) groups := make([]duplicateGroup, 0) groupByKey := make(map[string]int) for index, record := range records { if !occurrencemodel.ValidSourceRefs(documentIndex, record.occurrence.SourceRefs) { keep[index] = true continue } key := occurrencemodel.ExactIdentity(record.occurrence) 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.NPCOccurrence, 0, len(records)) for index, record := range records { if keep[index] { output = append(output, cloneOccurrence(record.occurrence)) } } 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 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: occurrenceScope(retainedIndex), ReasonCode: ReasonCodeDuplicateCollapsed, Message: diagnostics.Aggregate( fmt.Sprintf("duplicate NPC occurrence collapsed; retained input index %d", retainedIndex), issues), } } func occurrenceScope(index int) string { return fmt.Sprintf("occurrences[%d]", index) } func referenceSlots() []contracts.ReferenceSlot { return []contracts.ReferenceSlot{{ Name: NPCRegistryReferenceSlot, Description: "Required normalized NPC registry used only for occurrence identity grounding, never as occurrence evidence.", Required: true, AcceptedMediaTypes: []string{"application/json"}, AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCRegistryKind}, MaxBytes: NPCRegistryMaxBytes, }} } func ModuleSpec() pipeline.ModuleSpec { return pipeline.ModuleSpec{ Key: Key, Stage: pipeline.StageNormalize, ExecutionClass: contracts.ExecutionClassDeterministic, Requires: append([]string(nil), requiredCapabilities...), Provides: append([]string(nil), providedCapabilities...), ArtifactKind: dnd.NPCOccurrenceListKind, ReferenceSlots: referenceSlots(), } } func Register(registry *pipeline.NormalizerRegistry) error { return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.NPCOccurrenceList], 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 occurrences normalizer: "+format, args...) }