diff --git a/internal/modules/dnd/chunk/scenes/chunker.go b/internal/modules/dnd/chunk/scenes/chunker.go index ee0d781..32ffdde 100644 --- a/internal/modules/dnd/chunk/scenes/chunker.go +++ b/internal/modules/dnd/chunk/scenes/chunker.go @@ -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) } diff --git a/internal/modules/dnd/normalize/combatturns/normalizer.go b/internal/modules/dnd/normalize/combatturns/normalizer.go index 5066f20..e69bd75 100644 --- a/internal/modules/dnd/normalize/combatturns/normalizer.go +++ b/internal/modules/dnd/normalize/combatturns/normalizer.go @@ -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 } } diff --git a/internal/modules/dnd/normalize/npcinteractions/normalizer.go b/internal/modules/dnd/normalize/npcinteractions/normalizer.go index 4f5cbd8..259093e 100644 --- a/internal/modules/dnd/normalize/npcinteractions/normalizer.go +++ b/internal/modules/dnd/normalize/npcinteractions/normalizer.go @@ -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 } diff --git a/internal/modules/dnd/normalize/npcs/normalizer.go b/internal/modules/dnd/normalize/npcs/normalizer.go index 3a2979b..89d3c73 100644 --- a/internal/modules/dnd/normalize/npcs/normalizer.go +++ b/internal/modules/dnd/normalize/npcs/normalizer.go @@ -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 diff --git a/internal/modules/dnd/normalize/scenedescriptions/normalizer.go b/internal/modules/dnd/normalize/scenedescriptions/normalizer.go index 6e5a410..aa292c3 100644 --- a/internal/modules/dnd/normalize/scenedescriptions/normalizer.go +++ b/internal/modules/dnd/normalize/scenedescriptions/normalizer.go @@ -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 } diff --git a/internal/modules/dnd/normalize/spells/normalizer.go b/internal/modules/dnd/normalize/spells/normalizer.go index bbb700a..486e2fc 100644 --- a/internal/modules/dnd/normalize/spells/normalizer.go +++ b/internal/modules/dnd/normalize/spells/normalizer.go @@ -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 } } diff --git a/internal/modules/dnd/npcinteractions/canonical.go b/internal/modules/dnd/npcinteractions/canonical.go index 7925bf8..8c12831 100644 --- a/internal/modules/dnd/npcinteractions/canonical.go +++ b/internal/modules/dnd/npcinteractions/canonical.go @@ -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 } } diff --git a/internal/modules/dnd/shared/unit_refs.go b/internal/modules/dnd/shared/unit_refs.go index 60e558a..84b4c63 100644 --- a/internal/modules/dnd/shared/unit_refs.go +++ b/internal/modules/dnd/shared/unit_refs.go @@ -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 diff --git a/internal/modules/dnd/shared/unit_refs_test.go b/internal/modules/dnd/shared/unit_refs_test.go index 08ffd5a..f49a0a2 100644 --- a/internal/modules/dnd/shared/unit_refs_test.go +++ b/internal/modules/dnd/shared/unit_refs_test.go @@ -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") } diff --git a/internal/modules/dnd/validate/npcinteractions/invariants/validator.go b/internal/modules/dnd/validate/npcinteractions/invariants/validator.go index aa33e4f..a61a40d 100644 --- a/internal/modules/dnd/validate/npcinteractions/invariants/validator.go +++ b/internal/modules/dnd/validate/npcinteractions/invariants/validator.go @@ -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