Add NPC interaction validators

This commit is contained in:
2026-07-23 13:35:15 +00:00
parent 2b9d2eaeaa
commit cb7f145c76
10 changed files with 1137 additions and 0 deletions

View File

@@ -0,0 +1,80 @@
// Package sourcerefs validates D&D NPC interaction transcript evidence.
package sourcerefs
import (
"context"
"fmt"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
interactionshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/shape"
)
const (
Key = "extract/dnd/npc-interactions/source_refs"
ReasonCode = "invalid_npc_interaction_source_refs"
policy = "dnd.npc_interactions.validator.source_refs.v1"
)
type Options struct{}
type Validator struct{}
var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*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.NPCInteractionList]) (contracts.ValidationResult, error) {
if interactionshape.Validate(req.Value) != nil {
return contracts.ValidationResult{Approved: true}, nil
}
issues := make([]string, 0)
for interactionIndex, interaction := range req.Value.Interactions {
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())))
}
}
}
if len(issues) == 0 {
return contracts.ValidationResult{Approved: true}, nil
}
return contracts.ValidationResult{
Approved: false,
ReasonCode: ReasonCode,
Message: diagnostics.Aggregate("invalid NPC interaction source references", issues),
}, nil
}
func Spec() pipeline.ValidatorSpec {
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
}
func Register(registry *pipeline.ValidatorRegistry) error {
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCInteractionListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCInteractionList], error) {
options, err := DecodeOptions(request.Options)
if err != nil {
return nil, err
}
return New(options), nil
})
}
func DecodeOptions(options map[string]any) (Options, error) {
if err := pipeline.RejectUnknownOptions(options); err != nil {
return Options{}, err
}
return Options{}, nil
}
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }

View File

@@ -0,0 +1,66 @@
package sourcerefs
import (
"context"
"reflect"
"strings"
"testing"
"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"
)
func TestValidatorOwnsCurrentSourceUnitAndRangeValidation(t *testing.T) {
value := validList()
result, err := New(Options{}).Validate(context.Background(), request(document(), value))
if err != nil || !result.Approved {
t.Fatalf("valid result = %#v, %v", result, err)
}
value.Interactions[0].SourceRefs = []source.SourceRef{
{SourceID: "foreign", StartUnitID: 1, EndUnitID: 1},
{SourceID: "session", StartUnitID: 99, EndUnitID: 99},
{SourceID: "session", StartUnitID: 2, EndUnitID: 1},
}
result, err = New(Options{}).Validate(context.Background(), request(document(), value))
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "interactions[0].source_refs[0]") {
t.Fatalf("invalid result = %#v, %v", result, 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))
if err != nil || !result.Approved {
t.Fatalf("shape deferral = %#v, %v", result, err)
}
value := validList()
before := value
_, err = New(Options{}).Validate(context.Background(), request(document(), value))
if err != nil || !reflect.DeepEqual(value, before) {
t.Fatalf("Validate() mutated value: %#v", value)
}
registry := pipeline.NewValidatorRegistry()
if err := Register(registry); err != nil {
t.Fatal(err)
}
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Value != policy {
t.Fatalf("CheckpointFingerprints() = %#v", got)
}
}
func request(doc *source.SourceDocument, value dnd.NPCInteractionList) contracts.TypedValidationRequest[dnd.NPCInteractionList] {
return contracts.TypedValidationRequest[dnd.NPCInteractionList]{Source: doc, Value: value}
}
func document() *source.SourceDocument {
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}}}
}
func validList() dnd.NPCInteractionList {
return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{
Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue,
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 2}},
}}}
}