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")
}
unitIndexes := make(map[int]int, len(doc.Units))
for i, unit := range doc.Units {
unitIndexes[unit.ID] = i
}
index := source.NewDocumentIndex(doc)
ranges := make([]source.ChunkRange, 0, len(response.Scenes))
previousEnd := -1
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 {
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 {
return source.ChunkPlan{}, fmt.Errorf("scene[%d] %w", i, err)
}
startIndex, ok := unitIndexes[startUnitID]
startIndex, ok := index.Position(startUnitID)
if !ok {
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 {
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 {
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
}
}

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
// 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 {
return false
}
for _, ref := range refs {
if source.ValidateRef(doc, ref) != nil {
if index.ValidateRef(ref) != nil {
return false
}
}

View File

@@ -77,11 +77,11 @@ func (ref UnitRef) MarshalJSON() ([]byte, error) {
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 {
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 ref.value, nil

View File

@@ -44,7 +44,7 @@ func TestUnitRefUnmarshalRejectsNonIntegerValues(t *testing.T) {
func TestResolveUnitIDReturnsExistingIntegerSourceUnitID(t *testing.T) {
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 {
t.Fatalf("ResolveUnitID() error = %v, want nil", err)
}
@@ -56,7 +56,7 @@ func TestResolveUnitIDReturnsExistingIntegerSourceUnitID(t *testing.T) {
func TestResolveUnitIDDoesNotFallbackToOneBasedUnitNumber(t *testing.T) {
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 {
t.Fatal("ResolveUnitID() error = nil, want missing source-unit ID")
}
@@ -65,7 +65,7 @@ func TestResolveUnitIDDoesNotFallbackToOneBasedUnitNumber(t *testing.T) {
func TestResolveUnitIDRejectsMissingUnit(t *testing.T) {
doc := unitRefSourceDocument(1)
_, err := ResolveUnitID(doc, "start_unit_id", UnitRefFromInt(9))
_, err := ResolveUnitID(source.NewDocumentIndex(doc), "start_unit_id", UnitRefFromInt(9))
if err == nil {
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 {
for _, interaction := range value.Interactions {
for _, ref := range interaction.SourceRefs {
if index.ValidateRef(ref) != nil {
return false
}
if !interactionmodel.ValidSourceRefs(index, interaction.SourceRefs) {
return false
}
}
return true