diff --git a/internal/modules/dnd/validate/locations/identity/validator.go b/internal/modules/dnd/validate/locations/identity/validator.go new file mode 100644 index 0000000..a8ed2d1 --- /dev/null +++ b/internal/modules/dnd/validate/locations/identity/validator.go @@ -0,0 +1,76 @@ +// Package identity validates stable, evidence-anchored location identity. +package identity + +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" + domainidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" + locationshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/locations/shape" +) + +const ( + Key = "normalize/dnd/locations/identity" + ReasonCode = "invalid_location_identity" + policy = domainidentity.Policy +) + +type Options struct{} +type Validator struct{} + +var _ contracts.TypedValidator[dnd.LocationList] = (*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.LocationList]) (contracts.ValidationResult, error) { + if err := locationshape.Validate(req.Value); err != nil { + return contracts.ValidationResult{Approved: true}, nil + } + identityIssues := domainidentity.ValidateList(req.Value) + if len(identityIssues) == 0 { + return contracts.ValidationResult{Approved: true}, nil + } + issues := make([]string, len(identityIssues)) + for index, issue := range identityIssues { + issues[index] = fmt.Sprintf("locations[%d] %s: %s", issue.RecordIndex, issue.Code, diagnostics.Quote(issue.Value)) + } + return contracts.ValidationResult{ + Approved: false, ReasonCode: ReasonCode, + Message: diagnostics.Aggregate("invalid location identity", issues), + }, nil +} + +func Spec() pipeline.ValidatorSpec { + return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} +} + +func Register(registry *pipeline.ValidatorRegistry) error { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.LocationListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.LocationList], 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/locations/identity/validator_test.go b/internal/modules/dnd/validate/locations/identity/validator_test.go new file mode 100644 index 0000000..3345262 --- /dev/null +++ b/internal/modules/dnd/validate/locations/identity/validator_test.go @@ -0,0 +1,70 @@ +package identity + +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" + domainidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity" +) + +func TestValidatorAllowsSameNameAtDistinctEvidence(t *testing.T) { + refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}} + otherRefs := []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}} + value := dnd.LocationList{Locations: []dnd.Location{ + {ID: domainidentity.DeriveID("Watchtower", refs), Name: "Watchtower", SourceRefs: refs}, + {ID: domainidentity.DeriveID("Watchtower", otherRefs), Name: "Watchtower", SourceRefs: otherRefs}, + }} + before := value + result, err := New(Options{}).Validate(context.Background(), request(value)) + if err != nil || !result.Approved || !reflect.DeepEqual(value, before) { + t.Fatalf("Validate() = %#v, %v; want non-mutating approval", result, err) + } +} + +func TestValidatorDefersShapeAndRejectsDerivationAndDuplicateID(t *testing.T) { + result, err := New(Options{}).Validate(context.Background(), request(dnd.LocationList{Locations: []dnd.Location{{Name: "Missing"}}})) + if err != nil || !result.Approved { + t.Fatalf("shape-invalid result = %#v, %v; want deferral", result, err) + } + refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}} + value := dnd.LocationList{Locations: []dnd.Location{ + {ID: "not-an-id", Name: "Gate", SourceRefs: refs}, + {ID: domainidentity.DeriveID("Gate", refs), Name: "Other Gate", SourceRefs: refs}, + {ID: domainidentity.DeriveID("Gate", refs), Name: "Gate", SourceRefs: refs}, + }} + result, err = New(Options{}).Validate(context.Background(), request(value)) + if err != nil || result.Approved || result.ReasonCode != ReasonCode { + t.Fatalf("identity result = %#v, %v; want rejection", result, err) + } + for _, want := range []string{"invalid_id", "id_mismatch", "duplicate_id", "locations["} { + if !strings.Contains(result.Message, want) { + t.Fatalf("message %q missing %q", result.Message, want) + } + } +} + +func TestValidatorRegistersIdentityPolicy(t *testing.T) { + if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Value != domainidentity.Policy { + t.Fatalf("fingerprints = %#v", got) + } + registry := pipeline.NewValidatorRegistry() + if err := Register(registry); err != nil { + t.Fatalf("Register() error = %v", err) + } + if got, ok := registry.Spec(Key); !ok || got != Spec() || got.ExecutionClass != contracts.ExecutionClassDeterministic { + t.Fatalf("registered spec = %#v, ok = %t", got, ok) + } + if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil { + t.Fatal("DecodeOptions() accepted an unknown option") + } +} + +func request(value dnd.LocationList) contracts.TypedValidationRequest[dnd.LocationList] { + return contracts.TypedValidationRequest[dnd.LocationList]{Value: value} +} diff --git a/internal/modules/dnd/validate/locations/shape/validator.go b/internal/modules/dnd/validate/locations/shape/validator.go new file mode 100644 index 0000000..d303e93 --- /dev/null +++ b/internal/modules/dnd/validate/locations/shape/validator.go @@ -0,0 +1,97 @@ +// Package shape validates the required extracted location 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/locations/shape" + ReasonCode = "invalid_location_shape" + policy = "dnd.locations.validator.shape.v1" +) + +type Options struct{} +type Validator struct{} + +var _ contracts.TypedValidator[dnd.LocationList] = (*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.LocationList]) (contracts.ValidationResult, error) { + issues := issuesFor(req.Value) + if len(issues) > 0 { + return rejection(diagnostics.Aggregate("invalid location shape", issues)), nil + } + return contracts.ValidationResult{Approved: true}, nil +} + +func Validate(value dnd.LocationList) error { + issues := issuesFor(value) + if len(issues) == 0 { + return nil + } + return fmt.Errorf("%s", diagnostics.Aggregate("invalid location shape", issues)) +} + +func issuesFor(value dnd.LocationList) []string { + if value.Locations == nil { + return []string{"locations must be present"} + } + issues := make([]string, 0) + for index, location := range value.Locations { + prefix := fmt.Sprintf("locations[%d]", index) + if strings.TrimSpace(location.ID) == "" { + issues = append(issues, prefix+".id must not be empty") + } + if strings.TrimSpace(location.Name) == "" { + issues = append(issues, prefix+".name must not be empty") + } + if len(location.SourceRefs) == 0 { + issues = append(issues, prefix+".source_refs must not be empty") + } + } + return issues +} + +func Spec() pipeline.ValidatorSpec { + return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} +} + +func Register(registry *pipeline.ValidatorRegistry) error { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.LocationListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.LocationList], 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 } + +func rejection(message string) contracts.ValidationResult { + return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message} +} diff --git a/internal/modules/dnd/validate/locations/shape/validator_test.go b/internal/modules/dnd/validate/locations/shape/validator_test.go new file mode 100644 index 0000000..e3e6d22 --- /dev/null +++ b/internal/modules/dnd/validate/locations/shape/validator_test.go @@ -0,0 +1,58 @@ +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 TestValidatorRejectsMissingRequiredFieldsWithoutMutation(t *testing.T) { + value := dnd.LocationList{Locations: []dnd.Location{{Name: strings.Repeat("火", 220), SourceRefs: []source.SourceRef{}}}} + before := value + result, err := New(Options{}).Validate(context.Background(), requestWithValue(value)) + if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "id must not be empty") || !strings.Contains(result.Message, "source_refs must not be empty") { + t.Fatalf("Validate() = %#v, %v; want shape rejection", result, err) + } + if len(result.Message) > 4096 || !utf8.ValidString(result.Message) || !reflect.DeepEqual(value, before) { + t.Fatalf("Validate() produced unsafe diagnostics or mutated value: %#v", value) + } + result, err = New(Options{}).Validate(context.Background(), requestWithValue(dnd.LocationList{})) + if err != nil || result.Approved || !strings.Contains(result.Message, "locations must be present") { + t.Fatalf("missing locations = %#v, %v; want rejection", result, err) + } +} + +func TestValidatorApprovesAndRegisters(t *testing.T) { + result, err := New(Options{}).Validate(context.Background(), requestWithValue(validLocationList())) + if err != nil || !result.Approved { + t.Fatalf("Validate() = %#v, %v; want approval", result, err) + } + if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy { + t.Fatalf("CheckpointFingerprints() = %#v", got) + } + registry := pipeline.NewValidatorRegistry() + if err := Register(registry); err != nil { + t.Fatalf("Register() error = %v", err) + } + if got, ok := registry.Spec(Key); !ok || got != Spec() || got.ExecutionClass != contracts.ExecutionClassDeterministic { + t.Fatalf("registered spec = %#v, ok = %t", got, ok) + } + if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil { + t.Fatal("DecodeOptions() accepted an unknown option") + } +} + +func requestWithValue(value dnd.LocationList) contracts.TypedValidationRequest[dnd.LocationList] { + return contracts.TypedValidationRequest[dnd.LocationList]{Value: value} +} + +func validLocationList() dnd.LocationList { + return dnd.LocationList{Locations: []dnd.Location{{ID: "candidate", Name: "Moon Gate", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}} +} diff --git a/internal/modules/dnd/validate/locations/source_refs/validator.go b/internal/modules/dnd/validate/locations/source_refs/validator.go new file mode 100644 index 0000000..3b03a59 --- /dev/null +++ b/internal/modules/dnd/validate/locations/source_refs/validator.go @@ -0,0 +1,81 @@ +// Package sourcerefs validates location citations against the current source. +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" + locationshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/locations/shape" +) + +const ( + Key = "extract/dnd/locations/source_refs" + ReasonCode = "invalid_location_source_refs" + policy = "dnd.locations.validator.source_refs.v1" +) + +type Options struct{} +type Validator struct{} + +var _ contracts.TypedValidator[dnd.LocationList] = (*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.LocationList]) (contracts.ValidationResult, error) { + if err := locationshape.Validate(req.Value); err != nil { + return contracts.ValidationResult{Approved: true}, nil + } + index := source.NewDocumentIndex(req.Source) + issues := make([]string, 0) + for locationIndex, location := range req.Value.Locations { + 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()))) + } + } + } + if len(issues) == 0 { + return contracts.ValidationResult{Approved: true}, nil + } + return rejection(diagnostics.Aggregate("invalid location source references", issues)), nil +} + +func Spec() pipeline.ValidatorSpec { + return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} +} + +func Register(registry *pipeline.ValidatorRegistry) error { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.LocationListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.LocationList], 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 } + +func rejection(message string) contracts.ValidationResult { + return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message} +} diff --git a/internal/modules/dnd/validate/locations/source_refs/validator_test.go b/internal/modules/dnd/validate/locations/source_refs/validator_test.go new file mode 100644 index 0000000..9951e6a --- /dev/null +++ b/internal/modules/dnd/validate/locations/source_refs/validator_test.go @@ -0,0 +1,63 @@ +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 TestValidatorRejectsForeignAndOutOfRangeReferencesWithoutMutation(t *testing.T) { + value := validLocationList() + value.Locations[0].SourceRefs = []source.SourceRef{{SourceID: "foreign", StartUnitID: 1, EndUnitID: 1}, {SourceID: "session", StartUnitID: 9, EndUnitID: 9}} + before := value + result, err := New(Options{}).Validate(context.Background(), request(validDocument(), value)) + if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "locations[0].source_refs[0]") { + t.Fatalf("Validate() = %#v, %v; want source-reference rejection", result, err) + } + if len(result.Message) > 4096 || !utf8.ValidString(result.Message) || !reflect.DeepEqual(value, before) { + t.Fatalf("Validate() produced unsafe diagnostics or mutated value") + } +} + +func TestValidatorApprovesValidReferencesAndDefersMalformedShape(t *testing.T) { + result, err := New(Options{}).Validate(context.Background(), request(validDocument(), validLocationList())) + if err != nil || !result.Approved { + t.Fatalf("valid references = %#v, %v", result, err) + } + result, err = New(Options{}).Validate(context.Background(), request(nil, dnd.LocationList{Locations: []dnd.Location{{Name: "Missing"}}})) + if err != nil || !result.Approved || result.ReasonCode != "" { + t.Fatalf("malformed shape = %#v, %v; want deferral", result, err) + } +} + +func TestValidatorRegisters(t *testing.T) { + registry := pipeline.NewValidatorRegistry() + if err := Register(registry); err != nil { + t.Fatalf("Register() error = %v", 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.LocationList) contracts.TypedValidationRequest[dnd.LocationList] { + return contracts.TypedValidationRequest[dnd.LocationList]{Source: doc, Value: value} +} + +func validDocument() *source.SourceDocument { + return &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{{ID: 1, Kind: "message", Text: "Moon Gate opens."}}} +} + +func validLocationList() dnd.LocationList { + return dnd.LocationList{Locations: []dnd.Location{{ID: "candidate", Name: "Moon Gate", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}} +} diff --git a/internal/modules/dnd/validate/locations/source_relatedness/validator.go b/internal/modules/dnd/validate/locations/source_relatedness/validator.go new file mode 100644 index 0000000..670a8eb --- /dev/null +++ b/internal/modules/dnd/validate/locations/source_relatedness/validator.go @@ -0,0 +1,87 @@ +// Package sourcerelatedness warns when cited source text does not mention a location. +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" + locationshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/locations/shape" +) + +const ( + Key = "extract/dnd/locations/source_relatedness" + WarningReasonCode = "location_not_near_source" + policy = "dnd.locations.validator.source_relatedness.v1" +) + +type Options struct{} +type Validator struct{} + +var _ contracts.TypedValidator[dnd.LocationList] = (*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.LocationList]) (contracts.ValidationResult, error) { + if err := locationshape.Validate(req.Value); err != 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.Locations)) + for locationIndex, location := range req.Value.Locations { + citedText, err := resolver.CitedText(location.SourceRefs) + if err != nil { + return contracts.ValidationResult{Approved: true}, nil + } + citedTexts[locationIndex] = citedText + } + warnings := make([]contracts.Warning, 0) + for locationIndex, location := range req.Value.Locations { + if shared.ContainsTokenSequence(citedTexts[locationIndex], location.Name) { + continue + } + warnings = append(warnings, contracts.Warning{ + Scope: fmt.Sprintf("locations[%d]", locationIndex), ReasonCode: WarningReasonCode, + Message: fmt.Sprintf("Location %s was not found in cited source text", diagnostics.Quote(location.Name)), + }) + } + return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil +} + +func Spec() pipeline.ValidatorSpec { + return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} +} + +func Register(registry *pipeline.ValidatorRegistry) error { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.LocationListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.LocationList], 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/locations/source_relatedness/validator_test.go b/internal/modules/dnd/validate/locations/source_relatedness/validator_test.go new file mode 100644 index 0000000..a7a9d13 --- /dev/null +++ b/internal/modules/dnd/validate/locations/source_relatedness/validator_test.go @@ -0,0 +1,63 @@ +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" +) + +func TestValidatorUsesOnlyCitedTranscriptText(t *testing.T) { + value := dnd.LocationList{Locations: []dnd.Location{{ID: "candidate", Name: "O'Rin's Gate", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}} + doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{{ID: 1, Kind: "message", Text: "The party enters o’rin’s gate."}, {ID: 2, Kind: "message", Text: "Unrelated location."}}} + 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; want approval without warnings", result, err) + } + value.Locations[0].Name = "Glossary Keep" + before := 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; want advisory warning", result, err) + } +} + +func TestValidatorDefersMalformedOrUnreadableCitations(t *testing.T) { + invalid := dnd.LocationList{Locations: []dnd.Location{{Name: "Missing"}}} + result, err := New(Options{}).Validate(context.Background(), request(document(), invalid, contracts.ReferenceSet{})) + if err != nil || !result.Approved || len(result.Warnings) != 0 { + t.Fatalf("shape deferral = %#v, %v", result, err) + } + value := dnd.LocationList{Locations: []dnd.Location{{ID: "candidate", Name: "Missing", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}} + result, err = New(Options{}).Validate(context.Background(), request(document(), value, contracts.ReferenceSet{})) + if err != nil || !result.Approved || len(result.Warnings) != 0 { + t.Fatalf("unreadable citation = %#v, %v", result, err) + } +} + +func TestValidatorRegisters(t *testing.T) { + registry := pipeline.NewValidatorRegistry() + if err := Register(registry); err != nil { + t.Fatalf("Register() error = %v", 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.LocationList, references contracts.ReferenceSet) contracts.TypedValidationRequest[dnd.LocationList] { + return contracts.TypedValidationRequest[dnd.LocationList]{Source: doc, References: references, Value: value} +} + +func document() *source.SourceDocument { + return &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{{ID: 1, Kind: "message", Text: "The party waits."}}} +}