Share D&D cited source traversal

This commit is contained in:
2026-07-21 14:37:12 +00:00
parent 732b13669f
commit 07460341e3
11 changed files with 322 additions and 129 deletions

View File

@@ -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

View File

@@ -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
}

View File

@@ -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"},
},
}
}

View File

@@ -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
}

View File

@@ -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: "OrIn\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)
}
})
}
}

View File

@@ -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{}{}

View File

@@ -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})

View File

@@ -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}
}

View File

@@ -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)
}
}

View File

@@ -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}

View File

@@ -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: "Tashas"},
{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 {