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

@@ -387,8 +387,8 @@ production validators do not call the LLM and must not set `llm_profile`.
| `normalize/dnd/combat-turns/invariants` | deterministic | Rejects normalized combat-turn identity, evidence-order, and chronology violations. | | `normalize/dnd/combat-turns/invariants` | deterministic | Rejects normalized combat-turn identity, evidence-order, and chronology violations. |
| `extract/dnd/npc-interactions/shape` | deterministic | Rejects malformed D&D NPC-interaction-list artifacts. | | `extract/dnd/npc-interactions/shape` | deterministic | Rejects malformed D&D NPC-interaction-list artifacts. |
| `extract/dnd/npc-interactions/registry` | deterministic | Rejects interaction names absent from the supplied NPC registry. | | `extract/dnd/npc-interactions/registry` | deterministic | Rejects interaction names absent from the supplied NPC registry. |
| `extract/dnd/npc-interactions/source_refs` | deterministic | Rejects missing or invalid D&D interaction source references. | | `extract/dnd/npc-interactions/source_refs` | deterministic | Rejects missing, invalid, or extract-chunk-external D&D interaction source references. |
| `extract/dnd/npc-interactions/source_relatedness` | deterministic | Emits warnings when an interaction name is not found near cited source text. | | `extract/dnd/npc-interactions/source_relatedness` | deterministic | Emits bounded warnings when an interaction name is not found in its cited source text. |
| `normalize/dnd/npc-interactions/invariants` | deterministic | Rejects normalized interaction identity, evidence-order, and chronology violations. | | `normalize/dnd/npc-interactions/invariants` | deterministic | Rejects normalized interaction identity, evidence-order, and chronology violations. |
The production default chain for `dnd/spells` is used for both its extract and The production default chain for `dnd/spells` is used for both its extract and
@@ -460,9 +460,9 @@ normalize:
- generic/valid_json - generic/valid_json
- extract/dnd/npc-interactions/shape - extract/dnd/npc-interactions/shape
- extract/dnd/npc-interactions/registry - extract/dnd/npc-interactions/registry
- normalize/dnd/npc-interactions/invariants
- extract/dnd/npc-interactions/source_refs - extract/dnd/npc-interactions/source_refs
- generic/valid_json_schema - generic/valid_json_schema
- normalize/dnd/npc-interactions/invariants
- extract/dnd/npc-interactions/source_relatedness - extract/dnd/npc-interactions/source_relatedness
``` ```

View File

