From a168c13b8591d558c00ba5f41a059aab5cd5eb8d Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 4 Aug 2026 00:33:39 +0000 Subject: [PATCH] Add D&D location occurrence validators --- .../invariants/validator.go | 182 ++++++++++++++++++ .../invariants/validator_test.go | 77 ++++++++ .../locationoccurrences/registry/validator.go | 120 ++++++++++++ .../registry/validator_test.go | 109 +++++++++++ .../locationoccurrences/shape/validator.go | 107 ++++++++++ .../shape/validator_test.go | 62 ++++++ .../source_refs/validator.go | 92 +++++++++ .../source_refs/validator_test.go | 66 +++++++ .../source_relatedness/validator.go | 81 ++++++++ .../source_relatedness/validator_test.go | 78 ++++++++ .../locations/source_refs/validator.go | 22 ++- .../locations/source_refs/validator_test.go | 24 +++ .../locations/source_relatedness/validator.go | 5 +- .../source_relatedness/validator_test.go | 12 ++ 14 files changed, 1034 insertions(+), 3 deletions(-) create mode 100644 internal/modules/dnd/validate/locationoccurrences/invariants/validator.go create mode 100644 internal/modules/dnd/validate/locationoccurrences/invariants/validator_test.go create mode 100644 internal/modules/dnd/validate/locationoccurrences/registry/validator.go create mode 100644 internal/modules/dnd/validate/locationoccurrences/registry/validator_test.go create mode 100644 internal/modules/dnd/validate/locationoccurrences/shape/validator.go create mode 100644 internal/modules/dnd/validate/locationoccurrences/shape/validator_test.go create mode 100644 internal/modules/dnd/validate/locationoccurrences/source_refs/validator.go create mode 100644 internal/modules/dnd/validate/locationoccurrences/source_refs/validator_test.go create mode 100644 internal/modules/dnd/validate/locationoccurrences/source_relatedness/validator.go create mode 100644 internal/modules/dnd/validate/locationoccurrences/source_relatedness/validator_test.go diff --git a/internal/modules/dnd/validate/locationoccurrences/invariants/validator.go b/internal/modules/dnd/validate/locationoccurrences/invariants/validator.go new file mode 100644 index 0000000..b8a2fc1 --- /dev/null +++ b/internal/modules/dnd/validate/locationoccurrences/invariants/validator.go @@ -0,0 +1,182 @@ +// Package invariants validates normalized D&D location occurrence artifacts. +package invariants + +import ( + "context" + "fmt" + "sort" + "strconv" + "strings" + + "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" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" + occurrenceshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/locationoccurrences/shape" +) + +const ( + Key = "normalize/dnd/location-occurrences/invariants" + ReasonCode = "invalid_location_occurrence_normalization" + policy = "dnd.location_occurrences.validator.normalized.v1" +) + +type Options struct{} +type Validator struct{} + +var _ contracts.TypedValidator[dnd.LocationOccurrenceList] = (*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.LocationOccurrenceList]) (contracts.ValidationResult, error) { + if occurrenceshape.Validate(req.Value) != nil { + return contracts.ValidationResult{Approved: true}, nil + } + index := source.NewDocumentIndex(req.Source) + if !allSourceRefsValid(index, req.Value) { + return contracts.ValidationResult{Approved: true}, nil + } + issues := issuesFor(shared.NewSourceRefOrderFromIndex(index), req.Value) + if len(issues) == 0 { + return contracts.ValidationResult{Approved: true}, nil + } + return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid location occurrence normalization", issues)}, nil +} + +func allSourceRefsValid(index source.DocumentIndex, value dnd.LocationOccurrenceList) bool { + for _, occurrence := range value.Occurrences { + for _, ref := range occurrence.SourceRefs { + if index.ValidateRef(ref) != nil { + return false + } + } + } + return true +} + +func issuesFor(order shared.SourceRefOrder, value dnd.LocationOccurrenceList) []string { + issues := make([]string, 0) + for index, occurrence := range value.Occurrences { + prefix := fmt.Sprintf("occurrences[%d]", index) + for refIndex := 1; refIndex < len(occurrence.SourceRefs); refIndex++ { + previous, current := occurrence.SourceRefs[refIndex-1], occurrence.SourceRefs[refIndex] + if order.Less(current, previous) { + issues = append(issues, fmt.Sprintf("%s.source_refs are not in canonical order at index %d", prefix, refIndex)) + } else if current == previous { + issues = append(issues, fmt.Sprintf("%s.source_refs[%d] duplicates the previous reference", prefix, refIndex)) + } + } + } + if !sort.SliceIsSorted(value.Occurrences, func(left, right int) bool { + return lessOccurrence(order, value.Occurrences[left], value.Occurrences[right]) + }) { + issues = append(issues, "occurrences are not in canonical order") + } + seen := make(map[string]int) + for index, occurrence := range value.Occurrences { + key := exactIdentity(occurrence) + if previous, ok := seen[key]; ok { + issues = append(issues, fmt.Sprintf("occurrences[%d] duplicates occurrence %d", index, previous)) + continue + } + seen[key] = index + } + return issues +} + +func lessOccurrence(order shared.SourceRefOrder, left, right dnd.LocationOccurrence) bool { + leftPosition, leftHasEvidence := order.EarliestValid(left.SourceRefs) + rightPosition, rightHasEvidence := order.EarliestValid(right.SourceRefs) + if leftHasEvidence != rightHasEvidence { + return leftHasEvidence + } + if leftHasEvidence && leftPosition != rightPosition { + return leftPosition < rightPosition + } + if left.LocationID != right.LocationID { + return left.LocationID < right.LocationID + } + if left.Name != right.Name { + return left.Name < right.Name + } + if kindOrder(left.Kind) != kindOrder(right.Kind) { + return kindOrder(left.Kind) < kindOrder(right.Kind) + } + if left.Kind != right.Kind { + return left.Kind < right.Kind + } + return sourceRefsLess(order, left.SourceRefs, right.SourceRefs) +} +func kindOrder(kind dnd.LocationOccurrenceKind) int { + switch kind { + case dnd.LocationOccurrenceKindVisited: + return 0 + case dnd.LocationOccurrenceKindPlanned: + return 1 + case dnd.LocationOccurrenceKindRecalled: + return 2 + case dnd.LocationOccurrenceKindMentioned: + return 3 + default: + return 4 + } +} +func sourceRefsLess(order shared.SourceRefOrder, left, right []source.SourceRef) bool { + for index := 0; index < len(left) && index < len(right); index++ { + if left[index] != right[index] { + return order.Less(left[index], right[index]) + } + } + return len(left) < len(right) +} +func exactIdentity(occurrence dnd.LocationOccurrence) string { + var key strings.Builder + writeKeyString(&key, occurrence.LocationID) + writeKeyString(&key, occurrence.Name) + writeKeyString(&key, string(occurrence.Kind)) + for _, ref := range occurrence.SourceRefs { + writeKeyString(&key, ref.SourceID) + writeKeyInt(&key, ref.StartUnitID) + writeKeyInt(&key, ref.EndUnitID) + } + return key.String() +} +func writeKeyString(builder *strings.Builder, value string) { + builder.WriteString(strconv.Itoa(len(value))) + builder.WriteByte(':') + builder.WriteString(value) +} +func writeKeyInt(builder *strings.Builder, value int) { + builder.WriteString(strconv.Itoa(value)) + builder.WriteByte(';') +} + +func Spec() pipeline.ValidatorSpec { + return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} +} +func Register(registry *pipeline.ValidatorRegistry) error { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.LocationOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.LocationOccurrenceList], 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/locationoccurrences/invariants/validator_test.go b/internal/modules/dnd/validate/locationoccurrences/invariants/validator_test.go new file mode 100644 index 0000000..00fe8f5 --- /dev/null +++ b/internal/modules/dnd/validate/locationoccurrences/invariants/validator_test.go @@ -0,0 +1,77 @@ +package invariants + +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 TestValidatorApprovesCanonicalOrderForEveryKind(t *testing.T) { + kinds := []dnd.LocationOccurrenceKind{dnd.LocationOccurrenceKindVisited, dnd.LocationOccurrenceKindPlanned, dnd.LocationOccurrenceKindRecalled, dnd.LocationOccurrenceKindMentioned} + occurrences := make([]dnd.LocationOccurrence, len(kinds)) + for index, kind := range kinds { + occurrences[index] = occurrence(kind, 1) + } + result, err := New(Options{}).Validate(context.Background(), request(dnd.LocationOccurrenceList{Occurrences: occurrences})) + if err != nil || !result.Approved { + t.Fatalf("Validate() = %#v, %v", result, err) + } +} + +func TestValidatorRejectsOrderingAndExactDuplicateInvariants(t *testing.T) { + for _, test := range []struct { + name string + value dnd.LocationOccurrenceList + want string + }{ + {"references", dnd.LocationOccurrenceList{Occurrences: []dnd.LocationOccurrence{{LocationID: "id", Name: "Moon Gate", Kind: dnd.LocationOccurrenceKindVisited, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}, {SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}}, "not in canonical order"}, + {"list", dnd.LocationOccurrenceList{Occurrences: []dnd.LocationOccurrence{occurrence(dnd.LocationOccurrenceKindVisited, 2), occurrence(dnd.LocationOccurrenceKindVisited, 1)}}, "occurrences are not in canonical order"}, + {"duplicate", dnd.LocationOccurrenceList{Occurrences: []dnd.LocationOccurrence{occurrence(dnd.LocationOccurrenceKindVisited, 1), occurrence(dnd.LocationOccurrenceKindVisited, 1)}}, "duplicates occurrence"}, + } { + t.Run(test.name, func(t *testing.T) { + result, err := New(Options{}).Validate(context.Background(), request(test.value)) + if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, test.want) { + t.Fatalf("Validate() = %#v, %v", result, err) + } + }) + } +} + +func TestValidatorDefersInvalidEvidenceAndDoesNotMutate(t *testing.T) { + value := dnd.LocationOccurrenceList{Occurrences: []dnd.LocationOccurrence{{LocationID: "id", Name: "Moon Gate", Kind: dnd.LocationOccurrenceKindVisited, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}} + before := cloneList(value) + result, err := New(Options{}).Validate(context.Background(), request(value)) + if err != nil || !result.Approved || !reflect.DeepEqual(value, before) { + t.Fatalf("invalid evidence deferral = %#v, %v", result, err) + } + 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("fingerprints = %#v", got) + } +} + +func request(value dnd.LocationOccurrenceList) contracts.TypedValidationRequest[dnd.LocationOccurrenceList] { + return contracts.TypedValidationRequest[dnd.LocationOccurrenceList]{Source: document(), Value: value} +} +func document() *source.SourceDocument { + return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}}} +} +func occurrence(kind dnd.LocationOccurrenceKind, unit int) dnd.LocationOccurrence { + return dnd.LocationOccurrence{LocationID: "id", Name: "Moon Gate", Kind: kind, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: unit, EndUnitID: unit}}} +} +func cloneList(value dnd.LocationOccurrenceList) dnd.LocationOccurrenceList { + cloned := dnd.LocationOccurrenceList{Occurrences: append([]dnd.LocationOccurrence(nil), value.Occurrences...)} + for index := range cloned.Occurrences { + cloned.Occurrences[index].SourceRefs = append([]source.SourceRef(nil), value.Occurrences[index].SourceRefs...) + } + return cloned +} diff --git a/internal/modules/dnd/validate/locationoccurrences/registry/validator.go b/internal/modules/dnd/validate/locationoccurrences/registry/validator.go new file mode 100644 index 0000000..dc4a3ae --- /dev/null +++ b/internal/modules/dnd/validate/locationoccurrences/registry/validator.go @@ -0,0 +1,120 @@ +// Package registry validates location occurrence identity against location 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" + locationregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/registry" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" + occurrenceshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/locationoccurrences/shape" +) + +const ( + Key = "extract/dnd/location-occurrences/registry" + ReasonCode = "invalid_location_occurrence_registry" + policy = "dnd.location_occurrences.validator.registry.v1" +) + +type Options struct{} +type Validator struct{ locationResolver *locationregistry.Resolver } + +var _ contracts.TypedValidator[dnd.LocationOccurrenceList] = (*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("location occurrence registry validator accepts at most one reference set") + } + var referenceSet contracts.ReferenceSet + if len(references) == 1 { + referenceSet = references[0] + } + resolver, err := locationregistry.NewResolver(referenceSet) + if err != nil { + return nil, fmt.Errorf("prepare location registry: %w", err) + } + return &Validator{locationResolver: 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.locationResolver == nil { + return nil + } + metadata := map[string]any{"policy": policy} + seeded := v.locationResolver.Seeded() + if seeded.Bound() { + metadata["location_registry_digest"] = seeded.Digest() + metadata["location_count"] = seeded.Count() + } + return metadata +} +func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint { + if v == nil || v.locationResolver == nil { + return nil + } + return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}, {Name: "location_registry", Value: v.locationResolver.Seeded().ProjectionDigest()}} +} + +func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.LocationOccurrenceList]) (contracts.ValidationResult, error) { + if occurrenceshape.Validate(req.Value) != nil { + return contracts.ValidationResult{Approved: true}, nil + } + if v == nil || v.locationResolver == nil { + return contracts.ValidationResult{}, fmt.Errorf("location occurrence registry validator must not be nil") + } + registry, err := v.locationResolver.Resolve(req.References) + if err != nil { + return contracts.ValidationResult{}, fmt.Errorf("resolve location registry: %w", err) + } + if !registry.Bound() { + return rejection([]string{"location registry reference is required"}), nil + } + issues := make([]string, 0) + for index, occurrence := range req.Value.Occurrences { + location, ok := registry.Lookup(occurrence.LocationID) + if !ok { + issues = append(issues, fmt.Sprintf("occurrences[%d].location_id is not in the location registry: %s", index, diagnostics.Quote(occurrence.LocationID))) + continue + } + if occurrence.Name != location.Name { + issues = append(issues, fmt.Sprintf("occurrences[%d] does not match registry location %s", index, diagnostics.Quote(occurrence.LocationID))) + } + } + 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 location 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.LocationOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.LocationOccurrenceList], 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 } diff --git a/internal/modules/dnd/validate/locationoccurrences/registry/validator_test.go b/internal/modules/dnd/validate/locationoccurrences/registry/validator_test.go new file mode 100644 index 0000000..0f368ee --- /dev/null +++ b/internal/modules/dnd/validate/locationoccurrences/registry/validator_test.go @@ -0,0 +1,109 @@ +package registry + +import ( + "context" + "encoding/json" + "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" + locationcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/locations" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity" + locationregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/registry" +) + +func TestValidatorDistinguishesSameNameLocationsByID(t *testing.T) { + references, first, second := registryReferences(t) + value := dnd.LocationOccurrenceList{Occurrences: []dnd.LocationOccurrence{ + occurrence(first.ID, first.Name), occurrence(second.ID, second.Name), + }} + validator := newValidator(t, references) + before := cloneList(value) + result, err := validator.Validate(context.Background(), request(references, value)) + if err != nil || !result.Approved || !reflect.DeepEqual(value, before) { + t.Fatalf("matching IDs = %#v, %v", result, err) + } + value.Occurrences[0].Name = "Other Gate" + result, err = validator.Validate(context.Background(), request(references, value)) + if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "does not match registry location") { + t.Fatalf("mismatched name = %#v, %v", result, err) + } + value.Occurrences[0].LocationID = "location:unknown" + result, err = validator.Validate(context.Background(), request(references, value)) + if err != nil || result.Approved || !strings.Contains(result.Message, "is not in the location registry") { + t.Fatalf("unknown ID = %#v, %v", result, err) + } +} + +func TestValidatorRequiresAndSafelyPreparesRegistry(t *testing.T) { + value := dnd.LocationOccurrenceList{Occurrences: []dnd.LocationOccurrence{occurrence("location:one", "Moon Gate")}} + result, err := newValidator(t).Validate(context.Background(), request(contracts.ReferenceSet{}, value)) + if err != nil || result.Approved || !strings.Contains(result.Message, "required") { + t.Fatalf("missing registry = %#v, %v", result, err) + } + malformed := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{locationregistry.ReferenceSlot: {Items: []contracts.ReferenceItem{{MediaType: "application/json", Content: []byte(`{"secret":"private"}`)}}}}} + if _, err := New(Options{}, malformed); err == nil || strings.Contains(err.Error(), "private") { + t.Fatalf("New() error = %v", err) + } + references, _, _ := registryReferences(t) + metadata, err := json.Marshal(newValidator(t, references).ManifestMetadata()) + if err != nil || strings.Contains(string(metadata), "Moon Gate") { + t.Fatalf("metadata = %s, %v", metadata, err) + } + if got := newValidator(t, references).CheckpointFingerprints(); len(got) != 2 || got[0].Value != policy || !strings.HasPrefix(got[1].Value, "sha256:") { + t.Fatalf("fingerprints = %#v", got) + } +} + +func TestValidatorRegistersAndDefersShape(t *testing.T) { + references, _, _ := registryReferences(t) + result, err := newValidator(t, references).Validate(context.Background(), request(references, dnd.LocationOccurrenceList{Occurrences: []dnd.LocationOccurrence{{Name: "Missing"}}})) + if err != nil || !result.Approved { + t.Fatalf("shape deferral = %#v, %v", result, err) + } + registry := pipeline.NewValidatorRegistry() + if err := Register(registry); err != nil { + t.Fatal(err) + } + if got, ok := registry.Spec(Key); !ok || got != Spec() || got.ExecutionClass != contracts.ExecutionClassDeterministic { + t.Fatalf("registered spec = %#v, ok = %t", got, ok) + } +} + +func newValidator(t *testing.T, references ...contracts.ReferenceSet) *Validator { + t.Helper() + validator, err := New(Options{}, references...) + if err != nil { + t.Fatal(err) + } + return validator +} +func request(references contracts.ReferenceSet, value dnd.LocationOccurrenceList) contracts.TypedValidationRequest[dnd.LocationOccurrenceList] { + return contracts.TypedValidationRequest[dnd.LocationOccurrenceList]{References: references, Value: value} +} +func occurrence(id, name string) dnd.LocationOccurrence { + return dnd.LocationOccurrence{LocationID: id, Name: name, Kind: dnd.LocationOccurrenceKindVisited, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}} +} +func cloneList(value dnd.LocationOccurrenceList) dnd.LocationOccurrenceList { + cloned := dnd.LocationOccurrenceList{Occurrences: append([]dnd.LocationOccurrence(nil), value.Occurrences...)} + for index := range cloned.Occurrences { + cloned.Occurrences[index].SourceRefs = append([]source.SourceRef(nil), value.Occurrences[index].SourceRefs...) + } + return cloned +} +func registryReferences(t *testing.T) (contracts.ReferenceSet, dnd.Location, dnd.Location) { + t.Helper() + firstRefs := []source.SourceRef{{SourceID: "source", StartUnitID: 1, EndUnitID: 1}} + secondRefs := []source.SourceRef{{SourceID: "source", StartUnitID: 2, EndUnitID: 2}} + first := dnd.Location{ID: identity.DeriveID("Moon Gate", firstRefs), Name: "Moon Gate", SourceRefs: firstRefs} + second := dnd.Location{ID: identity.DeriveID("Moon Gate", secondRefs), Name: "Moon Gate", SourceRefs: secondRefs} + content, err := locationcodec.New().Encode(dnd.LocationList{Locations: []dnd.Location{first, second}}) + if err != nil { + t.Fatal(err) + } + return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{locationregistry.ReferenceSlot: {Slot: contracts.ReferenceSlot{Name: locationregistry.ReferenceSlot}, Items: []contracts.ReferenceItem{{SlotName: locationregistry.ReferenceSlot, MediaType: locationcodec.MediaType, Content: content, Origin: contracts.ReferenceOrigin{Type: "generated"}}}}}}, first, second +} diff --git a/internal/modules/dnd/validate/locationoccurrences/shape/validator.go b/internal/modules/dnd/validate/locationoccurrences/shape/validator.go new file mode 100644 index 0000000..d80a776 --- /dev/null +++ b/internal/modules/dnd/validate/locationoccurrences/shape/validator.go @@ -0,0 +1,107 @@ +// Package shape validates required D&D location occurrence 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/shared/diagnostics" +) + +const ( + Key = "extract/dnd/location-occurrences/shape" + ReasonCode = "invalid_location_occurrence_shape" + policy = "dnd.location_occurrences.validator.shape.v1" +) + +type Options struct{} +type Validator struct{} + +var _ contracts.TypedValidator[dnd.LocationOccurrenceList] = (*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.LocationOccurrenceList]) (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 +} + +func Validate(value dnd.LocationOccurrenceList) error { + issues := issuesFor(value) + if len(issues) == 0 { + return nil + } + return fmt.Errorf("%s", diagnostics.Aggregate("invalid location occurrence shape", issues)) +} + +func issuesFor(value dnd.LocationOccurrenceList) []string { + if value.Occurrences == nil { + return []string{"occurrences must be present"} + } + issues := make([]string, 0) + for index, occurrence := range value.Occurrences { + prefix := fmt.Sprintf("occurrences[%d]", index) + if strings.TrimSpace(occurrence.LocationID) == "" { + issues = append(issues, prefix+".location_id must not be empty") + } + if strings.TrimSpace(occurrence.Name) == "" { + issues = append(issues, prefix+".name must not be empty") + } + if !validKind(occurrence.Kind) { + issues = append(issues, prefix+".kind is unsupported: "+diagnostics.Quote(string(occurrence.Kind))) + } + if len(occurrence.SourceRefs) == 0 { + issues = append(issues, prefix+".source_refs must contain at least one reference") + } + } + return issues +} + +func validKind(value dnd.LocationOccurrenceKind) bool { + switch value { + case dnd.LocationOccurrenceKindVisited, + dnd.LocationOccurrenceKindPlanned, + dnd.LocationOccurrenceKindRecalled, + dnd.LocationOccurrenceKindMentioned: + return true + default: + return false + } +} + +func Spec() pipeline.ValidatorSpec { + return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} +} + +func Register(registry *pipeline.ValidatorRegistry) error { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.LocationOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.LocationOccurrenceList], 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/locationoccurrences/shape/validator_test.go b/internal/modules/dnd/validate/locationoccurrences/shape/validator_test.go new file mode 100644 index 0000000..328d53e --- /dev/null +++ b/internal/modules/dnd/validate/locationoccurrences/shape/validator_test.go @@ -0,0 +1,62 @@ +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 TestValidatorAcceptsEverySupportedKind(t *testing.T) { + kinds := []dnd.LocationOccurrenceKind{dnd.LocationOccurrenceKindVisited, dnd.LocationOccurrenceKindPlanned, dnd.LocationOccurrenceKindRecalled, dnd.LocationOccurrenceKindMentioned} + occurrences := make([]dnd.LocationOccurrence, len(kinds)) + for index, kind := range kinds { + occurrences[index] = occurrence(kind) + } + result, err := New(Options{}).Validate(context.Background(), request(dnd.LocationOccurrenceList{Occurrences: occurrences})) + if err != nil || !result.Approved { + t.Fatalf("Validate() = %#v, %v", result, err) + } +} + +func TestValidatorRejectsRequiredFieldsAndUnsupportedKindWithoutMutation(t *testing.T) { + value := dnd.LocationOccurrenceList{Occurrences: []dnd.LocationOccurrence{{Name: strings.Repeat("火", 220), Kind: "other", SourceRefs: []source.SourceRef{}}}} + before := value + result, err := New(Options{}).Validate(context.Background(), request(value)) + if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "location_id must not be empty") || !strings.Contains(result.Message, "kind is unsupported") { + t.Fatalf("Validate() = %#v, %v", result, err) + } + if !reflect.DeepEqual(value, before) || len(result.Message) > 4096 || !utf8.ValidString(result.Message) { + t.Fatal("validation mutated value or returned unsafe diagnostics") + } + result, err = New(Options{}).Validate(context.Background(), request(dnd.LocationOccurrenceList{})) + if err != nil || result.Approved || !strings.Contains(result.Message, "occurrences must be present") { + t.Fatalf("missing list = %#v, %v", result, err) + } +} + +func TestValidatorRegisters(t *testing.T) { + registry := pipeline.NewValidatorRegistry() + if err := Register(registry); err != nil { + t.Fatal(err) + } + if got, ok := registry.Spec(Key); !ok || got != Spec() || got.ExecutionClass != contracts.ExecutionClassDeterministic { + t.Fatalf("registered spec = %#v, ok = %t", got, ok) + } + if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Value != policy { + t.Fatalf("fingerprints = %#v", got) + } +} + +func request(value dnd.LocationOccurrenceList) contracts.TypedValidationRequest[dnd.LocationOccurrenceList] { + return contracts.TypedValidationRequest[dnd.LocationOccurrenceList]{Value: value} +} +func occurrence(kind dnd.LocationOccurrenceKind) dnd.LocationOccurrence { + return dnd.LocationOccurrence{LocationID: "location:one", Name: "Moon Gate", Kind: kind, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}} +} diff --git a/internal/modules/dnd/validate/locationoccurrences/source_refs/validator.go b/internal/modules/dnd/validate/locationoccurrences/source_refs/validator.go new file mode 100644 index 0000000..7733028 --- /dev/null +++ b/internal/modules/dnd/validate/locationoccurrences/source_refs/validator.go @@ -0,0 +1,92 @@ +// Package sourcerefs validates location occurrence 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" + occurrenceshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/locationoccurrences/shape" +) + +const ( + Key = "extract/dnd/location-occurrences/source_refs" + ReasonCode = "invalid_location_occurrence_source_refs" + policy = "dnd.location_occurrences.validator.source_refs.v1" +) + +type Options struct{} +type Validator struct{} + +var _ contracts.TypedValidator[dnd.LocationOccurrenceList] = (*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.LocationOccurrenceList]) (contracts.ValidationResult, error) { + if req.Stage == string(pipeline.StageExtract) && req.Chunk == nil { + return contracts.ValidationResult{}, fmt.Errorf("location occurrence source-reference validator requires the current extraction chunk") + } + if occurrenceshape.Validate(req.Value) != nil { + return contracts.ValidationResult{Approved: true}, nil + } + index := source.NewDocumentIndex(req.Source) + issues := make([]string, 0) + for occurrenceIndex, occurrence := range req.Value.Occurrences { + for refIndex, ref := range occurrence.SourceRefs { + if err := index.ValidateRef(ref); err != nil { + issues = append(issues, fmt.Sprintf("occurrences[%d].source_refs[%d]: %s", occurrenceIndex, refIndex, diagnostics.Truncate(err.Error()))) + continue + } + if req.Stage == string(pipeline.StageExtract) && !chunkContainsRef(req.Chunk, ref) { + issues = append(issues, fmt.Sprintf("occurrences[%d].source_refs[%d]: source reference is outside the current extraction chunk", occurrenceIndex, refIndex)) + } + } + } + if len(issues) == 0 { + return contracts.ValidationResult{Approved: true}, nil + } + return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid location occurrence source references", issues)}, nil +} + +func chunkContainsRef(chunk *source.Chunk, ref source.SourceRef) bool { + if chunk == nil || ref.SourceID != chunk.SourceID { + return false + } + startFound, endFound := false, 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.LocationOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.LocationOccurrenceList], 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/locationoccurrences/source_refs/validator_test.go b/internal/modules/dnd/validate/locationoccurrences/source_refs/validator_test.go new file mode 100644 index 0000000..3016e5d --- /dev/null +++ b/internal/modules/dnd/validate/locationoccurrences/source_refs/validator_test.go @@ -0,0 +1,66 @@ +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 TestValidatorRejectsInvalidAndOutOfChunkEvidenceWithoutMutation(t *testing.T) { + doc := document() + value := occurrenceList() + value.Occurrences[0].SourceRefs = []source.SourceRef{{SourceID: "foreign", StartUnitID: 1, EndUnitID: 1}, {SourceID: "session", StartUnitID: 2, EndUnitID: 2}} + before := cloneList(value) + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.LocationOccurrenceList]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: &source.Chunk{SourceID: "session", Units: []source.SourceUnit{{ID: 1}}}, Value: value}) + if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "occurrences[0].source_refs[0]") || !strings.Contains(result.Message, "outside the current extraction chunk") { + t.Fatalf("Validate() = %#v, %v", result, err) + } + if !reflect.DeepEqual(value, before) || len(result.Message) > 4096 || !utf8.ValidString(result.Message) { + t.Fatal("validation mutated value or returned unsafe diagnostics") + } +} + +func TestValidatorRequiresChunkDuringExtractionAndDefersShape(t *testing.T) { + _, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.LocationOccurrenceList]{Stage: string(pipeline.StageExtract), Source: document(), Value: occurrenceList()}) + if err == nil || !strings.Contains(err.Error(), "requires the current extraction chunk") { + t.Fatalf("missing chunk error = %v", err) + } + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.LocationOccurrenceList]{Source: document(), Value: dnd.LocationOccurrenceList{Occurrences: []dnd.LocationOccurrence{{Name: "Missing"}}}}) + if err != nil || !result.Approved { + t.Fatalf("shape deferral = %#v, %v", result, err) + } +} + +func TestValidatorRegisters(t *testing.T) { + registry := pipeline.NewValidatorRegistry() + if err := Register(registry); err != nil { + t.Fatal(err) + } + if got, ok := registry.Spec(Key); !ok || got != Spec() || got.ExecutionClass != contracts.ExecutionClassDeterministic { + t.Fatalf("registered spec = %#v, ok = %t", got, ok) + } + if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Value != policy { + t.Fatalf("fingerprints = %#v", got) + } +} + +func document() *source.SourceDocument { + return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}}} +} +func occurrenceList() dnd.LocationOccurrenceList { + return dnd.LocationOccurrenceList{Occurrences: []dnd.LocationOccurrence{{LocationID: "id", Name: "Moon Gate", Kind: dnd.LocationOccurrenceKindVisited, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}} +} +func cloneList(value dnd.LocationOccurrenceList) dnd.LocationOccurrenceList { + cloned := dnd.LocationOccurrenceList{Occurrences: append([]dnd.LocationOccurrence(nil), value.Occurrences...)} + for index := range cloned.Occurrences { + cloned.Occurrences[index].SourceRefs = append([]source.SourceRef(nil), value.Occurrences[index].SourceRefs...) + } + return cloned +} diff --git a/internal/modules/dnd/validate/locationoccurrences/source_relatedness/validator.go b/internal/modules/dnd/validate/locationoccurrences/source_relatedness/validator.go new file mode 100644 index 0000000..c179538 --- /dev/null +++ b/internal/modules/dnd/validate/locationoccurrences/source_relatedness/validator.go @@ -0,0 +1,81 @@ +// Package sourcerelatedness warns when cited source text does not mention a location occurrence. +package sourcerelatedness + +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" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" + occurrenceshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/locationoccurrences/shape" +) + +const ( + Key = "extract/dnd/location-occurrences/source_relatedness" + WarningReasonCode = "location_occurrence_not_near_source" + OmittedReasonCode = "location_occurrence_relatedness_warnings_omitted" + policy = "dnd.location_occurrences.validator.source_relatedness.v1" +) + +type Options struct{} +type Validator struct{} + +var _ contracts.TypedValidator[dnd.LocationOccurrenceList] = (*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.LocationOccurrenceList]) (contracts.ValidationResult, error) { + if occurrenceshape.Validate(req.Value) != nil { + return contracts.ValidationResult{Approved: true}, nil + } + resolver, err := shared.NewCitationResolver(req.Source) + if err != nil { + return contracts.ValidationResult{Approved: true}, nil + } + citedTexts := make([]string, len(req.Value.Occurrences)) + for index, occurrence := range req.Value.Occurrences { + citedText, err := resolver.CitedText(occurrence.SourceRefs) + if err != nil { + return contracts.ValidationResult{Approved: true}, nil + } + citedTexts[index] = citedText + } + warnings := make([]contracts.Warning, 0) + for index, occurrence := range req.Value.Occurrences { + if shared.ContainsTokenSequence(citedTexts[index], occurrence.Name) { + continue + } + warnings = append(warnings, contracts.Warning{Scope: fmt.Sprintf("occurrences[%d]", index), ReasonCode: WarningReasonCode, Message: fmt.Sprintf("Location occurrence name %s was not found in cited source text", diagnostics.Quote(occurrence.Name))}) + } + return contracts.ValidationResult{Approved: true, Warnings: diagnostics.LimitWarnings(warnings, "location_occurrences", OmittedReasonCode)}, nil +} +func Spec() pipeline.ValidatorSpec { + return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} +} +func Register(registry *pipeline.ValidatorRegistry) error { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.LocationOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.LocationOccurrenceList], 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/locationoccurrences/source_relatedness/validator_test.go b/internal/modules/dnd/validate/locationoccurrences/source_relatedness/validator_test.go new file mode 100644 index 0000000..3696568 --- /dev/null +++ b/internal/modules/dnd/validate/locationoccurrences/source_relatedness/validator_test.go @@ -0,0 +1,78 @@ +package sourcerelatedness + +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" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" +) + +func TestValidatorUsesOnlyCitedTranscriptEvidence(t *testing.T) { + value := occurrenceList("O'Rin Gate") + doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "The party enters o’rin gate."}, {ID: 2, Text: "No location here."}}} + result, err := New(Options{}).Validate(context.Background(), request(doc, value, contracts.ReferenceSet{})) + if err != nil || !result.Approved || len(result.Warnings) != 0 { + t.Fatalf("cited match = %#v, %v", result, err) + } + value = occurrenceList("Glossary Keep") + before := cloneList(value) + references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{"glossary": {Items: []contracts.ReferenceItem{{Content: []byte("Glossary Keep")}}}}} + result, err = New(Options{}).Validate(context.Background(), request(doc, value, references)) + if err != nil || !result.Approved || len(result.Warnings) != 1 || result.Warnings[0].ReasonCode != WarningReasonCode || !strings.Contains(result.Warnings[0].Message, "Glossary Keep") || !reflect.DeepEqual(value, before) { + t.Fatalf("reference-only match = %#v, %v", result, err) + } +} + +func TestValidatorDefersUnreadableEvidenceAndBoundsWarnings(t *testing.T) { + invalid := occurrenceList("Missing") + invalid.Occurrences[0].SourceRefs[0].StartUnitID = 99 + invalid.Occurrences[0].SourceRefs[0].EndUnitID = 99 + result, err := New(Options{}).Validate(context.Background(), request(document(), invalid, contracts.ReferenceSet{})) + if err != nil || !result.Approved || len(result.Warnings) != 0 { + t.Fatalf("unreadable evidence = %#v, %v", result, err) + } + occurrences := make([]dnd.LocationOccurrence, diagnostics.MaxWarnings+1) + for index := range occurrences { + occurrences[index] = occurrenceList("Missing").Occurrences[0] + } + result, err = New(Options{}).Validate(context.Background(), request(document(), dnd.LocationOccurrenceList{Occurrences: occurrences}, contracts.ReferenceSet{})) + if err != nil || !result.Approved || len(result.Warnings) != diagnostics.MaxWarnings || result.Warnings[len(result.Warnings)-1].ReasonCode != OmittedReasonCode { + t.Fatalf("bounded warnings = %#v, %v", result, err) + } +} + +func TestValidatorRegisters(t *testing.T) { + registry := pipeline.NewValidatorRegistry() + if err := Register(registry); err != nil { + t.Fatal(err) + } + if got, ok := registry.Spec(Key); !ok || got != Spec() || got.ExecutionClass != contracts.ExecutionClassDeterministic { + t.Fatalf("registered spec = %#v, ok = %t", got, ok) + } + if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Value != policy { + t.Fatalf("fingerprints = %#v", got) + } +} + +func request(doc *source.SourceDocument, value dnd.LocationOccurrenceList, references contracts.ReferenceSet) contracts.TypedValidationRequest[dnd.LocationOccurrenceList] { + return contracts.TypedValidationRequest[dnd.LocationOccurrenceList]{Source: doc, References: references, Value: value} +} +func document() *source.SourceDocument { + return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "The party waits."}}} +} +func occurrenceList(name string) dnd.LocationOccurrenceList { + return dnd.LocationOccurrenceList{Occurrences: []dnd.LocationOccurrence{{LocationID: "id", Name: name, Kind: dnd.LocationOccurrenceKindVisited, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}} +} +func cloneList(value dnd.LocationOccurrenceList) dnd.LocationOccurrenceList { + cloned := dnd.LocationOccurrenceList{Occurrences: append([]dnd.LocationOccurrence(nil), value.Occurrences...)} + for index := range cloned.Occurrences { + cloned.Occurrences[index].SourceRefs = append([]source.SourceRef(nil), value.Occurrences[index].SourceRefs...) + } + return cloned +} diff --git a/internal/modules/dnd/validate/locations/source_refs/validator.go b/internal/modules/dnd/validate/locations/source_refs/validator.go index 3b03a59..a5cf403 100644 --- a/internal/modules/dnd/validate/locations/source_refs/validator.go +++ b/internal/modules/dnd/validate/locations/source_refs/validator.go @@ -16,7 +16,7 @@ import ( const ( Key = "extract/dnd/locations/source_refs" ReasonCode = "invalid_location_source_refs" - policy = "dnd.locations.validator.source_refs.v1" + policy = "dnd.locations.validator.source_refs.v2" ) type Options struct{} @@ -35,6 +35,9 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint { } func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.LocationList]) (contracts.ValidationResult, error) { + if req.Stage == string(pipeline.StageExtract) && req.Chunk == nil { + return contracts.ValidationResult{}, fmt.Errorf("location source-reference validator requires the current extraction chunk") + } if err := locationshape.Validate(req.Value); err != nil { return contracts.ValidationResult{Approved: true}, nil } @@ -44,6 +47,10 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq for refIndex, ref := range location.SourceRefs { if err := index.ValidateRef(ref); err != nil { issues = append(issues, fmt.Sprintf("locations[%d].source_refs[%d]: %s", locationIndex, refIndex, diagnostics.Truncate(err.Error()))) + continue + } + if req.Stage == string(pipeline.StageExtract) && !chunkContainsRef(req.Chunk, ref) { + issues = append(issues, fmt.Sprintf("locations[%d].source_refs[%d]: source reference is outside the current extraction chunk", locationIndex, refIndex)) } } } @@ -53,6 +60,19 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq return rejection(diagnostics.Aggregate("invalid location source references", issues)), nil } +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} } diff --git a/internal/modules/dnd/validate/locations/source_refs/validator_test.go b/internal/modules/dnd/validate/locations/source_refs/validator_test.go index 9951e6a..daac909 100644 --- a/internal/modules/dnd/validate/locations/source_refs/validator_test.go +++ b/internal/modules/dnd/validate/locations/source_refs/validator_test.go @@ -37,6 +37,30 @@ func TestValidatorApprovesValidReferencesAndDefersMalformedShape(t *testing.T) { } } +func TestValidatorRequiresCurrentChunkAndRejectsEvidenceOutsideIt(t *testing.T) { + doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}}} + value := validLocationList() + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.LocationList]{ + Stage: string(pipeline.StageExtract), Source: doc, + Chunk: &source.Chunk{SourceID: "session", Units: []source.SourceUnit{{ID: 1}}}, Value: value, + }) + if err != nil || !result.Approved { + t.Fatalf("contained extraction evidence = %#v, %v", result, err) + } + value.Locations[0].SourceRefs[0].EndUnitID = 2 + result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.LocationList]{ + Stage: string(pipeline.StageExtract), Source: doc, + Chunk: &source.Chunk{SourceID: "session", Units: []source.SourceUnit{{ID: 1}}}, Value: value, + }) + if err != nil || result.Approved || !strings.Contains(result.Message, "outside the current extraction chunk") { + t.Fatalf("out-of-chunk extraction evidence = %#v, %v", result, err) + } + _, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.LocationList]{Stage: string(pipeline.StageExtract), Source: doc, Value: validLocationList()}) + if err == nil || !strings.Contains(err.Error(), "requires the current extraction chunk") { + t.Fatalf("missing chunk error = %v", err) + } +} + func TestValidatorRegisters(t *testing.T) { registry := pipeline.NewValidatorRegistry() if err := Register(registry); err != nil { diff --git a/internal/modules/dnd/validate/locations/source_relatedness/validator.go b/internal/modules/dnd/validate/locations/source_relatedness/validator.go index 670a8eb..60a9813 100644 --- a/internal/modules/dnd/validate/locations/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/locations/source_relatedness/validator.go @@ -16,7 +16,8 @@ import ( const ( Key = "extract/dnd/locations/source_relatedness" WarningReasonCode = "location_not_near_source" - policy = "dnd.locations.validator.source_relatedness.v1" + OmittedReasonCode = "location_relatedness_warnings_omitted" + policy = "dnd.locations.validator.source_relatedness.v2" ) type Options struct{} @@ -60,7 +61,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq Message: fmt.Sprintf("Location %s was not found in cited source text", diagnostics.Quote(location.Name)), }) } - return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil + return contracts.ValidationResult{Approved: true, Warnings: diagnostics.LimitWarnings(warnings, "locations", OmittedReasonCode)}, nil } func Spec() pipeline.ValidatorSpec { diff --git a/internal/modules/dnd/validate/locations/source_relatedness/validator_test.go b/internal/modules/dnd/validate/locations/source_relatedness/validator_test.go index a7a9d13..478b4f9 100644 --- a/internal/modules/dnd/validate/locations/source_relatedness/validator_test.go +++ b/internal/modules/dnd/validate/locations/source_relatedness/validator_test.go @@ -10,6 +10,7 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" ) func TestValidatorUsesOnlyCitedTranscriptText(t *testing.T) { @@ -54,6 +55,17 @@ func TestValidatorRegisters(t *testing.T) { } } +func TestValidatorBoundsWarnings(t *testing.T) { + locations := make([]dnd.Location, diagnostics.MaxWarnings+1) + for index := range locations { + locations[index] = dnd.Location{ID: "candidate", Name: "Missing", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}} + } + result, err := New(Options{}).Validate(context.Background(), request(document(), dnd.LocationList{Locations: locations}, contracts.ReferenceSet{})) + if err != nil || !result.Approved || len(result.Warnings) != diagnostics.MaxWarnings || result.Warnings[len(result.Warnings)-1].ReasonCode != OmittedReasonCode { + t.Fatalf("bounded warnings = %#v, %v", result, err) + } +} + func request(doc *source.SourceDocument, value dnd.LocationList, references contracts.ReferenceSet) contracts.TypedValidationRequest[dnd.LocationList] { return contracts.TypedValidationRequest[dnd.LocationList]{Source: doc, References: references, Value: value} }