From 07460341e34c5aac1bc58f94c3717de3bcdf4a3c Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 21 Jul 2026 14:37:12 +0000 Subject: [PATCH] Share D&D cited source traversal --- docs/internal/modules.md | 30 ++++--- internal/modules/dnd/shared/citations.go | 36 ++++++++ internal/modules/dnd/shared/citations_test.go | 87 +++++++++++++++++++ internal/modules/dnd/shared/matching.go | 43 +++++++++ internal/modules/dnd/shared/matching_test.go | 25 ++++++ .../source_relatedness/validator.go | 65 +++----------- .../source_relatedness/validator_test.go | 14 +++ .../npcs/source_relatedness/validator.go | 45 +++------- .../npcs/source_relatedness/validator_test.go | 21 ++++- .../spells/source_relatedness/validator.go | 44 +++------- .../source_relatedness/validator_test.go | 41 +++++++++ 11 files changed, 322 insertions(+), 129 deletions(-) create mode 100644 internal/modules/dnd/shared/citations.go create mode 100644 internal/modules/dnd/shared/citations_test.go create mode 100644 internal/modules/dnd/shared/matching.go create mode 100644 internal/modules/dnd/shared/matching_test.go diff --git a/docs/internal/modules.md b/docs/internal/modules.md index 8a28e51..988e988 100644 --- a/docs/internal/modules.md +++ b/docs/internal/modules.md @@ -46,7 +46,7 @@ that agreement. Runtime delivery uses the corresponding stage request's LLM-backed extensions own their prompt definitions and response schemas under package-local embedded assets. Shared filesystem composition belongs in `internal/framework/promptfs`; reusable D&D prompt fragments, reference -declarations, prompt-input assembly, and source-unit helpers belong in +declarations, prompt-input assembly, and source-unit/citation helpers belong in `internal/modules/dnd/shared`, which also owns bounded D&D diagnostics. The spell, NPC, and combat-turn extractors use ordered package-local prompt manifests for both rendering and prompt fingerprinting, so only the shared @@ -359,8 +359,11 @@ against the immutable effective SRD and overlay catalog. It accepts normalized canonical names and aliases without rewriting the artifact; unknown names reject the complete result with bounded, stable index/name diagnostics. The source-reference validator applies generic source-reference validation to every -cited range. The relatedness validator warns when a case-insensitive spell name -is absent from all cited source text. +cited range. The relatedness validator resolves all cited ranges through the +shared document-order traversal, then warns when a case-insensitive spell name +is absent from the cited source text. Invalid shape or cited ranges produce no +relatedness warnings; the shape and source-reference validators own those +defects. These validators are deterministic. Their selectable keys and production order are defined in @@ -372,21 +375,24 @@ payload rules are defined in the NPC shape validation checks required strings, arrays, and source-reference shape. The source-reference validator checks current-document identity, unit -existence, and range ordering; source relatedness emits at most one bounded -warning per record when neither the canonical name nor an alias occurs near -its cited text. Normalize identity validation checks deterministic IDs, -canonical names, aliases, and cross-record ownership or canonical collisions. -All are deterministic and expose the policy fingerprints used by the -production chains. +existence, and range ordering; source relatedness uses the shared document-order +traversal and normalized consecutive-token matching, emitting at most one +bounded warning per record when neither the canonical name nor an alias occurs +near its cited text. Invalid shape or cited ranges produce no relatedness +warnings. Normalize identity validation checks deterministic IDs, canonical +names, aliases, and cross-record ownership or canonical collisions. All are +deterministic and expose the policy fingerprints used by the production chains. ## D&D Combat Validators Combat shape validation owns required arrays, strings, nullable values, positive rounds, and supported enums. Combat source-reference validation defers invalid shape and checks source identity, unit existence, and range order. Combat -source-relatedness defers invalid shape or ranges, combines overlapping cited -units in document order, and emits at most one bounded advisory warning per -turn for unrelated actor or declaration text. The normalized-invariants +source-relatedness defers invalid shape or ranges, uses the shared traversal to +combine overlapping cited units in document order, and emits at most one +bounded advisory warning per turn for unrelated actors or declaration text. +Actors use normalized consecutive-token matching; declarations retain the +minimum four-rune token heuristic. The normalized-invariants validator owns display normalization, comparison-unique targets, canonical source-reference order, chronology, and exact duplicate identity; it defers shape and source-reference failures. All four validators are deterministic and diff --git a/internal/modules/dnd/shared/citations.go b/internal/modules/dnd/shared/citations.go new file mode 100644 index 0000000..97e80e6 --- /dev/null +++ b/internal/modules/dnd/shared/citations.go @@ -0,0 +1,36 @@ +package shared + +import ( + "fmt" + "strings" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" +) + +// CitedText resolves cited source ranges in document order, including each +// source unit once, and joins the resulting text with newlines. +func CitedText(doc *source.SourceDocument, refs []source.SourceRef) (string, error) { + if doc == nil { + return "", fmt.Errorf("source document must not be nil") + } + + included := make([]bool, len(doc.Units)) + for _, ref := range refs { + if err := source.ValidateRef(doc, ref); err != nil { + return "", fmt.Errorf("resolve cited source range: %w", err) + } + start, _ := source.UnitIndex(doc, ref.StartUnitID) + end, _ := source.UnitIndex(doc, ref.EndUnitID) + for index := start; index <= end; index++ { + included[index] = true + } + } + + parts := make([]string, 0, len(doc.Units)) + for index, unit := range doc.Units { + if included[index] { + parts = append(parts, unit.Text) + } + } + return strings.Join(parts, "\n"), nil +} diff --git a/internal/modules/dnd/shared/citations_test.go b/internal/modules/dnd/shared/citations_test.go new file mode 100644 index 0000000..cf41b6a --- /dev/null +++ b/internal/modules/dnd/shared/citations_test.go @@ -0,0 +1,87 @@ +package shared + +import ( + "reflect" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" +) + +func TestCitedText(t *testing.T) { + doc := citationDocument() + tests := []struct { + name string + refs []source.SourceRef + want string + wantErr bool + }{ + {name: "empty references", refs: nil, want: ""}, + {name: "invalid source id", refs: []source.SourceRef{{SourceID: "other", StartUnitID: 10, EndUnitID: 10}}, wantErr: true}, + {name: "unknown start unit", refs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 10}}, wantErr: true}, + {name: "unknown end unit", refs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 99}}, wantErr: true}, + {name: "reversed document range", refs: []source.SourceRef{{SourceID: "session", StartUnitID: 30, EndUnitID: 10}}, wantErr: true}, + { + name: "disjoint ranges supplied out of order", + refs: []source.SourceRef{ + {SourceID: "session", StartUnitID: 30, EndUnitID: 30}, + {SourceID: "session", StartUnitID: 10, EndUnitID: 10}, + }, + want: "alpha\ngamma", + }, + { + name: "adjacent ranges", + refs: []source.SourceRef{ + {SourceID: "session", StartUnitID: 10, EndUnitID: 20}, + {SourceID: "session", StartUnitID: 30, EndUnitID: 40}, + }, + want: "alpha\nbeta\ngamma\ndelta", + }, + { + name: "overlapping and duplicate ranges", + refs: []source.SourceRef{ + {SourceID: "session", StartUnitID: 10, EndUnitID: 30}, + {SourceID: "session", StartUnitID: 20, EndUnitID: 40}, + {SourceID: "session", StartUnitID: 10, EndUnitID: 30}, + }, + want: "alpha\nbeta\ngamma\ndelta", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + beforeUnits := append([]source.SourceUnit(nil), doc.Units...) + refs := append([]source.SourceRef(nil), test.refs...) + got, err := CitedText(doc, refs) + if (err != nil) != test.wantErr { + t.Fatalf("CitedText() error = %v, want error = %t", err, test.wantErr) + } + if err == nil && got != test.want { + t.Fatalf("CitedText() = %q, want %q", got, test.want) + } + if !reflect.DeepEqual(doc.Units, beforeUnits) || !reflect.DeepEqual(refs, test.refs) { + t.Fatalf("CitedText() mutated document or references") + } + }) + } +} + +func TestCitedTextRejectsNilDocument(t *testing.T) { + if _, err := CitedText(nil, nil); err == nil { + t.Fatal("CitedText() error = nil, want nil-document error") + } +} + +func citationDocument() *source.SourceDocument { + return &source.SourceDocument{ + ID: "session", + Kind: "transcript", + Format: "application/json", + Digest: "sha256:session", + Units: []source.SourceUnit{ + {ID: 10, Kind: "message", Text: "alpha"}, + {ID: 20, Kind: "message", Text: "beta"}, + {ID: 30, Kind: "message", Text: "gamma"}, + {ID: 40, Kind: "message", Text: "delta"}, + }, + } +} diff --git a/internal/modules/dnd/shared/matching.go b/internal/modules/dnd/shared/matching.go new file mode 100644 index 0000000..ebe1fb0 --- /dev/null +++ b/internal/modules/dnd/shared/matching.go @@ -0,0 +1,43 @@ +package shared + +import ( + "strings" + "unicode" + + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" +) + +// NormalizedTokens returns identity-normalized alphanumeric tokens from a +// D&D value. +func NormalizedTokens(value string) []string { + value = identity.ComparisonKey(value) + if value == "" { + return nil + } + return strings.FieldsFunc(value, func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsDigit(r) + }) +} + +// ContainsTokenSequence reports whether value contains the complete normalized +// query token sequence as consecutive tokens. +func ContainsTokenSequence(value string, query string) bool { + valueTokens := NormalizedTokens(value) + queryTokens := NormalizedTokens(query) + if len(queryTokens) == 0 || len(queryTokens) > len(valueTokens) { + return false + } + for start := 0; start <= len(valueTokens)-len(queryTokens); start++ { + matches := true + for offset, token := range queryTokens { + if valueTokens[start+offset] != token { + matches = false + break + } + } + if matches { + return true + } + } + return false +} diff --git a/internal/modules/dnd/shared/matching_test.go b/internal/modules/dnd/shared/matching_test.go new file mode 100644 index 0000000..de9d990 --- /dev/null +++ b/internal/modules/dnd/shared/matching_test.go @@ -0,0 +1,25 @@ +package shared + +import "testing" + +func TestContainsTokenSequence(t *testing.T) { + tests := []struct { + name string + value string + query string + want bool + }{ + {name: "unicode apostrophe and case", value: "O’rIn\u2003ThOrN advances", query: "o'rin thorn", want: true}, + {name: "multiword sequence", value: "Mira Thorn watches", query: "mira thorn", want: true}, + {name: "nonconsecutive words", value: "Mira watches Thorn", query: "mira thorn", want: false}, + {name: "short name is not substring", value: "A cart rolls past", query: "art", want: false}, + {name: "empty query", value: "anything", query: " ", want: false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := ContainsTokenSequence(test.value, test.query); got != test.want { + t.Fatalf("ContainsTokenSequence(%q, %q) = %t, want %t", test.value, test.query, got, test.want) + } + }) + } +} diff --git a/internal/modules/dnd/validate/combatturns/source_relatedness/validator.go b/internal/modules/dnd/validate/combatturns/source_relatedness/validator.go index 04f424e..cda7e90 100644 --- a/internal/modules/dnd/validate/combatturns/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/combatturns/source_relatedness/validator.go @@ -3,15 +3,12 @@ package sourcerelatedness import ( "context" "fmt" - "strings" - "unicode" "unicode/utf8" - "gitea.maximumdirect.net/eric/notarius/internal/core/source" "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/npcs/identity" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape" ) @@ -38,13 +35,21 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint { } func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.CombatTurnList]) (contracts.ValidationResult, error) { - if combatshape.Validate(req.Value) != nil || !sourceRefsValid(req.Source, req.Value) { + if combatshape.Validate(req.Value) != nil { return contracts.ValidationResult{Approved: true}, nil } + citedTexts := make([]string, len(req.Value.CombatTurns)) + for turnIndex, turn := range req.Value.CombatTurns { + citedText, err := shared.CitedText(req.Source, turn.SourceRefs) + if err != nil { + return contracts.ValidationResult{Approved: true}, nil + } + citedTexts[turnIndex] = citedText + } warnings := make([]contracts.Warning, 0) for turnIndex, turn := range req.Value.CombatTurns { - citedText := citedTextKey(req.Source, turn.SourceRefs) + citedText := citedTexts[turnIndex] issues := make([]string, 0) if !actorAppearsInCitedText(citedText, turn.Actor) { issues = append(issues, fmt.Sprintf("actor %s was not found in cited source text", diagnostics.Quote(turn.Actor))) @@ -65,47 +70,13 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq } return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil } - -func sourceRefsValid(doc *source.SourceDocument, value dnd.CombatTurnList) bool { - for _, turn := range value.CombatTurns { - for _, ref := range turn.SourceRefs { - if source.ValidateRef(doc, ref) != nil { - return false - } - } - } - return true -} - -func citedTextKey(doc *source.SourceDocument, refs []source.SourceRef) string { - if doc == nil { - return "" - } - included := make([]bool, len(doc.Units)) - for _, ref := range refs { - start, _ := source.UnitIndex(doc, ref.StartUnitID) - end, _ := source.UnitIndex(doc, ref.EndUnitID) - for index := start; index <= end && index < len(included); index++ { - included[index] = true - } - } - parts := make([]string, 0) - for index, unit := range doc.Units { - if included[index] { - parts = append(parts, unit.Text) - } - } - return identity.ComparisonKey(strings.Join(parts, " ")) -} - func actorAppearsInCitedText(citedText string, actor string) bool { - key := identity.ComparisonKey(actor) - return key != "" && strings.Contains(citedText, key) + return shared.ContainsTokenSequence(citedText, actor) } func declarationAppearsInCitedText(citedText string, declaration string) bool { citedTokens := tokenSet(citedText) - for _, token := range comparisonTokens(declaration) { + for _, token := range shared.NormalizedTokens(declaration) { if utf8.RuneCountInString(token) >= 4 { if _, ok := citedTokens[token]; ok { return true @@ -115,16 +86,8 @@ func declarationAppearsInCitedText(citedText string, declaration string) bool { return false } -func comparisonTokens(value string) []string { - value = identity.ComparisonKey(value) - if value == "" { - return nil - } - return strings.FieldsFunc(value, func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) }) -} - func tokenSet(value string) map[string]struct{} { - tokens := comparisonTokens(value) + tokens := shared.NormalizedTokens(value) set := make(map[string]struct{}, len(tokens)) for _, token := range tokens { set[token] = struct{}{} diff --git a/internal/modules/dnd/validate/combatturns/source_relatedness/validator_test.go b/internal/modules/dnd/validate/combatturns/source_relatedness/validator_test.go index bdbe5cb..342086c 100644 --- a/internal/modules/dnd/validate/combatturns/source_relatedness/validator_test.go +++ b/internal/modules/dnd/validate/combatturns/source_relatedness/validator_test.go @@ -51,6 +51,20 @@ func TestValidatorWarnsOncePerTurnForUnrelatedActorAndActions(t *testing.T) { } } +func TestValidatorDoesNotMatchShortActorSubstring(t *testing.T) { + resolution := "The cart is struck." + value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ + Actor: "Art", TurnKind: dnd.CombatTurnKindTurn, + Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "cart attacks", Targets: []string{}, Resolution: &resolution}}, + Summary: "The cart attacks.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}, + }}} + doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{{ID: 1, Kind: "message", Text: "The cart attacks."}}} + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: doc, Value: value}) + if err != nil || !result.Approved || len(result.Warnings) != 1 || !strings.Contains(result.Warnings[0].Message, "actor") { + t.Fatalf("Validate() = %#v, %v; want short-actor boundary warning", result, err) + } +} + func TestValidatorDefersMalformedShapeAndInvalidRanges(t *testing.T) { invalidShape := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{Actor: "Aria"}}} result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: relatednessDocument(), Value: invalidShape}) diff --git a/internal/modules/dnd/validate/npcs/source_relatedness/validator.go b/internal/modules/dnd/validate/npcs/source_relatedness/validator.go index 06a482a..f8ca84d 100644 --- a/internal/modules/dnd/validate/npcs/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/npcs/source_relatedness/validator.go @@ -3,13 +3,11 @@ package sourcerelatedness import ( "context" "fmt" - "strings" - "gitea.maximumdirect.net/eric/notarius/internal/core/source" "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/npcs/identity" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape" ) @@ -39,9 +37,17 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq if err := npcshape.Validate(req.Value); err != nil { return contracts.ValidationResult{Approved: true}, nil } + citedTexts := make([]string, len(req.Value.NPCs)) + for npcIndex, npc := range req.Value.NPCs { + citedText, err := shared.CitedText(req.Source, npc.SourceRefs) + if err != nil { + return contracts.ValidationResult{Approved: true}, nil + } + citedTexts[npcIndex] = citedText + } var warnings []contracts.Warning for npcIndex, npc := range req.Value.NPCs { - if npcAppearsInCitedText(req.Source, npc) { + if npcAppearsInCitedText(citedTexts[npcIndex], npc) { continue } warnings = append(warnings, contracts.Warning{ @@ -53,43 +59,18 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil } -func npcAppearsInCitedText(doc *source.SourceDocument, npc dnd.NPC) bool { - cited := citedTextKey(doc, npc.SourceRefs) - if cited == "" { - return false - } - if strings.Contains(cited, identity.ComparisonKey(npc.Name)) { +func npcAppearsInCitedText(citedText string, npc dnd.NPC) bool { + if shared.ContainsTokenSequence(citedText, npc.Name) { return true } for _, alias := range npc.Aliases { - if strings.Contains(cited, identity.ComparisonKey(alias)) { + if shared.ContainsTokenSequence(citedText, alias) { return true } } return false } -func citedTextKey(doc *source.SourceDocument, refs []source.SourceRef) string { - if doc == nil { - return "" - } - var builder strings.Builder - for _, ref := range refs { - if err := source.ValidateRef(doc, ref); err != nil { - continue - } - start, _ := source.UnitIndex(doc, ref.StartUnitID) - end, _ := source.UnitIndex(doc, ref.EndUnitID) - for index := start; index <= end; index++ { - if builder.Len() > 0 { - builder.WriteByte(' ') - } - builder.WriteString(doc.Units[index].Text) - } - } - return identity.ComparisonKey(builder.String()) -} - func Spec() pipeline.ValidatorSpec { return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} } diff --git a/internal/modules/dnd/validate/npcs/source_relatedness/validator_test.go b/internal/modules/dnd/validate/npcs/source_relatedness/validator_test.go index 4530be5..224d9f2 100644 --- a/internal/modules/dnd/validate/npcs/source_relatedness/validator_test.go +++ b/internal/modules/dnd/validate/npcs/source_relatedness/validator_test.go @@ -14,7 +14,11 @@ import ( func TestValidatorMatchesCanonicalNamesAndAliasesWithUnicodeVariants(t *testing.T) { value := dnd.NPCList{NPCs: []dnd.NPC{ - {ID: "one", Name: "O'Rin Thorn", Aliases: []string{}, Description: "A ranger.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}, + {ID: "one", Name: "O'Rin Thorn", Aliases: []string{}, Description: "A ranger.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{ + {SourceID: "session", StartUnitID: 2, EndUnitID: 2}, + {SourceID: "session", StartUnitID: 1, EndUnitID: 2}, + {SourceID: "session", StartUnitID: 1, EndUnitID: 2}, + }}, {ID: "two", Name: "Missing Name", Aliases: []string{"The Greencloak"}, Description: "A guard.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}}, }} doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{ @@ -41,6 +45,17 @@ func TestValidatorWarnsAtMostOncePerNPCForUnrelatedCitations(t *testing.T) { } } +func TestValidatorDoesNotMatchShortNameSubstring(t *testing.T) { + value := dnd.NPCList{NPCs: []dnd.NPC{{ + ID: "one", Name: "Art", Aliases: []string{}, Description: "A guard.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}, + }}} + doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{{ID: 1, Kind: "message", Text: "A cart rolls past."}}} + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: doc, Value: value}) + if err != nil || !result.Approved || len(result.Warnings) != 1 { + t.Fatalf("Validate() = %#v, %v; want short-name boundary warning", result, err) + } +} + func TestValidatorDefersMalformedShapeAndInvalidRangesDoNotPanic(t *testing.T) { invalidShape := dnd.NPCList{NPCs: []dnd.NPC{{Name: "Mira Thorn"}}} result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: relatednessDocument(), Value: invalidShape}) @@ -49,8 +64,8 @@ func TestValidatorDefersMalformedShapeAndInvalidRangesDoNotPanic(t *testing.T) { } invalidRange := dnd.NPCList{NPCs: []dnd.NPC{{ID: "one", Name: "Mira Thorn", Aliases: []string{}, Description: "A ranger.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}} result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: relatednessDocument(), Value: invalidRange}) - if err != nil || !result.Approved || len(result.Warnings) != 1 || result.Warnings[0].ReasonCode != WarningReasonCode { - t.Fatalf("invalid-range relatedness = %#v, %v; want one warning", result, err) + if err != nil || !result.Approved || len(result.Warnings) != 0 { + t.Fatalf("invalid-range relatedness = %#v, %v; want approval without warning", result, err) } } diff --git a/internal/modules/dnd/validate/spells/source_relatedness/validator.go b/internal/modules/dnd/validate/spells/source_relatedness/validator.go index b4cfc02..72b6666 100644 --- a/internal/modules/dnd/validate/spells/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/spells/source_relatedness/validator.go @@ -5,10 +5,10 @@ import ( "fmt" "strings" - "gitea.maximumdirect.net/eric/notarius/internal/core/source" "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" spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape" ) @@ -29,46 +29,28 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq if err := spellshape.Validate(req.Value); err != nil { return contracts.ValidationResult{Approved: true}, nil } + citedTexts := make([]string, len(req.Value.SpellCasts)) + for spellIndex, spell := range req.Value.SpellCasts { + citedText, err := shared.CitedText(req.Source, spell.SourceRefs) + if err != nil { + return contracts.ValidationResult{Approved: true}, nil + } + citedTexts[spellIndex] = citedText + } var warnings []contracts.Warning for spellIndex, spell := range req.Value.SpellCasts { - if !spellAppearsInCitedText(req.Source, spell) { + if !spellAppearsInCitedText(citedTexts[spellIndex], spell) { warnings = append(warnings, contracts.Warning{Scope: fmt.Sprintf("spell_casts[%d]", spellIndex), ReasonCode: WarningReasonCode, Message: fmt.Sprintf("spell %q was not found in cited source text", strings.TrimSpace(spell.Spell))}) } } return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil } -func spellAppearsInCitedText(doc *source.SourceDocument, spell dnd.SpellCast) bool { - name := strings.ToLower(strings.TrimSpace(spell.Spell)) +func spellAppearsInCitedText(citedText string, spell dnd.SpellCast) bool { + name := strings.TrimSpace(spell.Spell) if name == "" { return true } - for _, ref := range spell.SourceRefs { - if text, ok := citedText(doc, ref); ok && strings.Contains(strings.ToLower(text), name) { - return true - } - } - return false -} -func citedText(doc *source.SourceDocument, ref source.SourceRef) (string, bool) { - if doc == nil { - return "", false - } - start, ok := source.UnitIndex(doc, ref.StartUnitID) - if !ok { - return "", false - } - end, ok := source.UnitIndex(doc, ref.EndUnitID) - if !ok || start > end { - return "", false - } - var b strings.Builder - for i := start; i <= end; i++ { - if b.Len() > 0 { - b.WriteByte('\n') - } - b.WriteString(doc.Units[i].Text) - } - return b.String(), true + return shared.ContainsTokenSequence(citedText, name) } func Spec() pipeline.ValidatorSpec { return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} diff --git a/internal/modules/dnd/validate/spells/source_relatedness/validator_test.go b/internal/modules/dnd/validate/spells/source_relatedness/validator_test.go index 060bacf..bc6de79 100644 --- a/internal/modules/dnd/validate/spells/source_relatedness/validator_test.go +++ b/internal/modules/dnd/validate/spells/source_relatedness/validator_test.go @@ -39,6 +39,47 @@ func TestValidatorWarnsWhenSpellDoesNotAppearInCitedText(t *testing.T) { } } +func TestValidatorMatchesCaseInsensitiveUnicodeMultiwordSpellAcrossCitations(t *testing.T) { + value := dnd.SpellList{SpellCasts: []dnd.SpellCast{{ + Caster: "Aria", Spell: "Tasha's Hideous Laughter", Effect: "effect", NarrativeDescription: "description", + SourceRefs: []source.SourceRef{ + {SourceID: "session", StartUnitID: 2, EndUnitID: 2}, + {SourceID: "session", StartUnitID: 1, EndUnitID: 2}, + {SourceID: "session", StartUnitID: 1, EndUnitID: 2}, + }, + }}} + doc := &source.SourceDocument{ + ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", + Units: []source.SourceUnit{ + {ID: 1, Kind: "message", Text: "Tasha’s"}, + {ID: 2, Kind: "message", Text: "hideous laughter fills the room."}, + }, + } + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.SpellList]{Source: doc, Value: value}) + if err != nil || !result.Approved || len(result.Warnings) != 0 { + t.Fatalf("Validate() = %#v, %v; want normalized multiword spell approval", result, err) + } +} + +func TestValidatorDoesNotMatchShortSpellNameSubstring(t *testing.T) { + result, err := New(Options{}).Validate(context.Background(), requestWithSpell(&source.SourceDocument{ + ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", + Units: []source.SourceUnit{{ID: 1, Kind: "message", Text: "The party said nothing."}}, + }, "Aid", 1)) + if err != nil || !result.Approved || len(result.Warnings) != 1 { + t.Fatalf("Validate() = %#v, %v; want short-name boundary warning", result, err) + } +} + +func TestValidatorIgnoresInvalidCitations(t *testing.T) { + request := requestWithSpell(validDocument(), "Cure Wounds", 2) + request.Value.SpellCasts[0].SourceRefs[0].StartUnitID = 99 + result, err := New(Options{}).Validate(context.Background(), request) + if err != nil || !result.Approved || len(result.Warnings) != 0 { + t.Fatalf("Validate() = %#v, %v; want approval without relatedness warning", result, err) + } +} + func TestValidatorApprovesEmptySpellListWithoutWarning(t *testing.T) { result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.SpellList]{Source: validDocument(), Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{}}}) if err != nil {