@@ -33,23 +33,57 @@ array may be empty. Each item has exactly `name`, `kind`, and `source_refs`:
`name` is the canonical display name from the required NPC registry. `name` is the canonical display name from the required NPC registry.
`source_refs` contains one or more current-source ranges with required `source_refs` contains one or more current-source ranges with required
`source_id`, `start_unit_id`, and `end_unit_id`; unit IDs are positive integers. `source_id`, `start_unit_id`, and `end_unit_id`; unit IDs are positive integers.
During extraction, every range must be wholly contained in the current accepted
chunk. This prevents a candidate from citing valid units that were not presented
to that extraction call.
Unknown fields are rejected. Unknown fields are rejected.
## Interaction Categories ## Interaction Categories
`kind` is exactly one of: `kind` is exactly one of:
- `mentioned`: the named NPC is referenced without stronger participation. | Kind | Meaning |
- `noncombat_presence`: the NPC is present in the current scene without a | --- | --- |
dialogue or combat classification. | `mentioned` | The NPC is referred to, but is not established as present or communicating in the evidenced passage. |
- `dialogue`: the NPC participates in spoken interaction. | `noncombat_presence` | The NPC is present and relevant to the passage but does not meaningfully participate in dialogue or combat. |
- `combat_ally`: the NPC participates in combat aligned with the party. | `dialogue` | The NPC speaks, responds, or is directly engaged in a meaningful non-combat exchange. |
- `combat_opponent`: the NPC participates in combat against the party. | `combat_ally` | The NPC actively participates in combat on the party's side. |
- `other`: a transcript-supported interaction outside the bounded categories. | `combat_opponent` | The NPC actively participates in combat against the party. |
| `other` | The transcript clearly establishes a direct NPC occurrence that fits none of the preceding kinds. |
`other` is a residual category for positively evidenced activity, not a fallback
for uncertain classification. When activities overlap, active combat
participation outranks dialogue, presence, and mention; dialogue outranks
non-combat presence and mention; and non-combat presence outranks mention.
Combat alignment is not resolved by precedence: a meaningful change between
ally and opponent creates separate occurrences.
These categories do not encode summaries, relationships, state, motives, or These categories do not encode summaries, relationships, state, motives, or
unobserved events. unobserved events.
## Occurrence Boundaries And Ordering
One occurrence represents one NPC, one kind, and one locally coherent passage
within one accepted chunk. Repeated evidence belongs to the same occurrence
only while it supports the same uninterrupted activity. A kind change, combat
alignment change, intervening scene or meaningful absence, or transition from
mention to presence starts a new occurrence. Occurrences never span chunks, and
merge or normalization never semantically combines nearby, overlapping, or
cross-chunk records.
Normalization orders records by:
1. earliest valid source-document position;
2. the NPC identity comparison key;
3. the exact canonical NPC display name;
4. interaction kind in lexical order; and
5. the complete canonical source-reference sequence, ordered by source ID and
the source-document positions of each range's start and end.
Only records with identical canonical names, kinds, and complete valid evidence
sequences are duplicates. Different categories, ranges, or separately grounded
occurrences remain separate.
## Evidence, Registry, And Normalization ## Evidence, Registry, And Normalization
The registry proves only the canonical NPC identity. Its source references are The registry proves only the canonical NPC identity. Its source references are
@@ -59,11 +93,8 @@ classification.
The extractor receives a names-only registry projection such as The extractor receives a names-only registry projection such as
`{"npcs":[{"name":"Mira Thorn"}]}`. The normalizer uses the full immutable `{"npcs":[{"name":"Mira Thorn"}]}`. The normalizer uses the full immutable
registry for exact canonical-name lookup. It orders source references, registry for exact canonical-name lookup. It canonicalizes source references
stable-sorts occurrences by their earliest source-document position, and and applies the ordering and exact-duplicate rules above.
collapses only exact duplicates with the same canonical name, kind, and complete
valid evidence. Different categories, distinct ranges, and separately grounded
occurrences remain separate; no semantic merge is performed.
## Production Pipeline ## Production Pipeline
@@ -102,8 +133,10 @@ copying registry names, source ranges, or payload content into the manifest.
The default extract chain is `generic/valid_json`, interaction shape, registry, The default extract chain is `generic/valid_json`, interaction shape, registry,
and source-reference validation, `generic/valid_json_schema`, then warning-only and source-reference validation, `generic/valid_json_schema`, then warning-only
source relatedness. The normalize chain adds normalized invariants after schema source relatedness. The normalize chain runs normalized invariants after
validation and before relatedness. The codec metadata contains only registry validation and before source-reference and schema validation, followed
by relatedness. Normalizer and relatedness warnings are bounded and end with an
omission summary when necessary. The codec metadata contains only
`interaction_count`. Extractor metadata identifies its prompt and private `interaction_count`. Extractor metadata identifies its prompt and private
response schema; component-local checkpoint identities include the names-only response schema; component-local checkpoint identities include the names-only
registry projection where relevant. Generated registry identity stays in registry projection where relevant. Generated registry identity stays in

View File

@@ -299,10 +299,19 @@ evidence, identity, and transcript material, then maps private model records to
references are never reused as interaction evidence. The private response references are never reused as interaction evidence. The private response
schema carries only name, bounded interaction kind, and source-unit ranges; schema carries only name, bounded interaction kind, and source-unit ranges;
deterministic validators own registry membership, source validity, and deterministic validators own registry membership, source validity, and
relatedness. Prompt, schema, mapping, and the names-only registry projection relatedness. Extract-stage source validation additionally requires every cited
range to be wholly contained in the current materialized chunk. Prompt, schema,
mapping, and the names-only registry projection
participate in checkpoint identity, while generated producer identity remains participate in checkpoint identity, while generated producer identity remains
framework provenance. framework provenance.
The domain-owned `internal/modules/dnd/npcinteractions` package defines
canonical source-reference and occurrence ordering, valid-evidence eligibility,
and collision-safe exact identity. The interaction normalizer and normalized
invariants validator both consume those rules, so their production and checking
paths cannot drift. Normalizer and relatedness warning lists use the shared D&D
diagnostic cap and emit a final omission-summary warning when truncated.
### `internal/modules/dnd/normalize/npcs` ### `internal/modules/dnd/normalize/npcs`
The NPC normalizer performs deterministic identity-aware consolidation in The NPC normalizer performs deterministic identity-aware consolidation in

