Adopt registry-backed item occurrences

This commit is contained in:
2026-08-05 19:55:34 +00:00
parent f91237e9c0
commit 3dfefd0e14
46 changed files with 996 additions and 1034 deletions

View File

@@ -24,7 +24,7 @@ const (
type Options struct{}
type Validator struct{}
var _ contracts.TypedValidator[dnd.ItemEventList] = (*Validator)(nil)
var _ contracts.TypedValidator[dnd.ItemOccurrenceList] = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
func New(Options) *Validator { return &Validator{} }
@@ -36,7 +36,7 @@ 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) {
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemOccurrenceList]) (contracts.ValidationResult, error) {
if err := Validate(req.Source, req.Value); err != nil {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error()}, nil
}
@@ -45,7 +45,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
// Validate checks only invariants introduced by item-event normalization.
// Shape and source-reference failures remain owned by their earlier validators.
func Validate(doc *source.SourceDocument, value dnd.ItemEventList) error {
func Validate(doc *source.SourceDocument, value dnd.ItemOccurrenceList) error {
if itemeventshape.Validate(value) != nil {
return nil
}
@@ -57,14 +57,14 @@ func Validate(doc *source.SourceDocument, value dnd.ItemEventList) error {
if len(issues) == 0 {
return nil
}
return fmt.Errorf("%s", diagnostics.Aggregate("invalid normalized item event invariants", issues))
return fmt.Errorf("%s", diagnostics.Aggregate("invalid normalized item occurrence invariants", issues))
}
func issuesFor(order shared.SourceRefOrder, value dnd.ItemEventList) []string {
func issuesFor(order shared.SourceRefOrder, value dnd.ItemOccurrenceList) []string {
issues := make([]string, 0)
seenIdentity := make(map[string]int)
for eventIndex, event := range value.Events {
prefix := fmt.Sprintf("events[%d]", eventIndex)
for eventIndex, event := range value.Occurrences {
prefix := fmt.Sprintf("occurrences[%d]", eventIndex)
if event.Name != itemeventmodel.DisplayValue(event.Name) {
issues = append(issues, prefix+".name is not display-normalized")
}
@@ -85,21 +85,21 @@ func issuesFor(order shared.SourceRefOrder, value dnd.ItemEventList) []string {
}
key := itemeventmodel.ExactIdentity(order, event)
if previous, exists := seenIdentity[key]; exists {
issues = append(issues, fmt.Sprintf("%s duplicates item event %d under normalized identity", prefix, previous))
issues = append(issues, fmt.Sprintf("%s duplicates item occurrence %d under normalized identity", prefix, previous))
} else {
seenIdentity[key] = eventIndex
}
}
for eventIndex := 1; eventIndex < len(value.Events); eventIndex++ {
if itemeventmodel.Less(order, value.Events[eventIndex], value.Events[eventIndex-1]) {
issues = append(issues, fmt.Sprintf("events[%d] is out of canonical order", eventIndex))
for eventIndex := 1; eventIndex < len(value.Occurrences); eventIndex++ {
if itemeventmodel.Less(order, value.Occurrences[eventIndex], value.Occurrences[eventIndex-1]) {
issues = append(issues, fmt.Sprintf("occurrences[%d] is out of canonical order", eventIndex))
}
}
return issues
}
func sourceRefsValid(index source.DocumentIndex, value dnd.ItemEventList) bool {
for _, event := range value.Events {
func sourceRefsValid(index source.DocumentIndex, value dnd.ItemOccurrenceList) bool {
for _, event := range value.Occurrences {
if !itemeventmodel.ValidSourceRefs(index, event.SourceRefs) {
return false
}
@@ -112,7 +112,7 @@ func Spec() pipeline.ValidatorSpec {
}
func Register(registry *pipeline.ValidatorRegistry) error {
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemEventList], error) {
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemOccurrenceList], error) {
options, err := DecodeOptions(request.Options)
if err != nil {
return nil, err

View File

@@ -11,20 +11,11 @@ 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"
normalizeitemevents "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents"
)
func TestValidatorApprovesNormalizerOutput(t *testing.T) {
func TestValidatorApprovesNormalizedOutput(t *testing.T) {
doc := invariantDocument()
input := dnd.ItemEventList{Events: []dnd.ItemEvent{
{Name: " Coin ", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
{Name: "Arrow", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
}}
normalized, err := normalizeitemevents.New(normalizeitemevents.Options{}).Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.ItemEventList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.ItemEventList]{Value: input}})
if err != nil {
t.Fatal(err)
}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: doc, Value: normalized.Value})
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: doc, Value: normalizedList()})
if err != nil || !result.Approved {
t.Fatalf("Validate() = %#v, %v; want approval", result, err)
}
@@ -33,26 +24,30 @@ func TestValidatorApprovesNormalizerOutput(t *testing.T) {
func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) {
tests := []struct {
name string
mutate func(*dnd.ItemEventList)
mutate func(*dnd.ItemOccurrenceList)
want string
}{
{name: "name whitespace", mutate: func(value *dnd.ItemEventList) { value.Events[0].Name = " Coin " }, want: ".name is not display-normalized"},
{name: "from whitespace", mutate: func(value *dnd.ItemEventList) {
value.Events[0].Kind = dnd.ItemEventKindLost
value.Events[0].From = " Aria "
{name: "name whitespace", mutate: func(value *dnd.ItemOccurrenceList) { value.Occurrences[0].Name = " Coin " }, want: ".name is not display-normalized"},
{name: "from whitespace", mutate: func(value *dnd.ItemOccurrenceList) {
value.Occurrences[0].Kind = dnd.ItemOccurrenceKindLost
value.Occurrences[0].From = " Aria "
}, want: ".from is not display-normalized"},
{name: "to whitespace", mutate: func(value *dnd.ItemEventList) {
value.Events[0].Kind = dnd.ItemEventKindAcquired
value.Events[0].To = " Aria "
{name: "to whitespace", mutate: func(value *dnd.ItemOccurrenceList) {
value.Occurrences[0].Kind = dnd.ItemOccurrenceKindAcquired
value.Occurrences[0].To = " Aria "
}, want: ".to is not display-normalized"},
{name: "reference order", mutate: func(value *dnd.ItemEventList) {
value.Events[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}}
{name: "reference order", mutate: func(value *dnd.ItemOccurrenceList) {
value.Occurrences[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}}
}, want: "not in canonical order"},
{name: "duplicate reference", mutate: func(value *dnd.ItemEventList) {
value.Events[0].SourceRefs = append(value.Events[0].SourceRefs, value.Events[0].SourceRefs[0])
{name: "duplicate reference", mutate: func(value *dnd.ItemOccurrenceList) {
value.Occurrences[0].SourceRefs = append(value.Occurrences[0].SourceRefs, value.Occurrences[0].SourceRefs[0])
}, want: "duplicates the previous reference"},
{name: "list order", mutate: func(value *dnd.ItemEventList) { value.Events = []dnd.ItemEvent{value.Events[1], value.Events[0]} }, want: "out of canonical order"},
{name: "duplicate event", mutate: func(value *dnd.ItemEventList) { value.Events = append(value.Events, value.Events[0]) }, want: "duplicates item event"},
{name: "list order", mutate: func(value *dnd.ItemOccurrenceList) {
value.Occurrences = []dnd.ItemOccurrence{value.Occurrences[1], value.Occurrences[0]}
}, want: "out of canonical order"},
{name: "duplicate event", mutate: func(value *dnd.ItemOccurrenceList) {
value.Occurrences = append(value.Occurrences, value.Occurrences[0])
}, want: "duplicates item occurrence"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
@@ -62,7 +57,7 @@ func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("Validate() error = %v, want %q", err, test.want)
}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: invariantDocument(), Value: value})
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: invariantDocument(), Value: value})
if err != nil || result.Approved || result.ReasonCode != ReasonCode {
t.Fatalf("validator result = %#v, %v", result, err)
}
@@ -72,20 +67,20 @@ func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) {
func TestValidateUsesSourceDocumentOrderAndDefersEarlierFailures(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}}
value := dnd.ItemEventList{Events: []dnd.ItemEvent{{
Name: "Coin", Kind: dnd.ItemEventKindDiscovered,
value := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{
ItemID: "item", Name: "Coin", Kind: dnd.ItemOccurrenceKindDiscovered,
SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}},
}}}
if err := Validate(doc, value); err == nil || !strings.Contains(err.Error(), "not in canonical order") {
t.Fatalf("Validate() error = %v, want document-order rejection", err)
}
shapeInvalid := normalizedList()
shapeInvalid.Events[0].Name = " "
shapeInvalid.Occurrences[0].Name = " "
if err := Validate(invariantDocument(), shapeInvalid); err != nil {
t.Fatalf("shape failure must be deferred, got %v", err)
}
sourceInvalid := normalizedList()
sourceInvalid.Events[0].SourceRefs[0].StartUnitID = 999
sourceInvalid.Occurrences[0].SourceRefs[0].StartUnitID = 999
if err := Validate(invariantDocument(), sourceInvalid); err != nil {
t.Fatalf("source-reference failure must be deferred, got %v", err)
}
@@ -93,12 +88,12 @@ func TestValidateUsesSourceDocumentOrderAndDefersEarlierFailures(t *testing.T) {
func TestValidatorContractAndBoundedDiagnostics(t *testing.T) {
value := normalizedList()
value.Events = make([]dnd.ItemEvent, 30)
for index := range value.Events {
value.Events[index] = normalizedList().Events[0]
value.Events[index].Name = strings.Repeat("火", 300)
value.Occurrences = make([]dnd.ItemOccurrence, 30)
for index := range value.Occurrences {
value.Occurrences[index] = normalizedList().Occurrences[0]
value.Occurrences[index].Name = strings.Repeat("火", 300)
}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: invariantDocument(), Value: value})
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: invariantDocument(), Value: value})
if err != nil || result.Approved || !utf8.ValidString(result.Message) || len([]byte(result.Message)) > 4096 || !strings.Contains(result.Message, "additional issue(s) omitted") {
t.Fatalf("bounded result = %#v, %v", result, err)
}
@@ -117,10 +112,9 @@ func TestValidatorContractAndBoundedDiagnostics(t *testing.T) {
}
}
func normalizedList() dnd.ItemEventList {
return dnd.ItemEventList{Events: []dnd.ItemEvent{
{Name: "Arrow", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}},
{Name: "Coin", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}},
func normalizedList() dnd.ItemOccurrenceList {
return dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "Arrow", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}},
{ItemID: "item", Name: "Coin", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}},
}}
}

View File

@@ -0,0 +1,119 @@
// Package registry validates item occurrence identity against item grounding.
package registry
import (
"context"
"fmt"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
itemregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/registry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
occurrenceshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/shape"
)
const (
Key = "extract/dnd/item-events/registry"
ReasonCode = "invalid_item_occurrence_registry"
policy = "dnd.item_occurrences.validator.registry.v1"
)
type Options struct{}
type Validator struct{ itemResolver *itemregistry.Resolver }
var _ contracts.TypedValidator[dnd.ItemOccurrenceList] = (*Validator)(nil)
var _ contracts.ManifestMetadataProvider = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
func New(_ Options, references ...contracts.ReferenceSet) (*Validator, error) {
if len(references) > 1 {
return nil, fmt.Errorf("item occurrence registry validator accepts at most one reference set")
}
var referenceSet contracts.ReferenceSet
if len(references) == 1 {
referenceSet = references[0]
}
resolver, err := itemregistry.NewResolver(referenceSet)
if err != nil {
return nil, fmt.Errorf("prepare item registry: %w", err)
}
return &Validator{itemResolver: resolver}, nil
}
func (v *Validator) Name() string { return Key }
func (v *Validator) ExecutionClass() contracts.ExecutionClass {
return contracts.ExecutionClassDeterministic
}
func (v *Validator) ManifestMetadata() map[string]any {
if v == nil || v.itemResolver == nil {
return nil
}
metadata := map[string]any{"policy": policy}
seeded := v.itemResolver.Seeded()
if seeded.Bound() {
metadata["item_registry_digest"] = seeded.Digest()
metadata["item_count"] = seeded.Count()
}
return metadata
}
func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
if v == nil || v.itemResolver == nil {
return nil
}
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}, {Name: "item_registry", Value: v.itemResolver.Seeded().ProjectionDigest()}}
}
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemOccurrenceList]) (contracts.ValidationResult, error) {
if occurrenceshape.Validate(req.Value) != nil {
return contracts.ValidationResult{Approved: true}, nil
}
if v == nil || v.itemResolver == nil {
return contracts.ValidationResult{}, fmt.Errorf("item occurrence registry validator must not be nil")
}
registry, err := v.itemResolver.Resolve(req.References)
if err != nil {
return contracts.ValidationResult{}, fmt.Errorf("resolve item registry: %w", err)
}
if !registry.Bound() {
return rejection([]string{"item registry reference is required"}), nil
}
issues := make([]string, 0)
for index, occurrence := range req.Value.Occurrences {
item, ok := registry.LookupID(occurrence.ItemID)
if !ok {
issues = append(issues, fmt.Sprintf("occurrences[%d].item_id is not in the item registry: %s", index, diagnostics.Quote(occurrence.ItemID)))
continue
}
if occurrence.Name != item.Name {
issues = append(issues, fmt.Sprintf("occurrences[%d] does not match registry item %s", index, diagnostics.Quote(occurrence.ItemID)))
}
}
if len(issues) == 0 {
return contracts.ValidationResult{Approved: true}, nil
}
return rejection(issues), nil
}
func rejection(issues []string) contracts.ValidationResult {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid item occurrence registry", issues)}
}
func Spec() pipeline.ValidatorSpec {
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
}
func Register(registry *pipeline.ValidatorRegistry) error {
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemOccurrenceList], error) {
options, err := DecodeOptions(request.Options)
if err != nil {
return nil, err
}
return New(options, request.References)
})
}
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,37 @@
package registry
import (
"context"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
itemcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemregistry"
itemidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity"
itemregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/registry"
)
func TestValidatorRejectsUnknownAndMismatchedItemPairs(t *testing.T) {
id := itemidentity.DeriveID("Torch")
content, err := itemcodec.New().Encode(dnd.ItemRegistry{Items: []dnd.Item{{ID: id, Name: "Torch", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}})
if err != nil {
t.Fatal(err)
}
references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{itemregistry.ReferenceSlot: {Slot: contracts.ReferenceSlot{Name: itemregistry.ReferenceSlot}, Items: []contracts.ReferenceItem{{SlotName: itemregistry.ReferenceSlot, Content: content, MediaType: "application/json", ArtifactKind: dnd.ItemRegistryKind}}}}}
validator, err := New(Options{}, references)
if err != nil {
t.Fatal(err)
}
base := dnd.ItemOccurrence{ItemID: id, Name: "Torch", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}
for _, occurrence := range []dnd.ItemOccurrence{{ItemID: "unknown", Name: "Unknown", Kind: base.Kind, SourceRefs: base.SourceRefs}, {ItemID: id, Name: "Lantern", Kind: base.Kind, SourceRefs: base.SourceRefs}} {
result, err := validator.Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{References: references, Value: dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{occurrence}}})
if err != nil || result.Approved || result.ReasonCode != ReasonCode {
t.Fatalf("Validate() = %#v, %v", result, err)
}
}
result, err := validator.Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{References: references, Value: dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{base}}})
if err != nil || !result.Approved {
t.Fatalf("Validate() = %#v, %v", result, err)
}
}

