Reuse document indexes in D&D normalization

This commit is contained in:
2026-07-25 13:01:31 +00:00
parent 97cdb01357
commit 8199d95dc1
10 changed files with 48 additions and 64 deletions

View File

@@ -110,8 +110,9 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
if err != nil {
return contracts.TypedNormalizeResult[dnd.CombatTurnList]{}, normalizerErrorf("resolve NPC registry: %w", err)
}
order := shared.NewSourceRefOrder(req.Source)
value, warnings := normalizeList(req.MergeOutput.Value, req.Source, order, npcRegistry)
index := source.NewDocumentIndex(req.Source)
order := shared.NewSourceRefOrderWithIndex(req.Source, index)
value, warnings := normalizeList(req.MergeOutput.Value, index, order, npcRegistry)
return contracts.TypedNormalizeResult[dnd.CombatTurnList]{Value: value, Warnings: warnings}, nil
}
@@ -127,7 +128,7 @@ type actorCanonicalization struct {
to string
}
func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.CombatTurnList, []contracts.Warning) {
func normalizeList(input dnd.CombatTurnList, documentIndex source.DocumentIndex, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.CombatTurnList, []contracts.Warning) {
if input.CombatTurns == nil {
return dnd.CombatTurnList{}, nil
}
@@ -182,7 +183,7 @@ func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, order s
})
}
output, duplicateWarnings := collapseDuplicates(records, doc)
output, duplicateWarnings := collapseDuplicates(records, documentIndex)
warnings = append(warnings, duplicateWarnings...)
return dnd.CombatTurnList{CombatTurns: output}, warnings
}
@@ -200,8 +201,7 @@ func normalizeTurn(input dnd.CombatTurn, order shared.SourceRefOrder, registry *
actorChange = &actorCanonicalization{from: input.Actor, to: output.Actor}
}
canonicalRefs, _, _ := canonicalizeSourceRefs(order, input.SourceRefs)
output.SourceRefs = canonicalRefs
output.SourceRefs = order.Canonicalize(input.SourceRefs)
refsChanged := !sourceRefsEqual(input.SourceRefs, output.SourceRefs)
return output, actorChange, refsChanged
}
@@ -227,17 +227,12 @@ func sourceRefsEqual(left, right []source.SourceRef) bool {
return true
}
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)
}
type duplicateGroup struct {
retainedIndex int
removed []int
}
func collapseDuplicates(records []normalizedRecord, doc *source.SourceDocument) ([]dnd.CombatTurn, []contracts.Warning) {
func collapseDuplicates(records []normalizedRecord, documentIndex source.DocumentIndex) ([]dnd.CombatTurn, []contracts.Warning) {
if len(records) == 0 {
return make([]dnd.CombatTurn, 0), nil
}
@@ -246,7 +241,7 @@ func collapseDuplicates(records []normalizedRecord, doc *source.SourceDocument)
groups := make([]duplicateGroup, 0)
groupByKey := make(map[string]int)
for index, record := range records {
key, eligible := duplicateKey(record.turn, doc)
key, eligible := duplicateKey(record.turn, documentIndex)
if !eligible {
keep[index] = true
continue
@@ -278,12 +273,12 @@ func collapseDuplicates(records []normalizedRecord, doc *source.SourceDocument)
return output, warnings
}
func duplicateKey(turn dnd.CombatTurn, doc *source.SourceDocument) (string, bool) {
func duplicateKey(turn dnd.CombatTurn, documentIndex source.DocumentIndex) (string, bool) {
if len(turn.SourceRefs) == 0 {
return "", false
}
for _, ref := range turn.SourceRefs {
if source.ValidateRef(doc, ref) != nil {
if documentIndex.ValidateRef(ref) != nil {
return "", false
}
}

View File

@@ -118,8 +118,9 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
if !registry.Bound() {
return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("NPC registry reference is required")
}
order := shared.NewSourceRefOrder(req.Source)
value, warnings := normalizeList(req.MergeOutput.Value, req.Source, order, registry)
index := source.NewDocumentIndex(req.Source)
order := shared.NewSourceRefOrderWithIndex(req.Source, index)
value, warnings := normalizeList(req.MergeOutput.Value, index, order, registry)
return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{Value: value, Warnings: warnings}, nil
}
@@ -133,7 +134,7 @@ type nameCanonicalization struct {
to string
}
func normalizeList(input dnd.NPCInteractionList, doc *source.SourceDocument, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCInteractionList, []contracts.Warning) {
func normalizeList(input dnd.NPCInteractionList, documentIndex source.DocumentIndex, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCInteractionList, []contracts.Warning) {
if input.Interactions == nil {
return dnd.NPCInteractionList{}, nil
}
@@ -175,7 +176,7 @@ func normalizeList(input dnd.NPCInteractionList, doc *source.SourceDocument, ord
})
}
output, duplicateWarnings := collapseDuplicates(records, doc)
output, duplicateWarnings := collapseDuplicates(records, documentIndex)
warnings = append(warnings, duplicateWarnings...)
return dnd.NPCInteractionList{Interactions: output},
diagnostics.LimitWarnings(warnings, "npc_interactions", ReasonCodeWarningsOmitted)
@@ -207,7 +208,7 @@ type duplicateGroup struct {
removed []int
}
func collapseDuplicates(records []normalizedRecord, doc *source.SourceDocument) ([]dnd.NPCInteraction, []contracts.Warning) {
func collapseDuplicates(records []normalizedRecord, documentIndex source.DocumentIndex) ([]dnd.NPCInteraction, []contracts.Warning) {
if len(records) == 0 {
return make([]dnd.NPCInteraction, 0), nil
}
@@ -215,7 +216,7 @@ func collapseDuplicates(records []normalizedRecord, doc *source.SourceDocument)
groups := make([]duplicateGroup, 0)
groupByKey := make(map[string]int)
for index, record := range records {
if !interactionmodel.ValidSourceRefs(doc, record.interaction.SourceRefs) {
if !interactionmodel.ValidSourceRefs(documentIndex, record.interaction.SourceRefs) {
keep[index] = true
continue
}

View File

@@ -117,7 +117,7 @@ func normalizeList(input dnd.NPCList, order shared.SourceRefOrder) (dnd.NPCList,
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.SourceRefs = order.Canonicalize(input.SourceRefs)
output.ID = identity.DeriveID(output.Name)
return output, input.Name != output.Name, !reflect.DeepEqual(input.SourceRefs, output.SourceRefs)
}
@@ -150,16 +150,11 @@ func consolidate(records []normalizedRecord, members []int, order shared.SourceR
for _, member := range members[1:] {
output.SourceRefs = append(output.SourceRefs, records[member].npc.SourceRefs...)
}
output.SourceRefs, _, _ = canonicalizeSourceRefs(order, output.SourceRefs)
output.SourceRefs = order.Canonicalize(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

View File

@@ -78,26 +78,23 @@ func normalizeList(input dnd.SceneDescriptionList, doc *source.SourceDocument) (
return dnd.SceneDescriptionList{}, fmt.Errorf("scenes must not be empty")
}
unitPositions := make(map[int]int, len(doc.Units))
for index, unit := range doc.Units {
unitPositions[unit.ID] = index
}
documentIndex := source.NewDocumentIndex(doc)
output := dnd.SceneDescriptionList{Scenes: make([]dnd.SceneDescription, len(input.Scenes))}
for index, scene := range input.Scenes {
for sceneIndex, scene := range input.Scenes {
scene.Title = strings.TrimSpace(scene.Title)
scene.Summary = strings.TrimSpace(scene.Summary)
if err := shape.Validate(dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{scene}}); err != nil {
return dnd.SceneDescriptionList{}, fmt.Errorf("scenes[%d]: %w", index, err)
return dnd.SceneDescriptionList{}, fmt.Errorf("scenes[%d]: %w", sceneIndex, err)
}
if err := source.ValidateRef(doc, scene.SourceRef); err != nil {
return dnd.SceneDescriptionList{}, fmt.Errorf("scenes[%d].source_ref: %s", index, diagnostics.Truncate(err.Error()))
if err := documentIndex.ValidateRef(scene.SourceRef); err != nil {
return dnd.SceneDescriptionList{}, fmt.Errorf("scenes[%d].source_ref: %s", sceneIndex, diagnostics.Truncate(err.Error()))
}
output.Scenes[index] = scene
output.Scenes[sceneIndex] = scene
}
sort.SliceStable(output.Scenes, func(left, right int) bool {
leftStart := unitPositions[output.Scenes[left].SourceRef.StartUnitID]
rightStart := unitPositions[output.Scenes[right].SourceRef.StartUnitID]
leftStart, _ := documentIndex.Position(output.Scenes[left].SourceRef.StartUnitID)
rightStart, _ := documentIndex.Position(output.Scenes[right].SourceRef.StartUnitID)
if leftStart != rightStart {
return leftStart < rightStart
}

View File

@@ -97,9 +97,10 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
return contracts.TypedNormalizeResult[dnd.SpellList]{}, normalizerErrorf("context error before normalize: %w", err)
}
order := shared.NewSourceRefOrder(req.Source)
index := source.NewDocumentIndex(req.Source)
order := shared.NewSourceRefOrderWithIndex(req.Source, index)
value, warnings := normalizeSpellList(req.MergeOutput.Value, n.effectiveCatalog, order)
value, duplicateWarnings := collapseDuplicateSpellCasts(value, req.Source, n.effectiveCatalog)
value, duplicateWarnings := collapseDuplicateSpellCasts(value, index, n.effectiveCatalog)
warnings = append(warnings, duplicateWarnings...)
return contracts.TypedNormalizeResult[dnd.SpellList]{Value: value, Warnings: warnings}, nil
}
@@ -173,7 +174,7 @@ type duplicateGroup struct {
removed []int
}
func collapseDuplicateSpellCasts(input dnd.SpellList, doc *source.SourceDocument, catalog spellcatalog.EffectiveCatalog) (dnd.SpellList, []contracts.Warning) {
func collapseDuplicateSpellCasts(input dnd.SpellList, documentIndex source.DocumentIndex, catalog spellcatalog.EffectiveCatalog) (dnd.SpellList, []contracts.Warning) {
if len(input.SpellCasts) == 0 {
return input, nil
}
@@ -182,7 +183,7 @@ func collapseDuplicateSpellCasts(input dnd.SpellList, doc *source.SourceDocument
groups := make([]duplicateGroup, 0)
groupByKey := make(map[string]int)
for index, cast := range input.SpellCasts {
key, eligible := duplicateKey(cast, doc, catalog)
key, eligible := duplicateKey(cast, documentIndex, catalog)
if !eligible {
keep[index] = true
continue
@@ -225,13 +226,13 @@ func collapseDuplicateSpellCasts(input dnd.SpellList, doc *source.SourceDocument
return output, warnings
}
func duplicateKey(cast dnd.SpellCast, doc *source.SourceDocument, catalog spellcatalog.EffectiveCatalog) (string, bool) {
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 source.ValidateRef(doc, ref) != nil {
if documentIndex.ValidateRef(ref) != nil {
return "", false
}
}