Tighten NPC interaction validation and consistency

This commit is contained in:
2026-07-23 14:25:32 +00:00
parent 36e0512454
commit bfe25609a7
14 changed files with 426 additions and 278 deletions

View File

@@ -5,13 +5,12 @@ import (
"context"
"fmt"
"sort"
"strconv"
"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"
interactionmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcinteractions"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
@@ -20,13 +19,14 @@ import (
const (
Key = "dnd/npc-interactions"
normalizationPolicy = "dnd.npc_interactions.normalize.v1"
normalizationPolicy = "dnd.npc_interactions.normalize.v2"
NormalizationPolicy = normalizationPolicy
ReasonCodeNameCanonicalized = "npc_interaction_name_canonicalized"
ReasonCodeSourceRefsNormalized = "source_references_normalized"
ReasonCodeInteractionsReordered = "npc_interactions_reordered"
ReasonCodeDuplicateCollapsed = "duplicate_npc_interaction_collapsed"
ReasonCodeWarningsOmitted = "npc_interaction_normalization_warnings_omitted"
)
const (
@@ -125,8 +125,6 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
type normalizedRecord struct {
interaction dnd.NPCInteraction
inputIndex int
earliest int
hasEvidence bool
}
type nameCanonicalization struct {
@@ -143,8 +141,7 @@ func normalizeList(input dnd.NPCInteractionList, doc *source.SourceDocument, reg
warnings := make([]contracts.Warning, 0)
for index, inputInteraction := range input.Interactions {
interaction, nameChange, refsChanged := normalizeInteraction(inputInteraction, doc, registry)
earliest, hasEvidence := earliestSourcePosition(doc, interaction)
records[index] = normalizedRecord{interaction: interaction, inputIndex: index, earliest: earliest, hasEvidence: hasEvidence}
records[index] = normalizedRecord{interaction: interaction, inputIndex: index}
if nameChange != nil {
warnings = append(warnings, contracts.Warning{
Scope: interactionScope(index),
@@ -163,7 +160,9 @@ func normalizeList(input dnd.NPCInteractionList, doc *source.SourceDocument, reg
}
}
sort.SliceStable(records, func(left, right int) bool { return recordLess(doc, records[left], records[right]) })
sort.SliceStable(records, func(left, right int) bool {
return interactionmodel.Less(doc, records[left].interaction, records[right].interaction)
})
for position, record := range records {
if position == record.inputIndex {
continue
@@ -177,7 +176,8 @@ func normalizeList(input dnd.NPCInteractionList, doc *source.SourceDocument, reg
output, duplicateWarnings := collapseDuplicates(records, doc)
warnings = append(warnings, duplicateWarnings...)
return dnd.NPCInteractionList{Interactions: output}, warnings
return dnd.NPCInteractionList{Interactions: output},
diagnostics.LimitWarnings(warnings, "npc_interactions", ReasonCodeWarningsOmitted)
}
func normalizeInteraction(input dnd.NPCInteraction, doc *source.SourceDocument, registry *npcregistry.Registry) (dnd.NPCInteraction, *nameCanonicalization, bool) {
@@ -189,8 +189,8 @@ func normalizeInteraction(input dnd.NPCInteraction, doc *source.SourceDocument,
if input.Name != output.Name {
nameChange = &nameCanonicalization{from: input.Name, to: output.Name}
}
output.SourceRefs = canonicalizeSourceRefs(doc, input.SourceRefs)
return output, nameChange, !sourceRefsEqual(input.SourceRefs, output.SourceRefs)
output.SourceRefs = interactionmodel.CanonicalizeSourceRefs(doc, input.SourceRefs)
return output, nameChange, !interactionmodel.SourceRefsEqual(input.SourceRefs, output.SourceRefs)
}
func cloneInteraction(input dnd.NPCInteraction) dnd.NPCInteraction {
@@ -201,107 +201,6 @@ func cloneInteraction(input dnd.NPCInteraction) dnd.NPCInteraction {
return output
}
func canonicalizeSourceRefs(doc *source.SourceDocument, input []source.SourceRef) []source.SourceRef {
if input == nil {
return nil
}
canonical := append([]source.SourceRef(nil), input...)
sort.SliceStable(canonical, func(left, right int) bool { return sourceRefLess(doc, canonical[left], canonical[right]) })
unique := make([]source.SourceRef, 0, len(canonical))
for _, ref := range canonical {
if len(unique) == 0 || unique[len(unique)-1] != ref {
unique = append(unique, ref)
}
}
return unique
}
func sourceRefsEqual(left, right []source.SourceRef) bool {
if (left == nil) != (right == nil) || len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
func sourceRefLess(doc *source.SourceDocument, left, right source.SourceRef) bool {
if left.SourceID != right.SourceID {
return left.SourceID < right.SourceID
}
leftStart, leftStartOK := source.UnitIndex(doc, left.StartUnitID)
rightStart, rightStartOK := source.UnitIndex(doc, right.StartUnitID)
if leftStartOK != rightStartOK {
return leftStartOK
}
if leftStartOK && leftStart != rightStart {
return leftStart < rightStart
}
if left.StartUnitID != right.StartUnitID {
return left.StartUnitID < right.StartUnitID
}
leftEnd, leftEndOK := source.UnitIndex(doc, left.EndUnitID)
rightEnd, rightEndOK := source.UnitIndex(doc, right.EndUnitID)
if leftEndOK != rightEndOK {
return leftEndOK
}
if leftEndOK && leftEnd != rightEnd {
return leftEnd < rightEnd
}
return left.EndUnitID < right.EndUnitID
}
func earliestSourcePosition(doc *source.SourceDocument, interaction dnd.NPCInteraction) (int, bool) {
found := false
earliest := 0
for _, ref := range interaction.SourceRefs {
if source.ValidateRef(doc, ref) != nil {
continue
}
position, ok := source.UnitIndex(doc, ref.StartUnitID)
if !ok || (found && position >= earliest) {
continue
}
earliest = position
found = true
}
return earliest, found
}
func recordLess(doc *source.SourceDocument, left, right normalizedRecord) bool {
if left.hasEvidence != right.hasEvidence {
return left.hasEvidence
}
if left.hasEvidence && left.earliest != right.earliest {
return left.earliest < right.earliest
}
leftKey := identity.ComparisonKey(left.interaction.Name)
rightKey := identity.ComparisonKey(right.interaction.Name)
if leftKey != rightKey {
return leftKey < rightKey
}
if left.interaction.Name != right.interaction.Name {
return left.interaction.Name < right.interaction.Name
}
if left.interaction.Kind != right.interaction.Kind {
return left.interaction.Kind < right.interaction.Kind
}
return sourceRefsLess(doc, left.interaction.SourceRefs, right.interaction.SourceRefs)
}
func sourceRefsLess(doc *source.SourceDocument, left, right []source.SourceRef) bool {
for index := 0; index < len(left) && index < len(right); index++ {
if left[index] == right[index] {
continue
}
return sourceRefLess(doc, left[index], right[index])
}
return len(left) < len(right)
}
type duplicateGroup struct {
retainedIndex int
removed []int
@@ -315,11 +214,11 @@ 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.interaction, doc)
if !eligible {
if !interactionmodel.ValidSourceRefs(doc, record.interaction.SourceRefs) {
keep[index] = true
continue
}
key := interactionmodel.ExactIdentity(record.interaction)
groupIndex, exists := groupByKey[key]
if !exists {
groupByKey[key] = len(groups)
@@ -344,37 +243,6 @@ func collapseDuplicates(records []normalizedRecord, doc *source.SourceDocument)
return output, warnings
}
func duplicateKey(interaction dnd.NPCInteraction, doc *source.SourceDocument) (string, bool) {
if len(interaction.SourceRefs) == 0 {
return "", false
}
for _, ref := range interaction.SourceRefs {
if source.ValidateRef(doc, ref) != nil {
return "", false
}
}
var key strings.Builder
writeKeyString(&key, interaction.Name)
writeKeyString(&key, string(interaction.Kind))
for _, ref := range interaction.SourceRefs {
writeKeyString(&key, ref.SourceID)
writeKeyInt(&key, ref.StartUnitID)
writeKeyInt(&key, ref.EndUnitID)
}
return key.String(), true
}
func writeKeyString(builder *strings.Builder, value string) {
builder.WriteString(strconv.Itoa(len(value)))
builder.WriteByte(':')
builder.WriteString(value)
}
func writeKeyInt(builder *strings.Builder, value int) {
builder.WriteString(strconv.Itoa(value))
builder.WriteByte(';')
}
func duplicateWarning(retainedIndex int, removed []int) contracts.Warning {
issues := make([]string, len(removed))
for index, removedIndex := range removed {

View File

@@ -11,6 +11,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
)
func TestNormalizeCanonicalizesAndClones(t *testing.T) {
@@ -135,6 +136,35 @@ func TestNormalizerContractAndDeterministicWarnings(t *testing.T) {
}
}
func TestNormalizeBoundsWarnings(t *testing.T) {
count := diagnostics.MaxWarnings + 5
doc := &source.SourceDocument{ID: "session", Units: make([]source.SourceUnit, count)}
input := dnd.NPCInteractionList{Interactions: make([]dnd.NPCInteraction, count)}
for index := range doc.Units {
doc.Units[index].ID = index + 1
unitID := count - index
input.Interactions[index] = interaction(
"Ária",
dnd.NPCInteractionKindDialogue,
source.SourceRef{SourceID: doc.ID, StartUnitID: unitID, EndUnitID: unitID},
)
}
normalizer, err := New(Options{}, npcReferences(t))
if err != nil {
t.Fatal(err)
}
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{
Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input},
})
if err != nil {
t.Fatal(err)
}
if len(result.Warnings) != diagnostics.MaxWarnings ||
result.Warnings[len(result.Warnings)-1].ReasonCode != ReasonCodeWarningsOmitted {
t.Fatalf("warnings = %#v", result.Warnings)
}
}
func interaction(name string, kind dnd.NPCInteractionKind, ref source.SourceRef) dnd.NPCInteraction {
return dnd.NPCInteraction{Name: name, Kind: kind, SourceRefs: []source.SourceRef{ref}}
}

View File

@@ -0,0 +1,166 @@
// Package npcinteractions owns canonical ordering and exact-identity rules for
// D&D NPC interaction artifacts.
package npcinteractions
import (
"sort"
"strconv"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
)
// CanonicalizeSourceRefs returns a cloned, document-ordered, de-duplicated
// source-reference list.
func CanonicalizeSourceRefs(doc *source.SourceDocument, input []source.SourceRef) []source.SourceRef {
if input == nil {
return nil
}
canonical := append([]source.SourceRef(nil), input...)
sort.SliceStable(canonical, func(left, right int) bool {
return SourceRefLess(doc, canonical[left], canonical[right])
})
unique := make([]source.SourceRef, 0, len(canonical))
for _, ref := range canonical {
if len(unique) == 0 || unique[len(unique)-1] != ref {
unique = append(unique, ref)
}
}
return unique
}
// SourceRefsEqual reports whether two source-reference lists have identical
// representations and values.
func SourceRefsEqual(left, right []source.SourceRef) bool {
if (left == nil) != (right == nil) || len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
// SourceRefLess orders references by source identity and then by the source
// document positions of their endpoints. Invalid endpoints sort after valid
// endpoints and fall back to their literal IDs for deterministic diagnostics.
func SourceRefLess(doc *source.SourceDocument, left, right source.SourceRef) bool {
if left.SourceID != right.SourceID {
return left.SourceID < right.SourceID
}
leftStart, leftStartOK := source.UnitIndex(doc, left.StartUnitID)
rightStart, rightStartOK := source.UnitIndex(doc, right.StartUnitID)
if leftStartOK != rightStartOK {
return leftStartOK
}
if leftStartOK && leftStart != rightStart {
return leftStart < rightStart
}
if left.StartUnitID != right.StartUnitID {
return left.StartUnitID < right.StartUnitID
}
leftEnd, leftEndOK := source.UnitIndex(doc, left.EndUnitID)
rightEnd, rightEndOK := source.UnitIndex(doc, right.EndUnitID)
if leftEndOK != rightEndOK {
return leftEndOK
}
if leftEndOK && leftEnd != rightEnd {
return leftEnd < rightEnd
}
return left.EndUnitID < right.EndUnitID
}
// Less defines the canonical order for NPC interaction occurrences.
func Less(doc *source.SourceDocument, left, right dnd.NPCInteraction) bool {
leftPosition, leftHasEvidence := EarliestSourcePosition(doc, left)
rightPosition, rightHasEvidence := EarliestSourcePosition(doc, right)
if leftHasEvidence != rightHasEvidence {
return leftHasEvidence
}
if leftHasEvidence && leftPosition != rightPosition {
return leftPosition < rightPosition
}
leftKey := identity.ComparisonKey(left.Name)
rightKey := identity.ComparisonKey(right.Name)
if leftKey != rightKey {
return leftKey < rightKey
}
if left.Name != right.Name {
return left.Name < right.Name
}
if left.Kind != right.Kind {
return left.Kind < right.Kind
}
return sourceRefsLess(doc, left.SourceRefs, right.SourceRefs)
}
// EarliestSourcePosition returns the earliest valid cited position.
func EarliestSourcePosition(doc *source.SourceDocument, interaction dnd.NPCInteraction) (int, bool) {
found := false
earliest := 0
for _, ref := range interaction.SourceRefs {
if source.ValidateRef(doc, ref) != nil {
continue
}
position, ok := source.UnitIndex(doc, ref.StartUnitID)
if !ok || (found && position >= earliest) {
continue
}
earliest = position
found = true
}
return earliest, found
}
// ValidSourceRefs reports whether an interaction has non-empty, valid
// current-document evidence.
func ValidSourceRefs(doc *source.SourceDocument, refs []source.SourceRef) bool {
if len(refs) == 0 {
return false
}
for _, ref := range refs {
if source.ValidateRef(doc, ref) != nil {
return false
}
}
return true
}
// ExactIdentity returns a collision-safe key over every durable interaction
// field. Callers decide whether the record is eligible for duplicate handling.
func ExactIdentity(interaction dnd.NPCInteraction) string {
var key strings.Builder
writeKeyString(&key, interaction.Name)
writeKeyString(&key, string(interaction.Kind))
for _, ref := range interaction.SourceRefs {
writeKeyString(&key, ref.SourceID)
writeKeyInt(&key, ref.StartUnitID)
writeKeyInt(&key, ref.EndUnitID)
}
return key.String()
}
func sourceRefsLess(doc *source.SourceDocument, left, right []source.SourceRef) bool {
for index := 0; index < len(left) && index < len(right); index++ {
if left[index] == right[index] {
continue
}
return SourceRefLess(doc, left[index], right[index])
}
return len(left) < len(right)
}
func writeKeyString(builder *strings.Builder, value string) {
builder.WriteString(strconv.Itoa(len(value)))
builder.WriteByte(':')
builder.WriteString(value)
}
func writeKeyInt(builder *strings.Builder, value int) {
builder.WriteString(strconv.Itoa(value))
builder.WriteByte(';')
}

View File

@@ -6,10 +6,13 @@ import (
"fmt"
"strconv"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
const (
MaxIssues = 20
MaxWarnings = 20
MaxDisplayedRunes = 128
MaxMessageBytes = 4096
)
@@ -37,6 +40,28 @@ func Aggregate(prefix string, issues []string) string {
return aggregateMessage(prefix, displayed, len(issues)-len(displayed))
}
// LimitWarnings returns at most MaxWarnings warnings, reserving the final
// position for a deterministic omission summary when truncation is required.
func LimitWarnings(warnings []contracts.Warning, scope, reasonCode string) []contracts.Warning {
if warnings == nil {
return nil
}
if len(warnings) <= MaxWarnings {
bounded := make([]contracts.Warning, len(warnings))
copy(bounded, warnings)
return bounded
}
displayed := MaxWarnings - 1
bounded := make([]contracts.Warning, displayed, MaxWarnings)
copy(bounded, warnings[:displayed])
bounded = append(bounded, contracts.Warning{
Scope: scope,
ReasonCode: reasonCode,
Message: fmt.Sprintf("%d additional warning(s) omitted", len(warnings)-displayed),
})
return bounded
}
func aggregateMessage(prefix string, issues []string, omitted int) string {
message := prefix + ": " + strings.Join(issues, ", ")
if omitted > 0 {

View File

@@ -2,9 +2,12 @@ package diagnostics
import (
"fmt"
"reflect"
"strings"
"testing"
"unicode/utf8"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
func TestAggregateEnforcesByteBudgetAndReportsOmissions(t *testing.T) {
@@ -26,3 +29,25 @@ func TestAggregateEnforcesByteBudgetAndReportsOmissions(t *testing.T) {
t.Fatalf("Aggregate() = %q, want %q", message, wantOmitted)
}
}
func TestLimitWarningsBoundsOutputAndReportsOmissions(t *testing.T) {
warnings := make([]contracts.Warning, MaxWarnings+3)
for index := range warnings {
warnings[index] = contracts.Warning{ReasonCode: fmt.Sprintf("warning-%d", index)}
}
before := append([]contracts.Warning(nil), warnings...)
got := LimitWarnings(warnings, "records", "warnings_omitted")
if len(got) != MaxWarnings {
t.Fatalf("LimitWarnings() count = %d, want %d", len(got), MaxWarnings)
}
summary := got[len(got)-1]
wantOmitted := len(warnings) - (MaxWarnings - 1)
if summary.Scope != "records" || summary.ReasonCode != "warnings_omitted" ||
summary.Message != fmt.Sprintf("%d additional warning(s) omitted", wantOmitted) {
t.Fatalf("summary = %#v", summary)
}
if !reflect.DeepEqual(warnings, before) {
t.Fatal("LimitWarnings() mutated its input")
}
}

View File

@@ -5,14 +5,12 @@ import (
"context"
"fmt"
"sort"
"strconv"
"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"
interactionmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcinteractions"
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
interactionshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/shape"
@@ -78,7 +76,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
}
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCInteractionList]) (contracts.ValidationResult, error) {
if interactionshape.Validate(req.Value) != nil || !sourceRefsValid(req.Source, req.Value) {
if interactionshape.Validate(req.Value) != nil || !allSourceRefsValid(req.Source, req.Value) {
return contracts.ValidationResult{Approved: true}, nil
}
if v == nil || v.npcResolver == nil {
@@ -102,12 +100,10 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
}, nil
}
func sourceRefsValid(doc *source.SourceDocument, value dnd.NPCInteractionList) bool {
func allSourceRefsValid(doc *source.SourceDocument, value dnd.NPCInteractionList) bool {
for _, interaction := range value.Interactions {
for _, ref := range interaction.SourceRefs {
if source.ValidateRef(doc, ref) != nil {
return false
}
if !interactionmodel.ValidSourceRefs(doc, interaction.SourceRefs) {
return false
}
}
return true
@@ -123,7 +119,7 @@ func issuesFor(doc *source.SourceDocument, value dnd.NPCInteractionList, npcRegi
for refIndex := 1; refIndex < len(interaction.SourceRefs); refIndex++ {
previous := interaction.SourceRefs[refIndex-1]
current := interaction.SourceRefs[refIndex]
if sourceRefLess(doc, current, previous) {
if interactionmodel.SourceRefLess(doc, current, previous) {
issues = append(issues, fmt.Sprintf("%s.source_refs are not in canonical order at index %d", prefix, refIndex))
} else if current == previous {
issues = append(issues, fmt.Sprintf("%s.source_refs[%d] duplicates the previous reference", prefix, refIndex))
@@ -132,13 +128,13 @@ func issuesFor(doc *source.SourceDocument, value dnd.NPCInteractionList, npcRegi
}
if !sort.SliceIsSorted(value.Interactions, func(left, right int) bool {
return interactionLess(doc, value.Interactions[left], value.Interactions[right])
return interactionmodel.Less(doc, value.Interactions[left], value.Interactions[right])
}) {
issues = append(issues, "interactions are not in canonical order")
}
seen := make(map[string]int)
for index, interaction := range value.Interactions {
key := duplicateKey(interaction)
key := interactionmodel.ExactIdentity(interaction)
if previous, ok := seen[key]; ok {
issues = append(issues, fmt.Sprintf("interactions[%d] duplicates interaction %d", index, previous))
continue
@@ -148,105 +144,6 @@ func issuesFor(doc *source.SourceDocument, value dnd.NPCInteractionList, npcRegi
return issues
}
func interactionLess(doc *source.SourceDocument, left, right dnd.NPCInteraction) bool {
leftPosition, leftHasEvidence := earliestSourcePosition(doc, left)
rightPosition, rightHasEvidence := earliestSourcePosition(doc, right)
if leftHasEvidence != rightHasEvidence {
return leftHasEvidence
}
if leftHasEvidence && leftPosition != rightPosition {
return leftPosition < rightPosition
}
leftKey := identity.ComparisonKey(left.Name)
rightKey := identity.ComparisonKey(right.Name)
if leftKey != rightKey {
return leftKey < rightKey
}
if left.Name != right.Name {
return left.Name < right.Name
}
if left.Kind != right.Kind {
return left.Kind < right.Kind
}
return sourceRefsLess(doc, left.SourceRefs, right.SourceRefs)
}
func sourceRefsLess(doc *source.SourceDocument, left, right []source.SourceRef) bool {
for index := 0; index < len(left) && index < len(right); index++ {
if left[index] == right[index] {
continue
}
return sourceRefLess(doc, left[index], right[index])
}
return len(left) < len(right)
}
func sourceRefLess(doc *source.SourceDocument, left, right source.SourceRef) bool {
if left.SourceID != right.SourceID {
return left.SourceID < right.SourceID
}
leftStart, leftStartOK := source.UnitIndex(doc, left.StartUnitID)
rightStart, rightStartOK := source.UnitIndex(doc, right.StartUnitID)
if leftStartOK != rightStartOK {
return leftStartOK
}
if leftStartOK && leftStart != rightStart {
return leftStart < rightStart
}
if left.StartUnitID != right.StartUnitID {
return left.StartUnitID < right.StartUnitID
}
leftEnd, leftEndOK := source.UnitIndex(doc, left.EndUnitID)
rightEnd, rightEndOK := source.UnitIndex(doc, right.EndUnitID)
if leftEndOK != rightEndOK {
return leftEndOK
}
if leftEndOK && leftEnd != rightEnd {
return leftEnd < rightEnd
}
return left.EndUnitID < right.EndUnitID
}
func earliestSourcePosition(doc *source.SourceDocument, interaction dnd.NPCInteraction) (int, bool) {
found := false
earliest := 0
for _, ref := range interaction.SourceRefs {
if source.ValidateRef(doc, ref) != nil {
continue
}
position, ok := source.UnitIndex(doc, ref.StartUnitID)
if !ok || (found && position >= earliest) {
continue
}
earliest = position
found = true
}
return earliest, found
}
func duplicateKey(interaction dnd.NPCInteraction) string {
var key strings.Builder
writeKeyString(&key, interaction.Name)
writeKeyString(&key, string(interaction.Kind))
for _, ref := range interaction.SourceRefs {
writeKeyString(&key, ref.SourceID)
writeKeyInt(&key, ref.StartUnitID)
writeKeyInt(&key, ref.EndUnitID)
}
return key.String()
}
func writeKeyString(builder *strings.Builder, value string) {
builder.WriteString(strconv.Itoa(len(value)))
builder.WriteByte(':')
builder.WriteString(value)
}
func writeKeyInt(builder *strings.Builder, value int) {
builder.WriteString(strconv.Itoa(value))
builder.WriteByte(';')
}
func Spec() pipeline.ValidatorSpec {
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
}

View File

@@ -16,7 +16,7 @@ import (
const (
Key = "extract/dnd/npc-interactions/source_refs"
ReasonCode = "invalid_npc_interaction_source_refs"
policy = "dnd.npc_interactions.validator.source_refs.v1"
policy = "dnd.npc_interactions.validator.source_refs.v2"
)
type Options struct{}
@@ -35,6 +35,9 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
}
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCInteractionList]) (contracts.ValidationResult, error) {
if req.Stage == string(pipeline.StageExtract) && req.Chunk == nil {
return contracts.ValidationResult{}, fmt.Errorf("NPC interaction source-reference validator requires the current extraction chunk")
}
if interactionshape.Validate(req.Value) != nil {
return contracts.ValidationResult{Approved: true}, nil
}
@@ -43,6 +46,13 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
for refIndex, ref := range interaction.SourceRefs {
if err := source.ValidateRef(req.Source, ref); err != nil {
issues = append(issues, fmt.Sprintf("interactions[%d].source_refs[%d]: %s", interactionIndex, refIndex, diagnostics.Truncate(err.Error())))
continue
}
if req.Stage == string(pipeline.StageExtract) && !chunkContainsRef(req.Chunk, ref) {
issues = append(issues, fmt.Sprintf(
"interactions[%d].source_refs[%d]: source reference is outside the current extraction chunk",
interactionIndex, refIndex,
))
}
}
}
@@ -56,6 +66,19 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
}, nil
}
func chunkContainsRef(chunk *source.Chunk, ref source.SourceRef) bool {
if chunk == nil || ref.SourceID != chunk.SourceID {
return false
}
startFound := false
endFound := false
for _, unit := range chunk.Units {
startFound = startFound || unit.ID == ref.StartUnitID
endFound = endFound || unit.ID == ref.EndUnitID
}
return startFound && endFound
}
func Spec() pipeline.ValidatorSpec {
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
}

View File

@@ -29,6 +29,49 @@ func TestValidatorOwnsCurrentSourceUnitAndRangeValidation(t *testing.T) {
}
}
func TestValidatorRejectsDocumentValidEvidenceOutsideCurrentExtractionChunk(t *testing.T) {
doc := document()
chunk := &source.Chunk{
ID: "chunk-0",
SourceID: doc.ID,
Units: append([]source.SourceUnit(nil), doc.Units[:2]...),
}
value := validList()
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{
Stage: string(pipeline.StageExtract),
Source: doc,
Chunk: chunk,
Value: value,
})
if err != nil || !result.Approved {
t.Fatalf("contained evidence = %#v, %v", result, err)
}
value.Interactions[0].SourceRefs = []source.SourceRef{{
SourceID: doc.ID, StartUnitID: 2, EndUnitID: 3,
}}
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{
Stage: string(pipeline.StageExtract),
Source: doc,
Chunk: chunk,
Value: value,
})
if err != nil || result.Approved || !strings.Contains(result.Message, "outside the current extraction chunk") {
t.Fatalf("out-of-chunk evidence = %#v, %v", result, err)
}
}
func TestValidatorRequiresChunkDuringExtractValidation(t *testing.T) {
_, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{
Stage: string(pipeline.StageExtract),
Source: document(),
Value: validList(),
})
if err == nil || !strings.Contains(err.Error(), "requires the current extraction chunk") {
t.Fatalf("Validate() error = %v", err)
}
}
func TestValidatorDefersShapeAndDoesNotMutate(t *testing.T) {
malformed := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn"}}}
result, err := New(Options{}).Validate(context.Background(), request(document(), malformed))
@@ -55,7 +98,7 @@ func request(doc *source.SourceDocument, value dnd.NPCInteractionList) contracts
}
func document() *source.SourceDocument {
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}}}
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}, {ID: 3}}}
}
func validList() dnd.NPCInteractionList {

View File

@@ -16,7 +16,8 @@ import (
const (
Key = "extract/dnd/npc-interactions/source_relatedness"
WarningReasonCode = "npc_interaction_not_near_source"
policy = "dnd.npc_interactions.validator.source_relatedness.v1"
OmittedReasonCode = "npc_interaction_relatedness_warnings_omitted"
policy = "dnd.npc_interactions.validator.source_relatedness.v2"
)
type Options struct{}
@@ -57,7 +58,10 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
Message: fmt.Sprintf("NPC interaction name %s was not found in cited source text", diagnostics.Quote(interaction.Name)),
})
}
return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil
return contracts.ValidationResult{
Approved: true,
Warnings: diagnostics.LimitWarnings(warnings, "npc_interactions", OmittedReasonCode),
}, nil
}
func Spec() pipeline.ValidatorSpec {

View File

@@ -10,6 +10,7 @@ import (
"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/diagnostics"
)
func TestValidatorUsesOnlyCurrentTranscriptAndWarnsOncePerInteraction(t *testing.T) {
@@ -49,3 +50,26 @@ func TestValidatorDefersMalformedShapeAndInvalidRanges(t *testing.T) {
t.Fatal(err)
}
}
func TestValidatorBoundsWarnings(t *testing.T) {
count := diagnostics.MaxWarnings + 5
interactions := make([]dnd.NPCInteraction, count)
for index := range interactions {
interactions[index] = dnd.NPCInteraction{
Name: "Missing NPC",
Kind: dnd.NPCInteractionKindMentioned,
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}
}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{
Source: &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "The party waits."}}},
Value: dnd.NPCInteractionList{Interactions: interactions},
})
if err != nil || !result.Approved {
t.Fatalf("Validate() = %#v, %v", result, err)
}
if len(result.Warnings) != diagnostics.MaxWarnings ||
result.Warnings[len(result.Warnings)-1].ReasonCode != OmittedReasonCode {
t.Fatalf("warnings = %#v", result.Warnings)
}
}