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 against the immutable effective SRD and overlay catalog. It accepts normalized
canonical names and aliases without rewriting the artifact; unknown names canonical names and aliases without rewriting the artifact; unknown names
reject the complete result with bounded, stable index/name diagnostics. The reject the complete result with bounded, stable index/name diagnostics. The
source-reference validator applies generic source-reference validation to every source-reference validator defers malformed shapes, validates every cited
cited range. The relatedness validator resolves all cited ranges through the range, and reports all range defects through a bounded aggregate while
shared document-order traversal, then warns when a case-insensitive spell name preserving `invalid_source_refs`. The relatedness validator resolves all cited
is absent from the cited source text. Invalid shape or cited ranges produce no ranges through the shared document-order traversal, then warns when a
relatedness warnings; the shape and source-reference validators own those case-insensitive spell name is absent from the cited source text. Invalid shape
defects. 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 These validators are deterministic. Shape, source-reference, and relatedness
are defined in 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 [Configuration](../config.md#implemented-production-validators); their durable
payload rules are defined in the payload rules are defined in the
[artifact contract](../integrations/dnd-spell-artifacts.md). [artifact contract](../integrations/dnd-spell-artifacts.md).
@@ -374,23 +378,26 @@ payload rules are defined in the
## D&D NPC Validators ## D&D NPC Validators
NPC shape validation checks required strings, arrays, and source-reference NPC shape validation checks required strings, arrays, and source-reference
shape. The source-reference validator checks current-document identity, unit shape. The source-reference validator defers malformed shapes, checks
existence, and range ordering; source relatedness uses the shared document-order current-document identity, unit existence, and range ordering, and reports all
traversal and normalized consecutive-token matching, emitting at most one defects through bounded aggregates. Source relatedness uses the shared
bounded warning per record when neither the canonical name nor an alias occurs document-order traversal and normalized consecutive-token matching, emitting at
near its cited text. Invalid shape or cited ranges produce no relatedness most one bounded warning per record when neither the canonical name nor an
warnings. Normalize identity validation checks deterministic IDs, canonical alias occurs near its cited text. Invalid shape or cited ranges produce no
names, aliases, and cross-record ownership or canonical collisions. All are relatedness warnings. Normalize identity validation checks deterministic IDs,
deterministic and expose the policy fingerprints used by the production chains. 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 ## D&D Combat Validators
Combat shape validation owns required arrays, strings, nullable values, positive Combat shape validation owns required arrays, strings, nullable values, positive
rounds, and supported enums. Combat source-reference validation defers invalid rounds, and supported enums. Combat source-reference validation defers invalid
shape and checks source identity, unit existence, and range order. Combat shape, checks source identity, unit existence, and range order, and reports all
source-relatedness defers invalid shape or ranges, uses the shared traversal to defects through bounded aggregates. Combat source-relatedness defers invalid
combine overlapping cited units in document order, and emits at most one shape or ranges, uses the shared traversal to combine overlapping cited units
bounded advisory warning per turn for unrelated actors or declaration text. 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 Actors use normalized consecutive-token matching; declarations retain the
minimum four-rune token heuristic. The normalized-invariants minimum four-rune token heuristic. The normalized-invariants
validator owns display normalization, comparison-unique targets, canonical validator owns display normalization, comparison-unique targets, canonical

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"strings" "strings"
"testing" "testing"
"unicode/utf8"
"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"
@@ -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) { func TestSpecRegisterOptionsAndPolicy(t *testing.T) {
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy { 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) t.Fatalf("CheckpointFingerprints() = %#v, want source-reference policy", got)

View File

@@ -10,19 +10,26 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
) )
const Key = "extract/dnd/spells/shape" const (
const ReasonCode = "invalid_spell_shape" Key = "extract/dnd/spells/shape"
ReasonCode = "invalid_spell_shape"
policy = "dnd.spells.validator.shape.v1"
)
type Options struct{} type Options struct{}
type Validator struct{} type Validator struct{}
var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil) var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
func New(Options) *Validator { return &Validator{} } func New(Options) *Validator { return &Validator{} }
func (v *Validator) Name() string { return Key } func (v *Validator) Name() string { return Key }
func (v *Validator) ExecutionClass() contracts.ExecutionClass { func (v *Validator) ExecutionClass() contracts.ExecutionClass {
return contracts.ExecutionClassDeterministic 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) { func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SpellList]) (contracts.ValidationResult, error) {
if err := Validate(req.Value); err != nil { if err := Validate(req.Value); err != nil {
return rejection(err.Error()), 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() registry := pipeline.NewValidatorRegistry()
if err := Register(registry); err != nil { if err := Register(registry); err != nil {
t.Fatalf("Register() error = %v, want nil", err) 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/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"
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape" spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
) )
const Key = "extract/dnd/spells/source_refs" const (
const ReasonCode = "invalid_source_refs" Key = "extract/dnd/spells/source_refs"
ReasonCode = "invalid_source_refs"
policy = "dnd.spells.validator.source_refs.v1"
)
type Options struct{} type Options struct{}
type Validator struct{} type Validator struct{}
var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil) var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
func New(Options) *Validator { return &Validator{} } func New(Options) *Validator { return &Validator{} }
func (v *Validator) Name() string { return Key } func (v *Validator) Name() string { return Key }
func (v *Validator) ExecutionClass() contracts.ExecutionClass { func (v *Validator) ExecutionClass() contracts.ExecutionClass {
return contracts.ExecutionClassDeterministic 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) { func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SpellList]) (contracts.ValidationResult, error) {
if err := spellshape.Validate(req.Value); err != nil { 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 spellIndex, spell := range req.Value.SpellCasts {
for refIndex, ref := range spell.SourceRefs { for refIndex, ref := range spell.SourceRefs {
if err := source.ValidateRef(req.Source, ref); err != nil { 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 return contracts.ValidationResult{Approved: true}, nil
} }
func Spec() pipeline.ValidatorSpec { func Spec() pipeline.ValidatorSpec {

View File

@@ -2,7 +2,9 @@ package sourcerefs
import ( import (
"context" "context"
"strings"
"testing" "testing"
"unicode/utf8"
"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"
@@ -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() registry := pipeline.NewValidatorRegistry()
if err := Register(registry); err != nil { if err := Register(registry); err != nil {
t.Fatalf("Register() error = %v, want nil", err) 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" spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
) )
const Key = "extract/dnd/spells/source_relatedness" const (
const WarningReasonCode = "spell_not_near_source" Key = "extract/dnd/spells/source_relatedness"
WarningReasonCode = "spell_not_near_source"
policy = "dnd.spells.validator.source_relatedness.v1"
)
type Options struct{} type Options struct{}
type Validator struct{} type Validator struct{}
var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil) var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
func New(Options) *Validator { return &Validator{} } func New(Options) *Validator { return &Validator{} }
func (v *Validator) Name() string { return Key } func (v *Validator) Name() string { return Key }
func (v *Validator) ExecutionClass() contracts.ExecutionClass { func (v *Validator) ExecutionClass() contracts.ExecutionClass {
return contracts.ExecutionClassDeterministic 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) { func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SpellList]) (contracts.ValidationResult, error) {
if err := spellshape.Validate(req.Value); err != nil { if err := spellshape.Validate(req.Value); err != nil {
return contracts.ValidationResult{Approved: true}, 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() registry := pipeline.NewValidatorRegistry()
if err := Register(registry); err != nil { if err := Register(registry); err != nil {
t.Fatalf("Register() error = %v, want nil", err) t.Fatalf("Register() error = %v, want nil", err)