Centralize D&D source reference ordering

This commit is contained in:
2026-07-24 14:20:10 +00:00
parent 6e21c83fd8
commit 1ff449435f
16 changed files with 363 additions and 460 deletions

View File

@@ -3,7 +3,6 @@ package spells
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
@@ -11,11 +10,16 @@ import (
"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"
const (
Key = "dnd/spells"
normalizationPolicy = "dnd.spells.normalize.v1"
NormalizationPolicy = normalizationPolicy
)
const (
ReasonCodeSpellNameCanonicalized = "spell_name_canonicalized"
@@ -65,9 +69,10 @@ func (n *Normalizer) ManifestMetadata() map[string]any {
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()...),
"catalog_base_id": n.effectiveCatalog.BaseID(),
"catalog_digest": n.effectiveCatalog.Digest(),
"catalog_overlay_ids": append([]string(nil), n.effectiveCatalog.OverlayIDs()...),
"normalization_policy": normalizationPolicy,
}
}
@@ -75,7 +80,10 @@ func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
if n == nil {
return nil
}
return []pipeline.CheckpointFingerprint{{Name: "effective_catalog", Value: n.effectiveCatalog.Digest()}}
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) {
@@ -89,13 +97,14 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
return contracts.TypedNormalizeResult[dnd.SpellList]{}, normalizerErrorf("context error before normalize: %w", err)
}
value, warnings := normalizeSpellList(req.MergeOutput.Value, n.effectiveCatalog)
order := shared.NewSourceRefOrder(req.Source)
value, warnings := normalizeSpellList(req.MergeOutput.Value, n.effectiveCatalog, order)
value, duplicateWarnings := collapseDuplicateSpellCasts(value, req.Source, n.effectiveCatalog)
warnings = append(warnings, duplicateWarnings...)
return contracts.TypedNormalizeResult[dnd.SpellList]{Value: value, Warnings: warnings}, nil
}
func normalizeSpellList(input dnd.SpellList, catalog spellcatalog.EffectiveCatalog) (dnd.SpellList, []contracts.Warning) {
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
@@ -123,7 +132,7 @@ func normalizeSpellList(input dnd.SpellList, catalog spellcatalog.EffectiveCatal
})
}
canonicalRefs, orderChanged, duplicateCount := canonicalizeSourceRefs(inputCast.SourceRefs)
canonicalRefs, orderChanged, duplicateCount := canonicalizeSourceRefs(order, inputCast.SourceRefs)
cast.SourceRefs = canonicalRefs
if orderChanged || duplicateCount > 0 {
warnings = append(warnings, contracts.Warning{
@@ -147,42 +156,21 @@ func cloneSpellCast(input dnd.SpellCast) dnd.SpellCast {
return output
}
func canonicalizeSourceRefs(input []source.SourceRef) ([]source.SourceRef, bool, int) {
if input == nil {
return nil, false, 0
}
canonical := make([]source.SourceRef, len(input))
copy(canonical, input)
sort.SliceStable(canonical, func(left, right int) bool {
return sourceRefLess(canonical[left], canonical[right])
})
orderChanged := false
for index := range input {
if input[index] != canonical[index] {
orderChanged = true
break
}
}
unique := make([]source.SourceRef, 0, len(canonical))
for _, ref := range canonical {
if len(unique) == 0 || unique[len(unique)-1] != ref {
unique = append(unique, ref)
}
}
return unique, orderChanged, len(input) - len(unique)
func canonicalizeSourceRefs(order shared.SourceRefOrder, input []source.SourceRef) ([]source.SourceRef, bool, int) {
canonical := order.Canonicalize(input)
return canonical, !sourceRefsEqual(input, canonical), len(input) - len(canonical)
}
func sourceRefLess(left, right source.SourceRef) bool {
if left.SourceID != right.SourceID {
return left.SourceID < right.SourceID
func sourceRefsEqual(left, right []source.SourceRef) bool {
if (left == nil) != (right == nil) || len(left) != len(right) {
return false
}
if left.StartUnitID != right.StartUnitID {
return left.StartUnitID < right.StartUnitID
for index := range left {
if left[index] != right[index] {
return false
}
}
return left.EndUnitID < right.EndUnitID
return true
}
type duplicateGroup struct {

View File

@@ -106,12 +106,12 @@ func TestIdentityAndMetadataAreDefensive(t *testing.T) {
}
fingerprints := normalizer.CheckpointFingerprints()
if len(fingerprints) != 1 || fingerprints[0].Name != "effective_catalog" || fingerprints[0].Value != normalizer.effectiveCatalog.Digest() {
t.Fatalf("fingerprints = %#v, want effective catalog fingerprint", fingerprints)
if len(fingerprints) != 2 || fingerprints[0].Name != "effective_catalog" || fingerprints[0].Value != normalizer.effectiveCatalog.Digest() || fingerprints[1] != (pipeline.CheckpointFingerprint{Name: "normalization_policy", Value: normalizationPolicy}) {
t.Fatalf("fingerprints = %#v, want catalog and normalization policy fingerprints", fingerprints)
}
fingerprints[0].Name = "changed"
fingerprints[0].Value = "changed"
if got := normalizer.CheckpointFingerprints(); len(got) != 1 || got[0].Name != "effective_catalog" || got[0].Value != normalizer.effectiveCatalog.Digest() {
if got := normalizer.CheckpointFingerprints(); len(got) != 2 || got[0].Name != "effective_catalog" || got[0].Value != normalizer.effectiveCatalog.Digest() || got[1] != (pipeline.CheckpointFingerprint{Name: "normalization_policy", Value: normalizationPolicy}) {
t.Fatalf("fingerprints were not defensive: %#v", got)
}
@@ -210,6 +210,26 @@ func TestNormalizeSortsAndDeduplicatesExactSourceReferences(t *testing.T) {
}
}
func TestNormalizeOrdersReferencesBySourceDocumentPosition(t *testing.T) {
doc := &source.SourceDocument{ID: "source", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}}
input := dnd.SpellList{SpellCasts: []dnd.SpellCast{{
Spell: "Cure Wounds",
SourceRefs: []source.SourceRef{
{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10},
{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30},
{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999},
},
}}}
result, err := newNormalizer(t).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
if err != nil {
t.Fatalf("Normalize() error = %v, want nil", err)
}
got := result.Value.SpellCasts[0].SourceRefs
if got[0].StartUnitID != 30 || got[1].StartUnitID != 10 || got[2].StartUnitID != 999 {
t.Fatalf("normalized refs = %#v, want source-document order followed by invalid reference", got)
}
}
func TestNormalizePreservesNilEmptyAndAdjacentOrOverlappingReferences(t *testing.T) {
normalizer := newNormalizer(t)
input := dnd.SpellList{SpellCasts: []dnd.SpellCast{