View File

@@ -22,7 +22,7 @@ const (
type Options struct{}
type Validator struct{}
var _ contracts.TypedValidator[dnd.ItemEventList] = (*Validator)(nil)
var _ contracts.TypedValidator[dnd.ItemOccurrenceList] = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
func New(Options) *Validator { return &Validator{} }
@@ -34,7 +34,7 @@ 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) {
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemOccurrenceList]) (contracts.ValidationResult, error) {
if err := Validate(req.Value); err != nil {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error()}, nil
}
@@ -42,21 +42,24 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
}
// Validate returns one bounded error for every owned item-event shape issue.
func Validate(value dnd.ItemEventList) error {
func Validate(value dnd.ItemOccurrenceList) error {
issues := issuesFor(value)
if len(issues) == 0 {
return nil
}
return fmt.Errorf("%s", diagnostics.Aggregate("invalid item event shape", issues))
return fmt.Errorf("%s", diagnostics.Aggregate("invalid item occurrence shape", issues))
}
func issuesFor(value dnd.ItemEventList) []string {
if value.Events == nil {
return []string{"events must be present"}
func issuesFor(value dnd.ItemOccurrenceList) []string {
if value.Occurrences == nil {
return []string{"occurrences must be present"}
}
issues := make([]string, 0)
for index, event := range value.Events {
prefix := fmt.Sprintf("events[%d]", index)
for index, event := range value.Occurrences {
prefix := fmt.Sprintf("occurrences[%d]", index)
if strings.TrimSpace(event.ItemID) == "" {
issues = append(issues, prefix+".item_id must not be empty: "+diagnostics.Quote(event.ItemID))
}
if strings.TrimSpace(event.Name) == "" {
issues = append(issues, prefix+".name must not be empty: "+diagnostics.Quote(event.Name))
}
@@ -80,7 +83,7 @@ func Spec() pipeline.ValidatorSpec {
}
func Register(registry *pipeline.ValidatorRegistry) error {
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemEventList], error) {
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemOccurrenceList], error) {
options, err := DecodeOptions(request.Options)
if err != nil {
return nil, err

View File

@@ -15,18 +15,17 @@ import (
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)},
value := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: " Hidden Cache ", Kind: dnd.ItemOccurrenceKindDiscovered, From: " ", To: " ", SourceRefs: refs(1, 1)},
{ItemID: "item", Name: "Gold Pieces", Kind: dnd.ItemOccurrenceKindAcquired, Quantity: &quantity, To: " party ", SourceRefs: refs(2, 2)},
{ItemID: "item", Name: "Torch", Kind: dnd.ItemOccurrenceKindLost, From: " party ", SourceRefs: refs(3, 3)},
{ItemID: "item", Name: "Potion", Kind: dnd.ItemOccurrenceKindConsumed, From: " Aria ", SourceRefs: refs(4, 4)},
{ItemID: "item", Name: "Moonblade", Kind: dnd.ItemOccurrenceKindTransferred, From: "Aria", To: "Borin", SourceRefs: refs(5, 5)},
}}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Value: value})
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{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{}}})
empty, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Value: dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{}}})
if err != nil || !empty.Approved {
t.Fatalf("empty list result = %#v, %v", empty, err)
}
@@ -38,47 +37,47 @@ func TestValidatorRejectsOwnedSemanticBoundaries(t *testing.T) {
negative := -1
tests := []struct {
name string
value dnd.ItemEventList
value dnd.ItemOccurrenceList
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"},
{"missing events", dnd.ItemOccurrenceList{}, "occurrences must be present"},
{"blank name", listWith(dnd.ItemOccurrence{ItemID: "item", Name: " \t", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: refs(1, 1)}), "name must not be empty"},
{"unsupported kind", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: "unknown", SourceRefs: refs(1, 1)}), "kind is unsupported"},
{"discovered holder", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindDiscovered, From: "Aria", SourceRefs: refs(1, 1)}), "incompatible"},
{"acquired without holder", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindAcquired, SourceRefs: refs(1, 1)}), "incompatible"},
{"lost destination", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindLost, From: "Aria", To: "Borin", SourceRefs: refs(1, 1)}), "incompatible"},
{"consumed without holder", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindConsumed, SourceRefs: refs(1, 1)}), "incompatible"},
{"party transfer from", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindTransferred, From: "party", To: "Borin", SourceRefs: refs(1, 1)}), "incompatible"},
{"party transfer to", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindTransferred, From: "Aria", To: " PARTY ", SourceRefs: refs(1, 1)}), "incompatible"},
{"case equivalent transfer", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindTransferred, From: "Aria", To: "aria", SourceRefs: refs(1, 1)}), "incompatible"},
{"unicode equivalent transfer", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindTransferred, From: "Åria", To: "Åria", SourceRefs: refs(1, 1)}), "incompatible"},
{"zero quantity", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindAcquired, Quantity: &zero, To: "party", SourceRefs: refs(1, 1)}), "quantity must be positive"},
{"negative quantity", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindAcquired, Quantity: &negative, To: "party", SourceRefs: refs(1, 1)}), "quantity must be positive"},
{"missing source refs", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindDiscovered}), "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})
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{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 {
if result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{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"}
value := dnd.ItemOccurrenceList{Occurrences: make([]dnd.ItemOccurrence, 24)}
for index := range value.Occurrences {
value.Occurrences[index] = dnd.ItemOccurrence{ItemID: "item", 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") {
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Value: value})
if err != nil || result.Approved || result.ReasonCode != ReasonCode || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) || !strings.Contains(result.Message, "occurrences[0]") || !strings.Contains(result.Message, "additional issue(s) omitted") {
t.Fatalf("Validate() = %#v, %v", result, err)
}
if value.Events[0].Name != " \n" {
if value.Occurrences[0].Name != " \n" {
t.Fatal("Validate() mutated input")
}
if got := New(Options{}).CheckpointFingerprints(); !reflect.DeepEqual(got, []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}) {
@@ -96,12 +95,12 @@ func TestValidatorAggregatesBoundedIndexedDiagnosticsAndRegistration(t *testing.
}
}
func validList() dnd.ItemEventList {
return dnd.ItemEventList{Events: []dnd.ItemEvent{{Name: "Ring", Kind: dnd.ItemEventKindAcquired, To: "party", SourceRefs: refs(1, 1)}}}
func validList() dnd.ItemOccurrenceList {
return dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindAcquired, To: "party", SourceRefs: refs(1, 1)}}}
}
func listWith(event dnd.ItemEvent) dnd.ItemEventList {
return dnd.ItemEventList{Events: []dnd.ItemEvent{event}}
func listWith(event dnd.ItemOccurrence) dnd.ItemOccurrenceList {
return dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{event}}
}
func refs(start, end int) []source.SourceRef {

View File

@@ -22,7 +22,7 @@ const (
type Options struct{}
type Validator struct{}
var _ contracts.TypedValidator[dnd.ItemEventList] = (*Validator)(nil)
var _ contracts.TypedValidator[dnd.ItemOccurrenceList] = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
func New(Options) *Validator { return &Validator{} }
@@ -34,9 +34,9 @@ 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) {
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemOccurrenceList]) (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")
return contracts.ValidationResult{}, fmt.Errorf("item occurrence source-reference validator requires the current extraction chunk")
}
if itemeventshape.Validate(req.Value) != nil {
return contracts.ValidationResult{Approved: true}, nil
@@ -47,21 +47,21 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
coverage = newChunkCoverage(req.Chunk)
}
issues := make([]string, 0)
for eventIndex, event := range req.Value.Events {
for eventIndex, event := range req.Value.Occurrences {
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())))
issues = append(issues, fmt.Sprintf("occurrences[%d].source_refs[%d]: %s", eventIndex, refIndex, diagnostics.Truncate(err.Error())))
continue
}
if coverage != nil && !coverage.contains(req.Source, ref) {
issues = append(issues, fmt.Sprintf("events[%d].source_refs[%d]: source reference is outside the current extraction chunk", eventIndex, refIndex))
issues = append(issues, fmt.Sprintf("occurrences[%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
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid item occurrence source references", issues)}, nil
}
type chunkCoverage struct {
@@ -102,7 +102,7 @@ func Spec() pipeline.ValidatorSpec {
}
func Register(registry *pipeline.ValidatorRegistry) error {
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemEventList], error) {
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemOccurrenceList], error) {
options, err := DecodeOptions(request.Options)
if err != nil {
return nil, err

View File

@@ -19,7 +19,7 @@ func TestValidatorOwnsSourceAndRangeValidation(t *testing.T) {
if err != nil || !result.Approved {
t.Fatalf("valid result = %#v, %v", result, err)
}
value.Events[0].SourceRefs = []source.SourceRef{
value.Occurrences[0].SourceRefs = []source.SourceRef{
{SourceID: "foreign", StartUnitID: 1, EndUnitID: 1},
{SourceID: "session", StartUnitID: 99, EndUnitID: 99},
{SourceID: "session", StartUnitID: 2, EndUnitID: 1},
@@ -28,7 +28,7 @@ func TestValidatorOwnsSourceAndRangeValidation(t *testing.T) {
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]"} {
for _, index := range []string{"occurrences[0].source_refs[0]", "occurrences[0].source_refs[1]", "occurrences[0].source_refs[2]"} {
if !strings.Contains(result.Message, index) {
t.Fatalf("validation message = %q, missing %q", result.Message, index)
}
@@ -39,40 +39,40 @@ 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})
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{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})
crossesChunk.Occurrences[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 2, EndUnitID: 3}}
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{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)
}
noncontiguousChunk := &source.Chunk{ID: "chunk-1", SourceID: doc.ID, Units: []source.SourceUnit{doc.Units[0], doc.Units[2]}}
spansMissingUnit := validList()
spansMissingUnit.Events[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 3}}
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: noncontiguousChunk, Value: spansMissingUnit})
spansMissingUnit.Occurrences[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 3}}
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: noncontiguousChunk, Value: spansMissingUnit})
if err != nil || result.Approved || !strings.Contains(result.Message, "outside the current extraction chunk") {
t.Fatalf("partially contained 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})
multiRange.Occurrences[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.ItemOccurrenceList]{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()})
_, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{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"}}}
malformed := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", 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)
@@ -80,12 +80,12 @@ func TestValidatorRequiresChunkDuringExtractionAndDefersShape(t *testing.T) {
}
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}}}
value := dnd.ItemOccurrenceList{Occurrences: make([]dnd.ItemOccurrence, 24)}
for index := range value.Occurrences {
value.Occurrences[index] = dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindDiscovered, 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") {
if err != nil || result.Approved || result.ReasonCode != ReasonCode || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) || !strings.Contains(result.Message, "occurrences[0].source_refs[0]") || !strings.Contains(result.Message, "additional issue(s) omitted") {
t.Fatalf("Validate() = %#v, %v", result, err)
}
before := validList()
@@ -108,14 +108,14 @@ func TestValidatorBoundsDiagnosticsAndRegistration(t *testing.T) {
}
}
func request(doc *source.SourceDocument, value dnd.ItemEventList) contracts.TypedValidationRequest[dnd.ItemEventList] {
return contracts.TypedValidationRequest[dnd.ItemEventList]{Source: doc, Value: value}
func request(doc *source.SourceDocument, value dnd.ItemOccurrenceList) contracts.TypedValidationRequest[dnd.ItemOccurrenceList] {
return contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{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}}}}}
func validList() dnd.ItemOccurrenceList {
return dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindAcquired, To: "party", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 2}}}}}
}

