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

@@ -156,28 +156,25 @@ func planFromResponse(doc *source.SourceDocument, response chunkResponse) (sourc
return source.ChunkPlan{}, fmt.Errorf("scenes must not be empty") return source.ChunkPlan{}, fmt.Errorf("scenes must not be empty")
} }
unitIndexes := make(map[int]int, len(doc.Units)) index := source.NewDocumentIndex(doc)
for i, unit := range doc.Units {
unitIndexes[unit.ID] = i
}
ranges := make([]source.ChunkRange, 0, len(response.Scenes)) ranges := make([]source.ChunkRange, 0, len(response.Scenes))
previousEnd := -1 previousEnd := -1
for i, scene := range response.Scenes { for i, scene := range response.Scenes {
startUnitID, err := shared.ResolveUnitID(doc, "start_unit_id", scene.StartUnitID) startUnitID, err := shared.ResolveUnitID(index, "start_unit_id", scene.StartUnitID)
if err != nil { if err != nil {
return source.ChunkPlan{}, fmt.Errorf("scene[%d] %w", i, err) return source.ChunkPlan{}, fmt.Errorf("scene[%d] %w", i, err)
} }
endUnitID, err := shared.ResolveUnitID(doc, "end_unit_id", scene.EndUnitID) endUnitID, err := shared.ResolveUnitID(index, "end_unit_id", scene.EndUnitID)
if err != nil { if err != nil {
return source.ChunkPlan{}, fmt.Errorf("scene[%d] %w", i, err) return source.ChunkPlan{}, fmt.Errorf("scene[%d] %w", i, err)
} }
startIndex, ok := unitIndexes[startUnitID] startIndex, ok := index.Position(startUnitID)
if !ok { if !ok {
return source.ChunkPlan{}, fmt.Errorf("scene[%d] start_unit_id %d was not found", i, startUnitID) return source.ChunkPlan{}, fmt.Errorf("scene[%d] start_unit_id %d was not found", i, startUnitID)
} }
endIndex, ok := unitIndexes[endUnitID] endIndex, ok := index.Position(endUnitID)
if !ok { if !ok {
return source.ChunkPlan{}, fmt.Errorf("scene[%d] end_unit_id %d was not found", i, endUnitID) return source.ChunkPlan{}, fmt.Errorf("scene[%d] end_unit_id %d was not found", i, endUnitID)
} }

View File

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

View File

@@ -118,8 +118,9 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
if !registry.Bound() { if !registry.Bound() {
return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("NPC registry reference is required") return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("NPC registry reference is required")
} }
order := shared.NewSourceRefOrder(req.Source) index := source.NewDocumentIndex(req.Source)
value, warnings := normalizeList(req.MergeOutput.Value, req.Source, order, registry) 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 return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{Value: value, Warnings: warnings}, nil
} }
@@ -133,7 +134,7 @@ type nameCanonicalization struct {
to string 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 { if input.Interactions == nil {
return dnd.NPCInteractionList{}, 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...) warnings = append(warnings, duplicateWarnings...)
return dnd.NPCInteractionList{Interactions: output}, return dnd.NPCInteractionList{Interactions: output},
diagnostics.LimitWarnings(warnings, "npc_interactions", ReasonCodeWarningsOmitted) diagnostics.LimitWarnings(warnings, "npc_interactions", ReasonCodeWarningsOmitted)
@@ -207,7 +208,7 @@ type duplicateGroup struct {
removed []int 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 { if len(records) == 0 {
return make([]dnd.NPCInteraction, 0), nil return make([]dnd.NPCInteraction, 0), nil
} }
@@ -215,7 +216,7 @@ func collapseDuplicates(records []normalizedRecord, doc *source.SourceDocument)
groups := make([]duplicateGroup, 0) groups := make([]duplicateGroup, 0)
groupByKey := make(map[string]int) groupByKey := make(map[string]int)
for index, record := range records { for index, record := range records {
if !interactionmodel.ValidSourceRefs(doc, record.interaction.SourceRefs) { if !interactionmodel.ValidSourceRefs(documentIndex, record.interaction.SourceRefs) {
keep[index] = true keep[index] = true
continue 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) { func normalizeRecord(input dnd.NPC, order shared.SourceRefOrder) (dnd.NPC, bool, bool) {
output := cloneNPC(input) output := cloneNPC(input)
output.Name = identity.NormalizeDisplay(input.Name) output.Name = identity.NormalizeDisplay(input.Name)
output.SourceRefs, _, _ = canonicalizeSourceRefs(order, input.SourceRefs) output.SourceRefs = order.Canonicalize(input.SourceRefs)
output.ID = identity.DeriveID(output.Name) output.ID = identity.DeriveID(output.Name)
return output, input.Name != output.Name, !reflect.DeepEqual(input.SourceRefs, output.SourceRefs) 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:] { for _, member := range members[1:] {
output.SourceRefs = append(output.SourceRefs, records[member].npc.SourceRefs...) 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) output.ID = identity.DeriveID(output.Name)
return output, !reflect.DeepEqual(originalRefs, output.SourceRefs) 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 { func cloneSourceRefs(input []source.SourceRef) []source.SourceRef {
if input == nil { if input == nil {
return 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") return dnd.SceneDescriptionList{}, fmt.Errorf("scenes must not be empty")
} }
unitPositions := make(map[int]int, len(doc.Units)) documentIndex := source.NewDocumentIndex(doc)
for index, unit := range doc.Units {
unitPositions[unit.ID] = index
}
output := dnd.SceneDescriptionList{Scenes: make([]dnd.SceneDescription, len(input.Scenes))} 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.Title = strings.TrimSpace(scene.Title)
scene.Summary = strings.TrimSpace(scene.Summary) scene.Summary = strings.TrimSpace(scene.Summary)
if err := shape.Validate(dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{scene}}); err != nil { 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 { if err := documentIndex.ValidateRef(scene.SourceRef); err != nil {
return dnd.SceneDescriptionList{}, fmt.Errorf("scenes[%d].source_ref: %s", index, diagnostics.Truncate(err.Error())) 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 { sort.SliceStable(output.Scenes, func(left, right int) bool {
leftStart := unitPositions[output.Scenes[left].SourceRef.StartUnitID] leftStart, _ := documentIndex.Position(output.Scenes[left].SourceRef.StartUnitID)
rightStart := unitPositions[output.Scenes[right].SourceRef.StartUnitID] rightStart, _ := documentIndex.Position(output.Scenes[right].SourceRef.StartUnitID)
if leftStart != rightStart { if leftStart != rightStart {
return 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) 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, 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...) warnings = append(warnings, duplicateWarnings...)
return contracts.TypedNormalizeResult[dnd.SpellList]{Value: value, Warnings: warnings}, nil return contracts.TypedNormalizeResult[dnd.SpellList]{Value: value, Warnings: warnings}, nil
} }
@@ -173,7 +174,7 @@ type duplicateGroup struct {
removed []int 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 { if len(input.SpellCasts) == 0 {
return input, nil return input, nil
} }
@@ -182,7 +183,7 @@ func collapseDuplicateSpellCasts(input dnd.SpellList, doc *source.SourceDocument
groups := make([]duplicateGroup, 0) groups := make([]duplicateGroup, 0)
groupByKey := make(map[string]int) groupByKey := make(map[string]int)
for index, cast := range input.SpellCasts { for index, cast := range input.SpellCasts {
key, eligible := duplicateKey(cast, doc, catalog) key, eligible := duplicateKey(cast, documentIndex, catalog)
if !eligible { if !eligible {
keep[index] = true keep[index] = true
continue continue
@@ -225,13 +226,13 @@ func collapseDuplicateSpellCasts(input dnd.SpellList, doc *source.SourceDocument
return output, warnings 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) canonicalName, resolved := catalog.Lookup(cast.Spell)
if !resolved || len(cast.SourceRefs) == 0 { if !resolved || len(cast.SourceRefs) == 0 {
return "", false return "", false
} }
for _, ref := range cast.SourceRefs { for _, ref := range cast.SourceRefs {
if source.ValidateRef(doc, ref) != nil { if documentIndex.ValidateRef(ref) != nil {
return "", false return "", false
} }
} }

View File

@@ -52,12 +52,12 @@ func Less(order shared.SourceRefOrder, left, right dnd.NPCInteraction) bool {
// ValidSourceRefs reports whether an interaction has non-empty, valid // ValidSourceRefs reports whether an interaction has non-empty, valid
// current-document evidence. // current-document evidence.
func ValidSourceRefs(doc *source.SourceDocument, refs []source.SourceRef) bool { func ValidSourceRefs(index source.DocumentIndex, refs []source.SourceRef) bool {
if len(refs) == 0 { if len(refs) == 0 {
return false return false
} }
for _, ref := range refs { for _, ref := range refs {
if source.ValidateRef(doc, ref) != nil { if index.ValidateRef(ref) != nil {
return false return false
} }
} }

View File

@@ -77,11 +77,11 @@ func (ref UnitRef) MarshalJSON() ([]byte, error) {
return json.Marshal(ref.String()) return json.Marshal(ref.String())
} }
func ResolveUnitID(doc *source.SourceDocument, field string, ref UnitRef) (int, error) { func ResolveUnitID(index source.DocumentIndex, field string, ref UnitRef) (int, error) {
if ref.value <= 0 { if ref.value <= 0 {
return 0, fmt.Errorf("%s must be positive", field) return 0, fmt.Errorf("%s must be positive", field)
} }
if _, ok := source.UnitIndex(doc, ref.value); !ok { if _, ok := index.Position(ref.value); !ok {
return 0, fmt.Errorf("%s %d was not found", field, ref.value) return 0, fmt.Errorf("%s %d was not found", field, ref.value)
} }
return ref.value, nil return ref.value, nil

View File

@@ -44,7 +44,7 @@ func TestUnitRefUnmarshalRejectsNonIntegerValues(t *testing.T) {
func TestResolveUnitIDReturnsExistingIntegerSourceUnitID(t *testing.T) { func TestResolveUnitIDReturnsExistingIntegerSourceUnitID(t *testing.T) {
doc := unitRefSourceDocument(2, 10) doc := unitRefSourceDocument(2, 10)
got, err := ResolveUnitID(doc, "start_unit_id", UnitRefFromInt(2)) got, err := ResolveUnitID(source.NewDocumentIndex(doc), "start_unit_id", UnitRefFromInt(2))
if err != nil { if err != nil {
t.Fatalf("ResolveUnitID() error = %v, want nil", err) t.Fatalf("ResolveUnitID() error = %v, want nil", err)
} }
@@ -56,7 +56,7 @@ func TestResolveUnitIDReturnsExistingIntegerSourceUnitID(t *testing.T) {
func TestResolveUnitIDDoesNotFallbackToOneBasedUnitNumber(t *testing.T) { func TestResolveUnitIDDoesNotFallbackToOneBasedUnitNumber(t *testing.T) {
doc := unitRefSourceDocument(10, 20) doc := unitRefSourceDocument(10, 20)
_, err := ResolveUnitID(doc, "end_unit_id", UnitRefFromInt(2)) _, err := ResolveUnitID(source.NewDocumentIndex(doc), "end_unit_id", UnitRefFromInt(2))
if err == nil { if err == nil {
t.Fatal("ResolveUnitID() error = nil, want missing source-unit ID") t.Fatal("ResolveUnitID() error = nil, want missing source-unit ID")
} }
@@ -65,7 +65,7 @@ func TestResolveUnitIDDoesNotFallbackToOneBasedUnitNumber(t *testing.T) {
func TestResolveUnitIDRejectsMissingUnit(t *testing.T) { func TestResolveUnitIDRejectsMissingUnit(t *testing.T) {
doc := unitRefSourceDocument(1) doc := unitRefSourceDocument(1)
_, err := ResolveUnitID(doc, "start_unit_id", UnitRefFromInt(9)) _, err := ResolveUnitID(source.NewDocumentIndex(doc), "start_unit_id", UnitRefFromInt(9))
if err == nil { if err == nil {
t.Fatal("ResolveUnitID() error = nil, want error") t.Fatal("ResolveUnitID() error = nil, want error")
} }

View File

@@ -107,10 +107,8 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
func allSourceRefsValid(index source.DocumentIndex, value dnd.NPCInteractionList) bool { func allSourceRefsValid(index source.DocumentIndex, value dnd.NPCInteractionList) bool {
for _, interaction := range value.Interactions { for _, interaction := range value.Interactions {
for _, ref := range interaction.SourceRefs { if !interactionmodel.ValidSourceRefs(index, interaction.SourceRefs) {
if index.ValidateRef(ref) != nil { return false
return false
}
} }
} }
return true return true