View File

@@ -97,6 +97,7 @@ Configuration. The implemented module packages are:
| `internal/modules/dnd/extract/npcs` | Maps private structured model output to canonical source-grounded D&D NPC lists. | | `internal/modules/dnd/extract/npcs` | Maps private structured model output to canonical source-grounded D&D NPC lists. |
| `internal/modules/dnd/extract/combatturns` | Maps private structured model output to source-grounded D&D combat-turn candidates and preserves chronology and invalid candidate values for validators. | | `internal/modules/dnd/extract/combatturns` | Maps private structured model output to source-grounded D&D combat-turn candidates and preserves chronology and invalid candidate values for validators. |
| `internal/modules/dnd/extract/npcinteractions` | Maps private structured model output to current-source NPC interaction candidates grounded by a required registry. | | `internal/modules/dnd/extract/npcinteractions` | Maps private structured model output to current-source NPC interaction candidates grounded by a required registry. |
| `internal/modules/dnd/npcinteractions` | Owns canonical source-reference ordering, occurrence ordering, valid-evidence checks, and exact interaction identity shared by normalization and invariant validation. |
| `internal/modules/dnd/normalize/combatturns` | Canonicalizes and orders merged combat turns, applies exact NPC identity matches, and collapses only exact valid-evidence duplicates. | | `internal/modules/dnd/normalize/combatturns` | Canonicalizes and orders merged combat turns, applies exact NPC identity matches, and collapses only exact valid-evidence duplicates. |
| `internal/modules/dnd/normalize/npcinteractions` | Canonicalizes required-registry names, orders interaction occurrences, and collapses only exact valid-evidence duplicates. | | `internal/modules/dnd/normalize/npcinteractions` | Canonicalizes required-registry names, orders interaction occurrences, and collapses only exact valid-evidence duplicates. |
| `internal/modules/dnd/validate/combatturns` | Provides deterministic shape, source-reference, source-relatedness, and normalized-invariant validation for the production combat chains. | | `internal/modules/dnd/validate/combatturns` | Provides deterministic shape, source-reference, source-relatedness, and normalized-invariant validation for the production combat chains. |

View File