View File

@@ -23,7 +23,7 @@ const (
type Options struct{}
type Validator struct{}
var _ contracts.TypedValidator[dnd.ItemEventList] = (*Validator)(nil)
var _ contracts.TypedValidator[dnd.ItemOccurrenceList] = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
func New(Options) *Validator { return &Validator{} }
@@ -37,7 +37,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
// Validate performs advisory source grounding only. Shape and range failures
// are intentionally left to their blocking owners.
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemEventList]) (contracts.ValidationResult, error) {
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemOccurrenceList]) (contracts.ValidationResult, error) {
if itemeventshape.Validate(req.Value) != nil {
return contracts.ValidationResult{Approved: true}, nil
}
@@ -47,15 +47,15 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
}
warnings := make([]contracts.Warning, 0)
for index, event := range req.Value.Events {
for index, event := range req.Value.Occurrences {
citedText, err := resolver.CitedText(event.SourceRefs)
if err != nil || shared.ContainsTokenSequence(citedText, event.Name) {
continue
}
warnings = append(warnings, contracts.Warning{
Scope: fmt.Sprintf("events[%d]", index),
Scope: fmt.Sprintf("occurrences[%d]", index),
ReasonCode: WarningReasonCode,
Message: fmt.Sprintf("item event name %s was not found in cited source text", diagnostics.Quote(event.Name)),
Message: fmt.Sprintf("item occurrence name %s was not found in cited source text", diagnostics.Quote(event.Name)),
})
}
return contracts.ValidationResult{
@@ -69,7 +69,7 @@ func Spec() pipeline.ValidatorSpec {
}
func Register(registry *pipeline.ValidatorRegistry) error {
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemEventList], error) {
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemOccurrenceList], error) {
options, err := DecodeOptions(request.Options)
if err != nil {
return nil, err

View File

@@ -19,29 +19,28 @@ func TestValidatorMatchesTokenSequencesAcrossRanges(t *testing.T) {
{ID: 2, Text: "of healing in the chest."},
{ID: 3, Text: "The group rests."},
}}
value := dnd.ItemEventList{Events: []dnd.ItemEvent{
{Name: "POTION OF HEALING", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}, {SourceID: doc.ID, StartUnitID: 2, EndUnitID: 2}}},
{Name: "missing-item", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 3, EndUnitID: 3}}},
value := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "POTION OF HEALING", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}, {SourceID: doc.ID, StartUnitID: 2, EndUnitID: 2}}},
{ItemID: "item", Name: "missing-item", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 3, EndUnitID: 3}}},
}}
references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
"glossary": {Items: []contracts.ReferenceItem{{Content: []byte("missing-item")}}},
}}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: doc, References: references, Value: value})
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: doc, References: references, Value: value})
if err != nil || !result.Approved || len(result.Warnings) != 1 {
t.Fatalf("Validate() = %#v, %v", result, err)
}
if warning := result.Warnings[0]; warning.Scope != "events[1]" || warning.ReasonCode != WarningReasonCode || !strings.Contains(warning.Message, "missing-item") {
if warning := result.Warnings[0]; warning.Scope != "occurrences[1]" || warning.ReasonCode != WarningReasonCode || !strings.Contains(warning.Message, "missing-item") {
t.Fatalf("warning = %#v", warning)
}
}
func TestValidatorDefersMalformedAndUnreadableEvidence(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "a potion"}}}
for _, value := range []dnd.ItemEventList{
{Events: []dnd.ItemEvent{{Name: "", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}}}},
{Events: []dnd.ItemEvent{{Name: "potion", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 99, EndUnitID: 99}}}}},
for _, value := range []dnd.ItemOccurrenceList{
{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}}}},
{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "potion", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 99, EndUnitID: 99}}}}},
} {
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: doc, Value: value})
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: doc, Value: value})
if err != nil || !result.Approved || len(result.Warnings) != 0 {
t.Fatalf("deferred result = %#v, %v", result, err)
}
@@ -50,13 +49,13 @@ func TestValidatorDefersMalformedAndUnreadableEvidence(t *testing.T) {
func TestValidatorBoundsWarningsAndRegistersPolicy(t *testing.T) {
count := diagnostics.MaxWarnings + 5
events := make([]dnd.ItemEvent, count)
events := make([]dnd.ItemOccurrence, count)
for index := range events {
events[index] = dnd.ItemEvent{Name: "missing item", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}
events[index] = dnd.ItemOccurrence{ItemID: "item", Name: "missing item", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}
}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{
Source: &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "nothing useful"}}},
Value: dnd.ItemEventList{Events: events},
Value: dnd.ItemOccurrenceList{Occurrences: events},
})
if err != nil || !result.Approved || len(result.Warnings) != diagnostics.MaxWarnings || result.Warnings[len(result.Warnings)-1].ReasonCode != OmittedReasonCode {
t.Fatalf("Validate() = %#v, %v", result, err)