// Package enemyevents normalizes merged D&D enemy-event candidates. package enemyevents 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" enemyeventmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/enemyevents" 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/enemy-events" normalizationPolicy = "dnd.enemy_events.normalize.v1" NormalizationPolicy = normalizationPolicy ReasonCodeNameCanonicalized = "enemy_event_name_canonicalized" ReasonCodeSourceRefsNormalized = "source_references_normalized" ReasonCodeEventsReordered = "enemy_events_reordered" ReasonCodeDuplicateCollapsed = "duplicate_enemy_event_collapsed" ReasonCodeWarningsOmitted = "enemy_event_normalization_warnings_omitted" ) const ( NPCRegistryReferenceSlot = npcregistry.ReferenceSlot NPCRegistryMaxBytes = npcregistry.MaxBytes ) var requiredCapabilities = []string{"merged"} var providedCapabilities = []string{"normalized"} var _ contracts.Normalizer[dnd.EnemyEventList] = (*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} if seeded := n.npcResolver.Seeded(); seeded.Bound() { metadata["npc_registry_digest"] = seeded.Digest() } 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().ProjectionDigest()}, } } func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.EnemyEventList]) (contracts.TypedNormalizeResult[dnd.EnemyEventList], error) { if n == nil || n.npcResolver == nil { return contracts.TypedNormalizeResult[dnd.EnemyEventList]{}, normalizerErrorf("normalizer must not be nil") } if ctx == nil { return contracts.TypedNormalizeResult[dnd.EnemyEventList]{}, normalizerErrorf("context must not be nil") } if err := ctx.Err(); err != nil { return contracts.TypedNormalizeResult[dnd.EnemyEventList]{}, normalizerErrorf("context error before normalize: %w", err) } registry, err := n.npcResolver.Resolve(req.References) if err != nil { return contracts.TypedNormalizeResult[dnd.EnemyEventList]{}, normalizerErrorf("resolve NPC registry: %w", err) } if !registry.Bound() { return contracts.TypedNormalizeResult[dnd.EnemyEventList]{}, normalizerErrorf("NPC registry reference is required") } index := source.NewDocumentIndex(req.Source) order := shared.NewSourceRefOrderFromIndex(index) value, warnings := normalizeList(req.MergeOutput.Value, order, registry) return contracts.TypedNormalizeResult[dnd.EnemyEventList]{Value: value, Warnings: warnings}, nil } type normalizedRecord struct { event dnd.EnemyEvent inputIndex int } type nameCanonicalization struct { from string to string } func normalizeList(input dnd.EnemyEventList, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.EnemyEventList, []contracts.Warning) { if input.Events == nil { return dnd.EnemyEventList{}, nil } records := make([]normalizedRecord, len(input.Events)) 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} if nameChange != nil { warnings = append(warnings, contracts.Warning{ Scope: eventScope(index), ReasonCode: ReasonCodeNameCanonicalized, Message: fmt.Sprintf("input index %d: subject canonicalized from %s to %s", index, diagnostics.Quote(nameChange.from), diagnostics.Quote(nameChange.to)), }) } if refsChanged { warnings = append(warnings, contracts.Warning{ Scope: eventScope(index), ReasonCode: ReasonCodeSourceRefsNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", index, len(inputEvent.SourceRefs), len(event.SourceRefs)), }) } } sort.SliceStable(records, func(left, right int) bool { return enemyeventmodel.Less(order, records[left].event, records[right].event) }) for position, record := range records { if position == record.inputIndex { continue } warnings = append(warnings, contracts.Warning{ Scope: eventScope(record.inputIndex), ReasonCode: ReasonCodeEventsReordered, Message: fmt.Sprintf("input index %d moved to normalized position %d", record.inputIndex, position), }) } output, duplicateWarnings := collapseDuplicates(records, order) warnings = append(warnings, duplicateWarnings...) return dnd.EnemyEventList{Events: output}, diagnostics.LimitWarnings(warnings, "enemy_events", ReasonCodeWarningsOmitted) } func normalizeEvent(input dnd.EnemyEvent, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.EnemyEvent, *nameCanonicalization, bool) { output := cloneEvent(input) output.Name = enemyeventmodel.NormalizeDisplay(input.Name) if canonical, ok := registry.Lookup(output.Name); ok { output.Name = enemyeventmodel.NormalizeDisplay(canonical.Name) } var nameChange *nameCanonicalization if input.Name != output.Name { nameChange = &nameCanonicalization{from: input.Name, to: output.Name} } output.SourceRefs = order.Canonicalize(input.SourceRefs) return output, nameChange, !rawSourceRefsEqual(input.SourceRefs, output.SourceRefs) } func cloneEvent(input dnd.EnemyEvent) dnd.EnemyEvent { output := input if input.SourceRefs != nil { output.SourceRefs = append([]source.SourceRef(nil), input.SourceRefs...) } return output } func rawSourceRefsEqual(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 } type duplicateGroup struct { retainedIndex int removed []int } func collapseDuplicates(records []normalizedRecord, order shared.SourceRefOrder) ([]dnd.EnemyEvent, []contracts.Warning) { if len(records) == 0 { return make([]dnd.EnemyEvent, 0), nil } kept := make([]normalizedRecord, 0, len(records)) groups := make([]duplicateGroup, 0) for _, record := range records { match := -1 for index := range kept { if enemyeventmodel.ExactEqual(order, kept[index].event, record.event) { match = index break } } if match < 0 { kept = append(kept, record) groups = append(groups, duplicateGroup{retainedIndex: record.inputIndex}) continue } groups[match].removed = append(groups[match].removed, record.inputIndex) } output := make([]dnd.EnemyEvent, len(kept)) for index, record := range kept { output[index] = cloneEvent(record.event) } warnings := make([]contracts.Warning, 0) for _, group := range groups { if len(group.removed) == 0 { continue } issues := make([]string, len(group.removed)) for index, removed := range group.removed { issues[index] = fmt.Sprintf("removed input index %d", removed) } warnings = append(warnings, contracts.Warning{ Scope: eventScope(group.retainedIndex), ReasonCode: ReasonCodeDuplicateCollapsed, Message: diagnostics.Aggregate( fmt.Sprintf("duplicate enemy event collapsed; retained input index %d", group.retainedIndex), issues), }) } return output, warnings } func eventScope(index int) string { return fmt.Sprintf("events[%d]", index) } func referenceSlots() []contracts.ReferenceSlot { return contracts.CloneReferenceSlots([]contracts.ReferenceSlot{{ Name: NPCRegistryReferenceSlot, Description: "Required normalized NPC registry used only to canonicalize enemy-subject names, never as event evidence.", Required: true, AcceptedMediaTypes: []string{"application/json"}, AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind}, 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.EnemyEventListKind, ReferenceSlots: referenceSlots(), } } func Register(registry *pipeline.NormalizerRegistry) error { return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.EnemyEventList], 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 enemy events normalizer: "+format, args...) }