package spells import ( "context" "fmt" "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/shared" spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog" "golang.org/x/text/cases" ) const ( Key = "dnd/spells" normalizationPolicy = "dnd.spells.normalize.v2" NormalizationPolicy = normalizationPolicy ) const ( ReasonCodeSpellNameCanonicalized = "spell_name_canonicalized" ReasonCodeSpellNameUnresolved = "spell_name_unresolved" ReasonCodeSourceReferencesNormalized = "source_references_normalized" ReasonCodeDuplicateSpellCastCollapsed = "duplicate_spell_cast_collapsed" ) var requiredCapabilities = []string{"merged"} var providedCapabilities = []string{"normalized"} var _ contracts.Normalizer[dnd.SpellList] = (*Normalizer)(nil) var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil) type Options struct{} type Normalizer struct { effectiveCatalog spellcatalog.EffectiveCatalog } 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] } effectiveCatalog, err := spellcatalog.ResolveEffectiveCatalog(referenceSet) if err != nil { return nil, normalizerErrorf("resolve effective spell catalog: %w", err) } return &Normalizer{effectiveCatalog: effectiveCatalog}, 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 { return nil } return map[string]any{ "catalog_base_id": n.effectiveCatalog.BaseID(), "catalog_digest": n.effectiveCatalog.Digest(), "catalog_overlay_ids": append([]string(nil), n.effectiveCatalog.OverlayIDs()...), "normalization_policy": normalizationPolicy, } } func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint { if n == nil { return nil } return []pipeline.CheckpointFingerprint{ {Name: "effective_catalog", Value: n.effectiveCatalog.Digest()}, {Name: "normalization_policy", Value: normalizationPolicy}, } } func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.SpellList]) (contracts.TypedNormalizeResult[dnd.SpellList], error) { if n == nil { return contracts.TypedNormalizeResult[dnd.SpellList]{}, normalizerErrorf("normalizer must not be nil") } if ctx == nil { return contracts.TypedNormalizeResult[dnd.SpellList]{}, normalizerErrorf("context must not be nil") } if err := ctx.Err(); err != nil { return contracts.TypedNormalizeResult[dnd.SpellList]{}, normalizerErrorf("context error before normalize: %w", err) } index := source.NewDocumentIndex(req.Source) order := shared.NewSourceRefOrderFromIndex(index) value, warnings := normalizeSpellList(req.MergeOutput.Value, n.effectiveCatalog, order) value, duplicateWarnings := collapseDuplicateSpellCasts(value, index, n.effectiveCatalog) warnings = append(warnings, duplicateWarnings...) return contracts.TypedNormalizeResult[dnd.SpellList]{Value: value, Warnings: warnings}, nil } func normalizeSpellList(input dnd.SpellList, catalog spellcatalog.EffectiveCatalog, order shared.SourceRefOrder) (dnd.SpellList, []contracts.Warning) { var warnings []contracts.Warning if input.SpellCasts == nil { return dnd.SpellList{}, nil } output := dnd.SpellList{SpellCasts: make([]dnd.SpellCast, len(input.SpellCasts))} for index, inputCast := range input.SpellCasts { cast := cloneSpellCast(inputCast) if canonicalName, ok := catalog.Lookup(inputCast.Spell); ok { if inputCast.Spell != canonicalName { warnings = append(warnings, contracts.Warning{ Scope: spellCastScope(index), ReasonCode: ReasonCodeSpellNameCanonicalized, Message: fmt.Sprintf("input index %d: spell name canonicalized from %q to %q", index, boundedName(inputCast.Spell), boundedName(canonicalName)), }) } cast.Spell = canonicalName } else { warnings = append(warnings, contracts.Warning{ Scope: spellCastScope(index), ReasonCode: ReasonCodeSpellNameUnresolved, Message: fmt.Sprintf("input index %d: spell name %q could not be resolved in the effective catalog", index, boundedName(inputCast.Spell)), }) } canonicalRefs, orderChanged, duplicateCount := canonicalizeSourceRefs(order, inputCast.SourceRefs) cast.SourceRefs = canonicalRefs if orderChanged || duplicateCount > 0 { warnings = append(warnings, contracts.Warning{ Scope: spellCastScope(index), ReasonCode: ReasonCodeSourceReferencesNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d, order changed %t, duplicates removed %d)", index, len(inputCast.SourceRefs), len(canonicalRefs), orderChanged, duplicateCount), }) } output.SpellCasts[index] = cast } return output, warnings } func cloneSpellCast(input dnd.SpellCast) dnd.SpellCast { output := input if input.SourceRefs != nil { output.SourceRefs = make([]source.SourceRef, len(input.SourceRefs)) copy(output.SourceRefs, input.SourceRefs) } return output } func canonicalizeSourceRefs(order shared.SourceRefOrder, input []source.SourceRef) ([]source.SourceRef, bool, int) { orderChanged := false for index := 1; index < len(input); index++ { if order.Less(input[index], input[index-1]) { orderChanged = true break } } canonical := order.Canonicalize(input) return canonical, orderChanged, len(input) - len(canonical) } type duplicateGroup struct { retainedIndex int removed []int } func collapseDuplicateSpellCasts(input dnd.SpellList, documentIndex source.DocumentIndex, catalog spellcatalog.EffectiveCatalog) (dnd.SpellList, []contracts.Warning) { if len(input.SpellCasts) == 0 { return input, nil } keep := make([]bool, len(input.SpellCasts)) groups := make([]duplicateGroup, 0) groupByKey := make(map[string]int) for index, cast := range input.SpellCasts { key, eligible := duplicateKey(cast, documentIndex, catalog) if !eligible { keep[index] = true continue } groupIndex, exists := groupByKey[key] if !exists { groupByKey[key] = len(groups) groups = append(groups, duplicateGroup{retainedIndex: index}) keep[index] = true continue } groups[groupIndex].removed = append(groups[groupIndex].removed, index) } removedAny := false for _, group := range groups { if len(group.removed) > 0 { removedAny = true break } } if !removedAny { return input, nil } output := dnd.SpellList{SpellCasts: make([]dnd.SpellCast, 0, len(input.SpellCasts))} for index, cast := range input.SpellCasts { if keep[index] { output.SpellCasts = append(output.SpellCasts, cast) } } warnings := make([]contracts.Warning, 0) for _, group := range groups { if len(group.removed) == 0 { continue } warnings = append(warnings, duplicateWarning(group.retainedIndex, group.removed)) } return output, warnings } func duplicateKey(cast dnd.SpellCast, documentIndex source.DocumentIndex, catalog spellcatalog.EffectiveCatalog) (string, bool) { canonicalName, resolved := catalog.Lookup(cast.Spell) if !resolved || len(cast.SourceRefs) == 0 { return "", false } for _, ref := range cast.SourceRefs { if documentIndex.ValidateRef(ref) != nil { return "", false } } var key strings.Builder writeKeyString(&key, canonicalName) writeKeyString(&key, cases.Fold().String(strings.Join(strings.Fields(cast.Caster), " "))) for _, ref := range cast.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 { 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: spellCastScope(retainedIndex), ReasonCode: ReasonCodeDuplicateSpellCastCollapsed, Message: message, } } func boundedName(name string) string { runes := []rune(name) if len(runes) <= 128 { return string(runes) } return string(runes[:127]) + "…" } func spellCastScope(index int) string { return fmt.Sprintf("spell_casts[%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.SpellListKind, ReferenceSlots: referenceSlots(), } } func Register(registry *pipeline.NormalizerRegistry) error { return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.SpellList], error) { options, err := DecodeOptions(request.Options) if err != nil { return nil, err } return New(options, request.References) }) } 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 referenceSlots() []contracts.ReferenceSlot { return []contracts.ReferenceSlot{{ Name: spellcatalog.SpellCatalogReferenceSlot, Description: "Optional canonical spell-name catalog used for normalization and duplicate identity.", AcceptedMediaTypes: []string{"application/json"}, MaxBytes: 1048576, }} } func normalizerErrorf(format string, args ...any) error { return fmt.Errorf("dnd spells normalizer: "+format, args...) }