@@ -5,13 +5,12 @@ import (
"context" "context"
"fmt" "fmt"
"sort" "sort"
"strconv"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "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" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry" npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
@@ -20,13 +19,14 @@ import (
const ( const (
Key = "dnd/npc-interactions" Key = "dnd/npc-interactions"
normalizationPolicy = "dnd.npc_interactions.normalize.v1" normalizationPolicy = "dnd.npc_interactions.normalize.v2"
NormalizationPolicy = normalizationPolicy NormalizationPolicy = normalizationPolicy
ReasonCodeNameCanonicalized = "npc_interaction_name_canonicalized" ReasonCodeNameCanonicalized = "npc_interaction_name_canonicalized"
ReasonCodeSourceRefsNormalized = "source_references_normalized" ReasonCodeSourceRefsNormalized = "source_references_normalized"
ReasonCodeInteractionsReordered = "npc_interactions_reordered" ReasonCodeInteractionsReordered = "npc_interactions_reordered"
ReasonCodeDuplicateCollapsed = "duplicate_npc_interaction_collapsed" ReasonCodeDuplicateCollapsed = "duplicate_npc_interaction_collapsed"
ReasonCodeWarningsOmitted = "npc_interaction_normalization_warnings_omitted"
) )
const ( const (
@@ -125,8 +125,6 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
type normalizedRecord struct { type normalizedRecord struct {
interaction dnd.NPCInteraction interaction dnd.NPCInteraction
inputIndex int inputIndex int
earliest int
hasEvidence bool
} }
type nameCanonicalization struct { type nameCanonicalization struct {
@@ -143,8 +141,7 @@ func normalizeList(input dnd.NPCInteractionList, doc *source.SourceDocument, reg
warnings := make([]contracts.Warning, 0) warnings := make([]contracts.Warning, 0)
for index, inputInteraction := range input.Interactions { for index, inputInteraction := range input.Interactions {
interaction, nameChange, refsChanged := normalizeInteraction(inputInteraction, doc, registry) interaction, nameChange, refsChanged := normalizeInteraction(inputInteraction, doc, registry)
earliest, hasEvidence := earliestSourcePosition(doc, interaction) records[index] = normalizedRecord{interaction: interaction, inputIndex: index}
records[index] = normalizedRecord{interaction: interaction, inputIndex: index, earliest: earliest, hasEvidence: hasEvidence}
if nameChange != nil { if nameChange != nil {
warnings = append(warnings, contracts.Warning{ warnings = append(warnings, contracts.Warning{
Scope: interactionScope(index), 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 { for position, record := range records {
if position == record.inputIndex { if position == record.inputIndex {
continue continue
@@ -177,7 +176,8 @@ func normalizeList(input dnd.NPCInteractionList, doc *source.SourceDocument, reg
output, duplicateWarnings := collapseDuplicates(records, doc) output, duplicateWarnings := collapseDuplicates(records, doc)
warnings = append(warnings, duplicateWarnings...) 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) { 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 { if input.Name != output.Name {
nameChange = &nameCanonicalization{from: input.Name, to: output.Name} nameChange = &nameCanonicalization{from: input.Name, to: output.Name}
} }
output.SourceRefs = canonicalizeSourceRefs(doc, input.SourceRefs) output.SourceRefs = interactionmodel.CanonicalizeSourceRefs(doc, input.SourceRefs)
return output, nameChange, !sourceRefsEqual(input.SourceRefs, output.SourceRefs) return output, nameChange, !interactionmodel.SourceRefsEqual(input.SourceRefs, output.SourceRefs)
} }
func cloneInteraction(input dnd.NPCInteraction) dnd.NPCInteraction { func cloneInteraction(input dnd.NPCInteraction) dnd.NPCInteraction {
@@ -201,107 +201,6 @@ func cloneInteraction(input dnd.NPCInteraction) dnd.NPCInteraction {
return output 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 { type duplicateGroup struct {
retainedIndex int retainedIndex int
removed []int removed []int
@@ -315,11 +214,11 @@ func collapseDuplicates(records []normalizedRecord, doc *source.SourceDocument)
groups := make([]duplicateGroup, 0) groups := make([]duplicateGroup, 0)
groupByKey := make(map[string]int) groupByKey := make(map[string]int)
for index, record := range records { for index, record := range records {
key, eligible := duplicateKey(record.interaction, doc) if !interactionmodel.ValidSourceRefs(doc, record.interaction.SourceRefs) {
if !eligible {
keep[index] = true keep[index] = true
continue continue
} }
key := interactionmodel.ExactIdentity(record.interaction)
groupIndex, exists := groupByKey[key] groupIndex, exists := groupByKey[key]
if !exists { if !exists {
groupByKey[key] = len(groups) groupByKey[key] = len(groups)
@@ -344,37 +243,6 @@ func collapseDuplicates(records []normalizedRecord, doc *source.SourceDocument)
return output, warnings 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 { func duplicateWarning(retainedIndex int, removed []int) contracts.Warning {
issues := make([]string, len(removed)) issues := make([]string, len(removed))
for index, removedIndex := range removed { for index, removedIndex := range removed {

View File

@@ -11,6 +11,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs" 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/npcs/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
) )
func TestNormalizeCanonicalizesAndClones(t *testing.T) { 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 { func interaction(name string, kind dnd.NPCInteractionKind, ref source.SourceRef) dnd.NPCInteraction {
return dnd.NPCInteraction{Name: name, Kind: kind, SourceRefs: []source.SourceRef{ref}} 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" "fmt"
"strconv" "strconv"
"strings" "strings"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
) )
const ( const (
MaxIssues = 20 MaxIssues = 20
MaxWarnings = 20
MaxDisplayedRunes = 128 MaxDisplayedRunes = 128
MaxMessageBytes = 4096 MaxMessageBytes = 4096
) )
@@ -37,6 +40,28 @@ func Aggregate(prefix string, issues []string) string {
return aggregateMessage(prefix, displayed, len(issues)-len(displayed)) 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 { func aggregateMessage(prefix string, issues []string, omitted int) string {
message := prefix + ": " + strings.Join(issues, ", ") message := prefix + ": " + strings.Join(issues, ", ")
if omitted > 0 { if omitted > 0 {

View File

@@ -2,9 +2,12 @@ package diagnostics
import ( import (
"fmt" "fmt"
"reflect"
"strings" "strings"
"testing" "testing"
"unicode/utf8" "unicode/utf8"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
) )
func TestAggregateEnforcesByteBudgetAndReportsOmissions(t *testing.T) { func TestAggregateEnforcesByteBudgetAndReportsOmissions(t *testing.T) {
@@ -26,3 +29,25 @@ func TestAggregateEnforcesByteBudgetAndReportsOmissions(t *testing.T) {
t.Fatalf("Aggregate() = %q, want %q", message, wantOmitted) 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" "context"
"fmt" "fmt"
"sort" "sort"
"strconv"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "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" npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
interactionshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/shape" 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) { 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 return contracts.ValidationResult{Approved: true}, nil
} }
if v == nil || v.npcResolver == nil { if v == nil || v.npcResolver == nil {
@@ -102,12 +100,10 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
}, nil }, 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 _, interaction := range value.Interactions {
for _, ref := range interaction.SourceRefs { if !interactionmodel.ValidSourceRefs(doc, interaction.SourceRefs) {
if source.ValidateRef(doc, ref) != nil { return false
return false
}
} }
} }
return true return true
@@ -123,7 +119,7 @@ func issuesFor(doc *source.SourceDocument, value dnd.NPCInteractionList, npcRegi
for refIndex := 1; refIndex < len(interaction.SourceRefs); refIndex++ { for refIndex := 1; refIndex < len(interaction.SourceRefs); refIndex++ {
previous := interaction.SourceRefs[refIndex-1] previous := interaction.SourceRefs[refIndex-1]
current := interaction.SourceRefs[refIndex] 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)) issues = append(issues, fmt.Sprintf("%s.source_refs are not in canonical order at index %d", prefix, refIndex))
} else if current == previous { } else if current == previous {
issues = append(issues, fmt.Sprintf("%s.source_refs[%d] duplicates the previous reference", prefix, refIndex)) 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 { 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") issues = append(issues, "interactions are not in canonical order")
} }
seen := make(map[string]int) seen := make(map[string]int)
for index, interaction := range value.Interactions { for index, interaction := range value.Interactions {
key := duplicateKey(interaction) key := interactionmodel.ExactIdentity(interaction)
if previous, ok := seen[key]; ok { if previous, ok := seen[key]; ok {
issues = append(issues, fmt.Sprintf("interactions[%d] duplicates interaction %d", index, previous)) issues = append(issues, fmt.Sprintf("interactions[%d] duplicates interaction %d", index, previous))
continue continue
@@ -148,105 +144,6 @@ func issuesFor(doc *source.SourceDocument, value dnd.NPCInteractionList, npcRegi
return issues 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 { func Spec() pipeline.ValidatorSpec {
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
} }

View File

@@ -16,7 +16,7 @@ import (
const ( const (
Key = "extract/dnd/npc-interactions/source_refs" Key = "extract/dnd/npc-interactions/source_refs"
ReasonCode = "invalid_npc_interaction_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{} 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) { 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 { if interactionshape.Validate(req.Value) != nil {
return contracts.ValidationResult{Approved: true}, 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 { for refIndex, ref := range interaction.SourceRefs {
if err := source.ValidateRef(req.Source, ref); err != nil { 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()))) 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 }, 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 { func Spec() pipeline.ValidatorSpec {
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} 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) { func TestValidatorDefersShapeAndDoesNotMutate(t *testing.T) {
malformed := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn"}}} malformed := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn"}}}
result, err := New(Options{}).Validate(context.Background(), request(document(), malformed)) 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 { 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 { func validList() dnd.NPCInteractionList {

View File

@@ -16,7 +16,8 @@ import (
const ( const (
Key = "extract/dnd/npc-interactions/source_relatedness" Key = "extract/dnd/npc-interactions/source_relatedness"
WarningReasonCode = "npc_interaction_not_near_source" 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{} 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)), 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 { 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/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
) )
func TestValidatorUsesOnlyCurrentTranscriptAndWarnsOncePerInteraction(t *testing.T) { func TestValidatorUsesOnlyCurrentTranscriptAndWarnsOncePerInteraction(t *testing.T) {
@@ -49,3 +50,26 @@ func TestValidatorDefersMalformedShapeAndInvalidRanges(t *testing.T) {
t.Fatal(err) 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)
}
}