Align D&D validator policies and diagnostics

This commit is contained in:
2026-07-21 14:43:20 +00:00
parent 07460341e3
commit 6e12c09952
8 changed files with 125 additions and 31 deletions

View File

@@ -358,15 +358,19 @@ validator defers when shape is invalid, then checks every non-empty spell name
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 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.
source-reference validator defers malformed shapes, validates every cited
range, and reports all range defects through a bounded aggregate while
preserving `invalid_source_refs`. 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
These validators are deterministic. Shape, source-reference, and relatedness
each expose a local semantic `policy` checkpoint fingerprint. The catalog
validator instead exposes its effective catalog digest as its semantic
checkpoint identity and does not add a separate policy fingerprint. Their
selectable keys and production order are defined in
[Configuration](../config.md#implemented-production-validators); their durable
payload rules are defined in the
[artifact contract](../integrations/dnd-spell-artifacts.md).
@@ -374,23 +378,26 @@ payload rules are defined in the
## D&D NPC Validators
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 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.
shape. The source-reference validator defers malformed shapes, checks
current-document identity, unit existence, and range ordering, and reports all
defects through bounded aggregates. 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, 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.
shape, checks source identity, unit existence, and range order, and reports all
defects through bounded aggregates. Combat 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

View File

@@ -4,6 +4,7 @@ import (
"context"
"strings"
"testing"
"unicode/utf8"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
@@ -53,6 +54,22 @@ func TestValidatorDefersMalformedShape(t *testing.T) {
}
}
func TestValidatorBoundsAggregateDiagnostics(t *testing.T) {
value := validCombatTurnList()
value.CombatTurns[0].SourceRefs = make([]source.SourceRef, 24)
for index := range value.CombatTurns[0].SourceRefs {
value.CombatTurns[0].SourceRefs[index] = source.SourceRef{SourceID: "session", StartUnitID: 99 + index, EndUnitID: 99 + index}
}
value.CombatTurns[0].SourceRefs[0].SourceID = strings.Repeat("火", 220) + "\n\t"
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: validDocument(), Value: value})
if err != nil || result.Approved || result.ReasonCode != ReasonCode || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) {
t.Fatalf("Validate() = %#v, %v; want bounded aggregate rejection", result, err)
}
if strings.Count(result.Message, "combat_turns[0].source_refs[") != 20 || !strings.Contains(result.Message, "source_refs[19]") || !strings.Contains(result.Message, "additional issue(s) omitted") || !strings.Contains(result.Message, "…") {
t.Fatalf("message = %q, want all bounded aggregate diagnostics", result.Message)
}
}
func TestSpecRegisterOptionsAndPolicy(t *testing.T) {
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy {
t.Fatalf("CheckpointFingerprints() = %#v, want source-reference policy", got)

View File

@@ -10,19 +10,26 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
)
const Key = "extract/dnd/spells/shape"
const ReasonCode = "invalid_spell_shape"
const (
Key = "extract/dnd/spells/shape"
ReasonCode = "invalid_spell_shape"
policy = "dnd.spells.validator.shape.v1"
)
type Options struct{}
type Validator struct{}
var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
func New(Options) *Validator { return &Validator{} }
func (v *Validator) Name() string { return Key }
func (v *Validator) ExecutionClass() contracts.ExecutionClass {
return contracts.ExecutionClassDeterministic
}
func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
}
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SpellList]) (contracts.ValidationResult, error) {
if err := Validate(req.Value); err != nil {
return rejection(err.Error()), nil

View File

@@ -48,7 +48,13 @@ func TestValidatorRejectsMissingRequiredSpellFields(t *testing.T) {
}
}
func TestSpecAndRegister(t *testing.T) {
func TestValidatorSpecCheckpointAndRegistration(t *testing.T) {
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy {
t.Fatalf("CheckpointFingerprints() = %#v, want local policy", got)
}
if spec := Spec(); spec.Key != Key || spec.ExecutionClass != contracts.ExecutionClassDeterministic {
t.Fatalf("Spec() = %#v, want deterministic shape validator", spec)
}
registry := pipeline.NewValidatorRegistry()
if err := Register(registry); err != nil {
t.Fatalf("Register() error = %v, want nil", err)

View File

@@ -8,33 +8,45 @@ 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"
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
)
const Key = "extract/dnd/spells/source_refs"
const ReasonCode = "invalid_source_refs"
const (
Key = "extract/dnd/spells/source_refs"
ReasonCode = "invalid_source_refs"
policy = "dnd.spells.validator.source_refs.v1"
)
type Options struct{}
type Validator struct{}
var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
func New(Options) *Validator { return &Validator{} }
func (v *Validator) Name() string { return Key }
func (v *Validator) ExecutionClass() contracts.ExecutionClass {
return contracts.ExecutionClassDeterministic
}
func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
}
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SpellList]) (contracts.ValidationResult, error) {
if err := spellshape.Validate(req.Value); err != nil {
return rejection(err.Error()), nil
return contracts.ValidationResult{Approved: true}, nil
}
issues := make([]string, 0)
for spellIndex, spell := range req.Value.SpellCasts {
for refIndex, ref := range spell.SourceRefs {
if err := source.ValidateRef(req.Source, ref); err != nil {
return rejection(fmt.Sprintf("spell_casts[%d].source_refs[%d]: %v", spellIndex, refIndex, err)), nil
issues = append(issues, fmt.Sprintf("spell_casts[%d].source_refs[%d]: %s", spellIndex, refIndex, diagnostics.Truncate(err.Error())))
}
}
}
if len(issues) > 0 {
return rejection(diagnostics.Aggregate("invalid spell source references", issues)), nil
}
return contracts.ValidationResult{Approved: true}, nil
}
func Spec() pipeline.ValidatorSpec {

View File

@@ -2,7 +2,9 @@ package sourcerefs
import (
"context"
"strings"
"testing"
"unicode/utf8"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
@@ -46,7 +48,37 @@ func TestValidatorRejectsMissingSourceDocument(t *testing.T) {
}
}
func TestSpecAndRegister(t *testing.T) {
func TestValidatorDefersMalformedShape(t *testing.T) {
value := dnd.SpellList{SpellCasts: []dnd.SpellCast{{Spell: "Cure Wounds"}}}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.SpellList]{Source: validDocument(), Value: value})
if err != nil || !result.Approved || result.ReasonCode != "" || result.Message != "" {
t.Fatalf("Validate() = %#v, %v; want shape deferral", result, err)
}
}
func TestValidatorAggregatesAndBoundsInvalidSourceReferences(t *testing.T) {
value := requestWithValue(validDocument(), source.SourceRef{SourceID: "session", StartUnitID: 1, EndUnitID: 1}).Value
value.SpellCasts[0].SourceRefs = make([]source.SourceRef, 24)
for index := range value.SpellCasts[0].SourceRefs {
value.SpellCasts[0].SourceRefs[index] = source.SourceRef{SourceID: "session", StartUnitID: 99 + index, EndUnitID: 99 + index}
}
value.SpellCasts[0].SourceRefs[0].SourceID = strings.Repeat("火", 220) + "\n\t"
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.SpellList]{Source: validDocument(), Value: value})
if err != nil || result.Approved || result.ReasonCode != ReasonCode || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) {
t.Fatalf("Validate() = %#v, %v; want bounded aggregate rejection", result, err)
}
if strings.Count(result.Message, "spell_casts[") != 20 || !strings.Contains(result.Message, "source_refs[19]") || !strings.Contains(result.Message, "additional issue(s) omitted") || !strings.Contains(result.Message, "…") {
t.Fatalf("message = %q, want all bounded aggregate diagnostics", result.Message)
}
}
func TestValidatorSpecCheckpointAndRegistration(t *testing.T) {
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy {
t.Fatalf("CheckpointFingerprints() = %#v, want local policy", got)
}
if spec := Spec(); spec.Key != Key || spec.ExecutionClass != contracts.ExecutionClassDeterministic {
t.Fatalf("Spec() = %#v, want deterministic source-reference validator", spec)
}
registry := pipeline.NewValidatorRegistry()
if err := Register(registry); err != nil {
t.Fatalf("Register() error = %v, want nil", err)

View File

@@ -12,19 +12,26 @@ import (
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
)
const Key = "extract/dnd/spells/source_relatedness"
const WarningReasonCode = "spell_not_near_source"
const (
Key = "extract/dnd/spells/source_relatedness"
WarningReasonCode = "spell_not_near_source"
policy = "dnd.spells.validator.source_relatedness.v1"
)
type Options struct{}
type Validator struct{}
var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
func New(Options) *Validator { return &Validator{} }
func (v *Validator) Name() string { return Key }
func (v *Validator) ExecutionClass() contracts.ExecutionClass {
return contracts.ExecutionClassDeterministic
}
func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
}
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SpellList]) (contracts.ValidationResult, error) {
if err := spellshape.Validate(req.Value); err != nil {
return contracts.ValidationResult{Approved: true}, nil

View File

@@ -90,7 +90,13 @@ func TestValidatorApprovesEmptySpellListWithoutWarning(t *testing.T) {
}
}
func TestSpecAndRegister(t *testing.T) {
func TestValidatorSpecCheckpointAndRegistration(t *testing.T) {
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy {
t.Fatalf("CheckpointFingerprints() = %#v, want local policy", got)
}
if spec := Spec(); spec.Key != Key || spec.ExecutionClass != contracts.ExecutionClassDeterministic {
t.Fatalf("Spec() = %#v, want deterministic relatedness validator", spec)
}
registry := pipeline.NewValidatorRegistry()
if err := Register(registry); err != nil {
t.Fatalf("Register() error = %v, want nil", err)