// Package itemevents normalizes merged D&D item-event candidates. package itemevents 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" itemeventmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/itemevents" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" ) const ( Key = "dnd/item-events" normalizationPolicy = "dnd.item_events.normalize.v1" NormalizationPolicy = normalizationPolicy ReasonCodeDisplayNormalized = "item_event_display_normalized" ReasonCodeSourceRefsNormalized = "source_references_normalized" ReasonCodeEventsReordered = "item_events_reordered" ReasonCodeDuplicateCollapsed = "duplicate_item_event_collapsed" ReasonCodeWarningsOmitted = "item_event_normalization_warnings_omitted" ) var requiredCapabilities = []string{"merged"} var providedCapabilities = []string{"normalized"} var _ contracts.Normalizer[dnd.ItemEventList] = (*Normalizer)(nil) var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil) // Options deliberately has no fields: item-event normalization has no // generated-reference dependency or configurable semantic behavior. 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{"normalization_policy": normalizationPolicy} } func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint { if n == nil { return nil } return []pipeline.CheckpointFingerprint{{Name: "normalization_policy", Value: normalizationPolicy}} } func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.ItemEventList]) (contracts.TypedNormalizeResult[dnd.ItemEventList], error) { if n == nil { return contracts.TypedNormalizeResult[dnd.ItemEventList]{}, normalizerErrorf("normalizer must not be nil") } if ctx == nil { return contracts.TypedNormalizeResult[dnd.ItemEventList]{}, normalizerErrorf("context must not be nil") } if err := ctx.Err(); err != nil { return contracts.TypedNormalizeResult[dnd.ItemEventList]{}, normalizerErrorf("context error before normalize: %w", err) } index := source.NewDocumentIndex(req.Source) value, warnings := normalizeList(req.MergeOutput.Value, index, shared.NewSourceRefOrderFromIndex(index)) return contracts.TypedNormalizeResult[dnd.ItemEventList]{Value: value, Warnings: warnings}, nil } type normalizedRecord struct { event dnd.ItemEvent inputIndex int } func normalizeList(input dnd.ItemEventList, index source.DocumentIndex, order shared.SourceRefOrder) (dnd.ItemEventList, []contracts.Warning) { if input.Events == nil { return dnd.ItemEventList{}, nil } records := make([]normalizedRecord, len(input.Events)) warnings := make([]contracts.Warning, 0) for index, inputEvent := range input.Events { event, changedFields, refsChanged := normalizeEvent(inputEvent, order) records[index] = normalizedRecord{event: event, inputIndex: index} if len(changedFields) != 0 { warnings = append(warnings, contracts.Warning{ Scope: eventScope(index), ReasonCode: ReasonCodeDisplayNormalized, Message: fmt.Sprintf("input index %d: normalized display whitespace in %s", index, diagnostics.Aggregate("fields", changedFields)), }) } 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 itemeventmodel.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, index, order) warnings = append(warnings, duplicateWarnings...) return dnd.ItemEventList{Events: output}, diagnostics.LimitWarnings(warnings, "item_events", ReasonCodeWarningsOmitted) } func normalizeEvent(input dnd.ItemEvent, order shared.SourceRefOrder) (dnd.ItemEvent, []string, bool) { output := cloneEvent(input) changedFields := make([]string, 0, 3) for _, field := range []struct { name string value *string }{ {name: "name", value: &output.Name}, {name: "from", value: &output.From}, {name: "to", value: &output.To}, } { trimmed := itemeventmodel.DisplayValue(*field.value) if *field.value != trimmed { *field.value = trimmed changedFields = append(changedFields, field.name) } } output.SourceRefs = order.Canonicalize(input.SourceRefs) return output, changedFields, !itemeventmodel.SourceRefsEqual(input.SourceRefs, output.SourceRefs) } func cloneEvent(input dnd.ItemEvent) dnd.ItemEvent { output := input if input.Quantity != nil { quantity := *input.Quantity output.Quantity = &quantity } 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, index source.DocumentIndex, order shared.SourceRefOrder) ([]dnd.ItemEvent, []contracts.Warning) { if len(records) == 0 { return make([]dnd.ItemEvent, 0), nil } keep := make([]bool, len(records)) groups := make([]duplicateGroup, 0) groupByKey := make(map[string]int) for recordIndex, record := range records { if !itemeventmodel.ValidSourceRefs(index, record.event.SourceRefs) { keep[recordIndex] = true continue } key := itemeventmodel.ExactIdentity(order, record.event) groupIndex, exists := groupByKey[key] if !exists { groupByKey[key] = len(groups) groups = append(groups, duplicateGroup{retainedIndex: record.inputIndex}) keep[recordIndex] = true continue } groups[groupIndex].removed = append(groups[groupIndex].removed, record.inputIndex) } output := make([]dnd.ItemEvent, 0, len(records)) for recordIndex, record := range records { if keep[recordIndex] { output = append(output, cloneEvent(record.event)) } } 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: eventScope(retainedIndex), ReasonCode: ReasonCodeDuplicateCollapsed, Message: diagnostics.Aggregate( fmt.Sprintf("duplicate item event collapsed; retained input index %d", retainedIndex), issues), } } func eventScope(index int) string { return fmt.Sprintf("events[%d]", index) } 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.ItemEventListKind, } } func Register(registry *pipeline.NormalizerRegistry) error { return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.ItemEventList], 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{}, 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 item events normalizer: "+format, args...) }