From da83510234559ad5ce1423f71bf80086de4c4c4f Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 25 Jul 2026 21:52:59 +0000 Subject: [PATCH] Add D&D item event validators --- .../validate/itemevents/shape/validator.go | 99 +++++++++++++++ .../itemevents/shape/validator_test.go | 109 +++++++++++++++++ .../itemevents/source_refs/validator.go | 112 +++++++++++++++++ .../itemevents/source_refs/validator_test.go | 113 ++++++++++++++++++ 4 files changed, 433 insertions(+) create mode 100644 internal/modules/dnd/validate/itemevents/shape/validator.go create mode 100644 internal/modules/dnd/validate/itemevents/shape/validator_test.go create mode 100644 internal/modules/dnd/validate/itemevents/source_refs/validator.go create mode 100644 internal/modules/dnd/validate/itemevents/source_refs/validator_test.go diff --git a/internal/modules/dnd/validate/itemevents/shape/validator.go b/internal/modules/dnd/validate/itemevents/shape/validator.go new file mode 100644 index 0000000..8cafb1d --- /dev/null +++ b/internal/modules/dnd/validate/itemevents/shape/validator.go @@ -0,0 +1,99 @@ +// Package shape validates required D&D item-event candidate fields. +package shape + +import ( + "context" + "fmt" + "strings" + + "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/itemevents" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" +) + +const ( + Key = "extract/dnd/item-events/shape" + ReasonCode = "invalid_item_event_shape" + policy = "dnd.item_events.shape.v1" +) + +type Options struct{} +type Validator struct{} + +var _ contracts.TypedValidator[dnd.ItemEventList] = (*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.ItemEventList]) (contracts.ValidationResult, error) { + if err := Validate(req.Value); err != nil { + return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error()}, nil + } + return contracts.ValidationResult{Approved: true}, nil +} + +// Validate returns one bounded error for every owned item-event shape issue. +func Validate(value dnd.ItemEventList) error { + issues := issuesFor(value) + if len(issues) == 0 { + return nil + } + return fmt.Errorf("%s", diagnostics.Aggregate("invalid item event shape", issues)) +} + +func issuesFor(value dnd.ItemEventList) []string { + if value.Events == nil { + return []string{"events must be present"} + } + issues := make([]string, 0) + for index, event := range value.Events { + prefix := fmt.Sprintf("events[%d]", index) + if strings.TrimSpace(event.Name) == "" { + issues = append(issues, prefix+".name must not be empty: "+diagnostics.Quote(event.Name)) + } + if !itemevents.SupportedKind(event.Kind) { + issues = append(issues, prefix+".kind is unsupported: "+diagnostics.Quote(string(event.Kind))) + } else if !itemevents.ValidHolderCombination(event.Kind, event.From, event.To) { + issues = append(issues, prefix+".from and .to are incompatible with "+diagnostics.Quote(string(event.Kind))) + } + if event.Quantity != nil && *event.Quantity < 1 { + issues = append(issues, prefix+".quantity must be positive when present") + } + if len(event.SourceRefs) == 0 { + issues = append(issues, prefix+".source_refs must contain at least one reference") + } + } + return issues +} + +func Spec() pipeline.ValidatorSpec { + return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} +} + +func Register(registry *pipeline.ValidatorRegistry) error { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemEventList], 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 } diff --git a/internal/modules/dnd/validate/itemevents/shape/validator_test.go b/internal/modules/dnd/validate/itemevents/shape/validator_test.go new file mode 100644 index 0000000..15b26c2 --- /dev/null +++ b/internal/modules/dnd/validate/itemevents/shape/validator_test.go @@ -0,0 +1,109 @@ +package shape + +import ( + "context" + "reflect" + "strings" + "testing" + "unicode/utf8" + + "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 TestValidatorAcceptsEveryEventRuleAndNormalizableWhitespace(t *testing.T) { + quantity := 1 + value := dnd.ItemEventList{Events: []dnd.ItemEvent{ + {Name: " Hidden Cache ", Kind: dnd.ItemEventKindDiscovered, From: " ", To: " ", SourceRefs: refs(1, 1)}, + {Name: "Gold Pieces", Kind: dnd.ItemEventKindAcquired, Quantity: &quantity, To: " party ", SourceRefs: refs(2, 2)}, + {Name: "Torch", Kind: dnd.ItemEventKindLost, From: " party ", SourceRefs: refs(3, 3)}, + {Name: "Potion", Kind: dnd.ItemEventKindConsumed, From: " Aria ", SourceRefs: refs(4, 4)}, + {Name: "Moonblade", Kind: dnd.ItemEventKindTransferred, From: "Aria", To: "Borin", SourceRefs: refs(5, 5)}, + }} + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Value: value}) + if err != nil || !result.Approved { + t.Fatalf("Validate() = %#v, %v", result, err) + } + empty, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Value: dnd.ItemEventList{Events: []dnd.ItemEvent{}}}) + if err != nil || !empty.Approved { + t.Fatalf("empty list result = %#v, %v", empty, err) + } +} + +func TestValidatorRejectsOwnedSemanticBoundaries(t *testing.T) { + valid := validList() + zero := 0 + negative := -1 + tests := []struct { + name string + value dnd.ItemEventList + want string + }{ + {"missing events", dnd.ItemEventList{}, "events must be present"}, + {"blank name", listWith(dnd.ItemEvent{Name: " \t", Kind: dnd.ItemEventKindDiscovered, SourceRefs: refs(1, 1)}), "name must not be empty"}, + {"unsupported kind", listWith(dnd.ItemEvent{Name: "Ring", Kind: "unknown", SourceRefs: refs(1, 1)}), "kind is unsupported"}, + {"discovered holder", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindDiscovered, From: "Aria", SourceRefs: refs(1, 1)}), "incompatible"}, + {"acquired without holder", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindAcquired, SourceRefs: refs(1, 1)}), "incompatible"}, + {"lost destination", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindLost, From: "Aria", To: "Borin", SourceRefs: refs(1, 1)}), "incompatible"}, + {"consumed without holder", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindConsumed, SourceRefs: refs(1, 1)}), "incompatible"}, + {"party transfer from", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindTransferred, From: "party", To: "Borin", SourceRefs: refs(1, 1)}), "incompatible"}, + {"party transfer to", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindTransferred, From: "Aria", To: " PARTY ", SourceRefs: refs(1, 1)}), "incompatible"}, + {"case equivalent transfer", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindTransferred, From: "Aria", To: "aria", SourceRefs: refs(1, 1)}), "incompatible"}, + {"unicode equivalent transfer", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindTransferred, From: "Åria", To: "Åria", SourceRefs: refs(1, 1)}), "incompatible"}, + {"zero quantity", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindAcquired, Quantity: &zero, To: "party", SourceRefs: refs(1, 1)}), "quantity must be positive"}, + {"negative quantity", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindAcquired, Quantity: &negative, To: "party", SourceRefs: refs(1, 1)}), "quantity must be positive"}, + {"missing source refs", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindDiscovered}), "source_refs must contain"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Value: test.value}) + if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, test.want) { + t.Fatalf("Validate() = %#v, %v; want %q", result, err, test.want) + } + }) + } + if result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Value: valid}); err != nil || !result.Approved { + t.Fatalf("valid result = %#v, %v", result, err) + } +} + +func TestValidatorAggregatesBoundedIndexedDiagnosticsAndRegistration(t *testing.T) { + value := dnd.ItemEventList{Events: make([]dnd.ItemEvent, 24)} + for index := range value.Events { + value.Events[index] = dnd.ItemEvent{Name: " \n", Kind: "unsupported"} + } + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Value: value}) + if err != nil || result.Approved || result.ReasonCode != ReasonCode || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) || !strings.Contains(result.Message, "events[0]") || !strings.Contains(result.Message, "additional issue(s) omitted") { + t.Fatalf("Validate() = %#v, %v", result, err) + } + if value.Events[0].Name != " \n" { + t.Fatal("Validate() mutated input") + } + if got := New(Options{}).CheckpointFingerprints(); !reflect.DeepEqual(got, []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}) { + t.Fatalf("CheckpointFingerprints() = %#v", got) + } + registry := pipeline.NewValidatorRegistry() + if err := Register(registry); err != nil { + t.Fatal(err) + } + if got, ok := registry.Spec(Key); !ok || !reflect.DeepEqual(got, Spec()) { + t.Fatalf("registry spec = %#v, %t", got, ok) + } + if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil { + t.Fatal("DecodeOptions() accepted unknown option") + } +} + +func validList() dnd.ItemEventList { + return dnd.ItemEventList{Events: []dnd.ItemEvent{{Name: "Ring", Kind: dnd.ItemEventKindAcquired, To: "party", SourceRefs: refs(1, 1)}}} +} + +func listWith(event dnd.ItemEvent) dnd.ItemEventList { + return dnd.ItemEventList{Events: []dnd.ItemEvent{event}} +} + +func refs(start, end int) []source.SourceRef { + return []source.SourceRef{{SourceID: "session", StartUnitID: start, EndUnitID: end}} +} diff --git a/internal/modules/dnd/validate/itemevents/source_refs/validator.go b/internal/modules/dnd/validate/itemevents/source_refs/validator.go new file mode 100644 index 0000000..b3f427d --- /dev/null +++ b/internal/modules/dnd/validate/itemevents/source_refs/validator.go @@ -0,0 +1,112 @@ +// Package sourcerefs validates D&D item-event 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/itemevents" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" + itemeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/shape" +) + +const ( + Key = "extract/dnd/item-events/source_refs" + ReasonCode = "invalid_item_event_source_references" + policy = "dnd.item_events.source_refs.v1" +) + +type Options struct{} +type Validator struct{} + +var _ contracts.TypedValidator[dnd.ItemEventList] = (*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.ItemEventList]) (contracts.ValidationResult, error) { + if req.Stage == string(pipeline.StageExtract) && req.Chunk == nil { + return contracts.ValidationResult{}, fmt.Errorf("item event source-reference validator requires the current extraction chunk") + } + if itemeventshape.Validate(req.Value) != nil { + return contracts.ValidationResult{Approved: true}, nil + } + index := source.NewDocumentIndex(req.Source) + issues := make([]string, 0) + for eventIndex, event := range req.Value.Events { + if itemevents.ValidSourceRefs(index, event.SourceRefs) { + if req.Stage != string(pipeline.StageExtract) || refsFitChunk(req.Chunk, event.SourceRefs) { + continue + } + } + for refIndex, ref := range event.SourceRefs { + if err := index.ValidateRef(ref); err != nil { + issues = append(issues, fmt.Sprintf("events[%d].source_refs[%d]: %s", eventIndex, refIndex, diagnostics.Truncate(err.Error()))) + continue + } + if req.Stage == string(pipeline.StageExtract) && !chunkContainsRef(req.Chunk, ref) { + issues = append(issues, fmt.Sprintf("events[%d].source_refs[%d]: source reference is outside the current extraction chunk", eventIndex, refIndex)) + } + } + } + if len(issues) == 0 { + return contracts.ValidationResult{Approved: true}, nil + } + return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid item event source references", issues)}, nil +} + +func refsFitChunk(chunk *source.Chunk, refs []source.SourceRef) bool { + for _, ref := range refs { + if !chunkContainsRef(chunk, ref) { + return false + } + } + return true +} + +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} +} + +func Register(registry *pipeline.ValidatorRegistry) error { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemEventList], 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 } diff --git a/internal/modules/dnd/validate/itemevents/source_refs/validator_test.go b/internal/modules/dnd/validate/itemevents/source_refs/validator_test.go new file mode 100644 index 0000000..f6439f4 --- /dev/null +++ b/internal/modules/dnd/validate/itemevents/source_refs/validator_test.go @@ -0,0 +1,113 @@ +package sourcerefs + +import ( + "context" + "reflect" + "strings" + "testing" + "unicode/utf8" + + "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 TestValidatorOwnsSourceAndRangeValidation(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.Events[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 { + t.Fatalf("invalid result = %#v, %v", result, err) + } + for _, index := range []string{"events[0].source_refs[0]", "events[0].source_refs[1]", "events[0].source_refs[2]"} { + if !strings.Contains(result.Message, index) { + t.Fatalf("validation message = %q, missing %q", result.Message, index) + } + } +} + +func TestValidatorEnforcesChunkOnlyDuringExtraction(t *testing.T) { + doc := document() + chunk := &source.Chunk{ID: "chunk-0", SourceID: doc.ID, Units: append([]source.SourceUnit(nil), doc.Units[:2]...)} + contained := validList() + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: chunk, Value: contained}) + if err != nil || !result.Approved { + t.Fatalf("contained evidence = %#v, %v", result, err) + } + + crossesChunk := validList() + crossesChunk.Events[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 2, EndUnitID: 3}} + result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: chunk, Value: crossesChunk}) + if err != nil || result.Approved || !strings.Contains(result.Message, "outside the current extraction chunk") { + t.Fatalf("out-of-chunk evidence = %#v, %v", result, err) + } + + multiRange := validList() + multiRange.Events[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 2}, {SourceID: doc.ID, StartUnitID: 3, EndUnitID: 4}} + result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: doc, Value: multiRange}) + if err != nil || !result.Approved { + t.Fatalf("post-merge evidence = %#v, %v", result, err) + } +} + +func TestValidatorRequiresChunkDuringExtractionAndDefersShape(t *testing.T) { + _, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{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) + } + malformed := dnd.ItemEventList{Events: []dnd.ItemEvent{{Name: "Ring"}}} + result, err := New(Options{}).Validate(context.Background(), request(document(), malformed)) + if err != nil || !result.Approved { + t.Fatalf("shape deferral = %#v, %v", result, err) + } +} + +func TestValidatorBoundsDiagnosticsAndRegistration(t *testing.T) { + value := dnd.ItemEventList{Events: make([]dnd.ItemEvent, 24)} + for index := range value.Events { + value.Events[index] = dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "foreign", StartUnitID: index + 1, EndUnitID: index + 1}}} + } + result, err := New(Options{}).Validate(context.Background(), request(document(), value)) + if err != nil || result.Approved || result.ReasonCode != ReasonCode || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) || !strings.Contains(result.Message, "events[0].source_refs[0]") || !strings.Contains(result.Message, "additional issue(s) omitted") { + t.Fatalf("Validate() = %#v, %v", result, err) + } + before := validList() + copy := before + if _, err := New(Options{}).Validate(context.Background(), request(document(), before)); err != nil || !reflect.DeepEqual(before, copy) { + t.Fatal("Validate() mutated input") + } + if got := New(Options{}).CheckpointFingerprints(); !reflect.DeepEqual(got, []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}) { + t.Fatalf("CheckpointFingerprints() = %#v", got) + } + registry := pipeline.NewValidatorRegistry() + if err := Register(registry); err != nil { + t.Fatal(err) + } + if got, ok := registry.Spec(Key); !ok || !reflect.DeepEqual(got, Spec()) { + t.Fatalf("registry spec = %#v, %t", got, ok) + } + if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil { + t.Fatal("DecodeOptions() accepted unknown option") + } +} + +func request(doc *source.SourceDocument, value dnd.ItemEventList) contracts.TypedValidationRequest[dnd.ItemEventList] { + return contracts.TypedValidationRequest[dnd.ItemEventList]{Source: doc, Value: value} +} + +func document() *source.SourceDocument { + return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}, {ID: 3}, {ID: 4}}} +} + +func validList() dnd.ItemEventList { + return dnd.ItemEventList{Events: []dnd.ItemEvent{{Name: "Ring", Kind: dnd.ItemEventKindAcquired, To: "party", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 2}}}}} +}