215 lines
8.4 KiB
Go
215 lines
8.4 KiB
Go
// Package npcs normalizes merged D&D non-player character records.
|
|
package npcs
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"reflect"
|
|
"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"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
|
)
|
|
|
|
const (
|
|
Key = "dnd/npcs"
|
|
normalizationPolicy = "dnd.npcs.normalize.v2"
|
|
NormalizationPolicy = normalizationPolicy
|
|
|
|
ReasonCodeNPCFieldsNormalized = "npc_fields_normalized"
|
|
ReasonCodeNPCIDRecomputed = "npc_id_recomputed"
|
|
ReasonCodeSourceReferencesNormalized = "source_references_normalized"
|
|
ReasonCodeDuplicateNPCCollapsed = "duplicate_npc_collapsed"
|
|
)
|
|
|
|
var requiredCapabilities = []string{"merged"}
|
|
var providedCapabilities = []string{"normalized"}
|
|
|
|
var _ contracts.Normalizer[dnd.NPCList] = (*Normalizer)(nil)
|
|
var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil)
|
|
var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
|
|
|
|
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{"identity_policy": identity.Policy, "normalization_policy": normalizationPolicy}
|
|
}
|
|
|
|
func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|
if n == nil {
|
|
return nil
|
|
}
|
|
return []pipeline.CheckpointFingerprint{
|
|
{Name: "identity_policy", Value: identity.Policy},
|
|
{Name: "normalization_policy", Value: normalizationPolicy},
|
|
}
|
|
}
|
|
|
|
func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.NPCList]) (contracts.TypedNormalizeResult[dnd.NPCList], error) {
|
|
if n == nil {
|
|
return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("normalizer must not be nil")
|
|
}
|
|
if ctx == nil {
|
|
return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("context must not be nil")
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("context error before normalize: %w", err)
|
|
}
|
|
order := shared.NewSourceRefOrder(req.Source)
|
|
value, warnings := normalizeList(req.MergeOutput.Value, order)
|
|
return contracts.TypedNormalizeResult[dnd.NPCList]{Value: value, Warnings: warnings}, nil
|
|
}
|
|
|
|
type normalizedRecord struct {
|
|
npc dnd.NPC
|
|
}
|
|
|
|
func normalizeList(input dnd.NPCList, order shared.SourceRefOrder) (dnd.NPCList, []contracts.Warning) {
|
|
if input.NPCs == nil {
|
|
return dnd.NPCList{}, nil
|
|
}
|
|
records := make([]normalizedRecord, len(input.NPCs))
|
|
warnings := make([]contracts.Warning, 0)
|
|
for index, inputNPC := range input.NPCs {
|
|
npc, fieldsChanged, referencesChanged := normalizeRecord(inputNPC, order)
|
|
records[index] = normalizedRecord{npc: npc}
|
|
if fieldsChanged {
|
|
warnings = append(warnings, contracts.Warning{Scope: npcScope(index), ReasonCode: ReasonCodeNPCFieldsNormalized, Message: fmt.Sprintf("input index %d: NPC name normalized for %s", index, diagnostics.Quote(inputNPC.Name))})
|
|
}
|
|
if referencesChanged {
|
|
warnings = append(warnings, contracts.Warning{Scope: npcScope(index), ReasonCode: ReasonCodeSourceReferencesNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", index, len(inputNPC.SourceRefs), len(npc.SourceRefs))})
|
|
}
|
|
if inputNPC.ID != npc.ID {
|
|
warnings = append(warnings, contracts.Warning{Scope: npcScope(index), ReasonCode: ReasonCodeNPCIDRecomputed, Message: fmt.Sprintf("input index %d: NPC ID recomputed from %s", index, diagnostics.Quote(npc.Name))})
|
|
}
|
|
}
|
|
|
|
groups := canonicalNameGroups(records)
|
|
output := dnd.NPCList{NPCs: make([]dnd.NPC, 0, len(groups))}
|
|
for _, members := range groups {
|
|
consolidated, referencesChanged := consolidate(records, members, order)
|
|
retainedIndex := members[0]
|
|
output.NPCs = append(output.NPCs, consolidated)
|
|
if referencesChanged {
|
|
warnings = append(warnings, contracts.Warning{Scope: npcScope(retainedIndex), ReasonCode: ReasonCodeSourceReferencesNormalized, Message: fmt.Sprintf("input index %d: source references normalized during identity consolidation (final count %d)", retainedIndex, len(consolidated.SourceRefs))})
|
|
}
|
|
if len(members) > 1 {
|
|
warnings = append(warnings, duplicateWarning(retainedIndex, members[1:]))
|
|
}
|
|
}
|
|
return output, warnings
|
|
}
|
|
|
|
func normalizeRecord(input dnd.NPC, order shared.SourceRefOrder) (dnd.NPC, bool, bool) {
|
|
output := cloneNPC(input)
|
|
output.Name = identity.NormalizeDisplay(input.Name)
|
|
output.SourceRefs, _, _ = canonicalizeSourceRefs(order, input.SourceRefs)
|
|
output.ID = identity.DeriveID(output.Name)
|
|
return output, input.Name != output.Name, !reflect.DeepEqual(input.SourceRefs, output.SourceRefs)
|
|
}
|
|
|
|
func cloneNPC(input dnd.NPC) dnd.NPC {
|
|
input.SourceRefs = cloneSourceRefs(input.SourceRefs)
|
|
return input
|
|
}
|
|
|
|
func canonicalNameGroups(records []normalizedRecord) [][]int {
|
|
groups := make([][]int, 0, len(records))
|
|
ownerByKey := make(map[string]int, len(records))
|
|
for index, record := range records {
|
|
key := identity.ComparisonKey(record.npc.Name)
|
|
if key != "" {
|
|
if groupIndex, ok := ownerByKey[key]; ok {
|
|
groups[groupIndex] = append(groups[groupIndex], index)
|
|
continue
|
|
}
|
|
ownerByKey[key] = len(groups)
|
|
}
|
|
groups = append(groups, []int{index})
|
|
}
|
|
return groups
|
|
}
|
|
|
|
func consolidate(records []normalizedRecord, members []int, order shared.SourceRefOrder) (dnd.NPC, bool) {
|
|
output := cloneNPC(records[members[0]].npc)
|
|
originalRefs := cloneSourceRefs(output.SourceRefs)
|
|
for _, member := range members[1:] {
|
|
output.SourceRefs = append(output.SourceRefs, records[member].npc.SourceRefs...)
|
|
}
|
|
output.SourceRefs, _, _ = canonicalizeSourceRefs(order, output.SourceRefs)
|
|
output.ID = identity.DeriveID(output.Name)
|
|
return output, !reflect.DeepEqual(originalRefs, output.SourceRefs)
|
|
}
|
|
|
|
func canonicalizeSourceRefs(order shared.SourceRefOrder, input []source.SourceRef) ([]source.SourceRef, bool, int) {
|
|
canonical := order.Canonicalize(input)
|
|
return canonical, !reflect.DeepEqual(input, canonical), len(input) - len(canonical)
|
|
}
|
|
|
|
func cloneSourceRefs(input []source.SourceRef) []source.SourceRef {
|
|
if input == nil {
|
|
return nil
|
|
}
|
|
return append([]source.SourceRef(nil), input...)
|
|
}
|
|
|
|
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: npcScope(retainedIndex), ReasonCode: ReasonCodeDuplicateNPCCollapsed, Message: message}
|
|
}
|
|
|
|
func npcScope(index int) string { return fmt.Sprintf("npcs[%d]", index) }
|
|
|
|
func ModuleSpec() pipeline.ModuleSpec {
|
|
return pipeline.ModuleSpec{Key: Key, Stage: pipeline.StageNormalize, Requires: append([]string(nil), requiredCapabilities...), Provides: append([]string(nil), providedCapabilities...), ArtifactKind: dnd.NPCListKind}
|
|
}
|
|
|
|
func Register(registry *pipeline.NormalizerRegistry) error {
|
|
return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.NPCList], error) {
|
|
options, err := DecodeOptions(request.Options)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return New(options), nil
|
|
})
|
|
}
|
|
|
|
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 normalizerErrorf(format string, args ...any) error {
|
|
return fmt.Errorf("dnd npcs normalizer: "+format, args...)
|
|
}
|