diff --git a/internal/modules/dnd/validate/enemyevents/invariants/validator.go b/internal/modules/dnd/validate/enemyevents/invariants/validator.go new file mode 100644 index 0000000..5539439 --- /dev/null +++ b/internal/modules/dnd/validate/enemyevents/invariants/validator.go @@ -0,0 +1,170 @@ +// Package invariants validates normalized D&D enemy-event artifacts. +package invariants + +import ( + "context" + "fmt" + "sort" + + "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" + enemyeventmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/enemyevents" + npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" + enemyeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/shape" +) + +const ( + Key = "normalize/dnd/enemy-events/invariants" + ReasonCode = "invalid_enemy_event_normalization" + policy = "dnd.enemy_events.validator.normalized.v1" +) + +type Options struct{} + +type Validator struct { + npcResolver *npcregistry.Resolver +} + +var _ contracts.TypedValidator[dnd.EnemyEventList] = (*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("enemy event invariants validator accepts at most one reference set") + } + var referenceSet contracts.ReferenceSet + if len(references) == 1 { + referenceSet = references[0] + } + resolver, err := npcregistry.NewResolver(referenceSet) + if err != nil { + return nil, fmt.Errorf("prepare NPC registry: %w", err) + } + return &Validator{npcResolver: 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.npcResolver == nil { + return nil + } + metadata := map[string]any{"policy": policy} + if seeded := v.npcResolver.Seeded(); seeded.Bound() { + metadata["npc_registry_digest"] = seeded.Digest() + } + return metadata +} + +func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint { + if v == nil || v.npcResolver == nil { + return nil + } + return []pipeline.CheckpointFingerprint{ + {Name: "policy", Value: policy}, + {Name: "npc_registry", Value: v.npcResolver.Seeded().ProjectionDigest()}, + } +} + +func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.EnemyEventList]) (contracts.ValidationResult, error) { + if enemyeventshape.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 + } + if v == nil || v.npcResolver == nil { + return contracts.ValidationResult{}, fmt.Errorf("enemy event invariants validator must not be nil") + } + registry, err := v.npcResolver.Resolve(req.References) + if err != nil { + return contracts.ValidationResult{}, fmt.Errorf("resolve NPC registry: %w", err) + } + if !registry.Bound() { + return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: "invalid enemy event normalization: NPC registry reference is required"}, nil + } + issues := issuesFor(shared.NewSourceRefOrderFromIndex(index), req.Value, registry) + if len(issues) == 0 { + return contracts.ValidationResult{Approved: true}, nil + } + return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid enemy event normalization", issues)}, nil +} + +func allSourceRefsValid(index source.DocumentIndex, value dnd.EnemyEventList) bool { + for _, event := range value.Events { + for _, ref := range event.SourceRefs { + if index.ValidateRef(ref) != nil { + return false + } + } + } + return true +} + +func issuesFor(order shared.SourceRefOrder, value dnd.EnemyEventList, registry *npcregistry.Registry) []string { + issues := make([]string, 0) + for eventIndex, event := range value.Events { + prefix := fmt.Sprintf("events[%d]", eventIndex) + if normalized := enemyeventmodel.NormalizeDisplay(event.Name); event.Name != normalized { + issues = append(issues, prefix+".name is not whitespace-normalized: "+diagnostics.Quote(event.Name)) + } + if canonical, ok := registry.Lookup(event.Name); ok && event.Name != canonical.Name { + issues = append(issues, prefix+".name is not the canonical NPC display name: "+diagnostics.Quote(event.Name)) + } + for refIndex := 1; refIndex < len(event.SourceRefs); refIndex++ { + previous := event.SourceRefs[refIndex-1] + current := event.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.Events, func(left, right int) bool { + return enemyeventmodel.Less(order, value.Events[left], value.Events[right]) + }) { + issues = append(issues, "events are not in canonical order") + } + for eventIndex, event := range value.Events { + for previousIndex := 0; previousIndex < eventIndex; previousIndex++ { + if enemyeventmodel.ExactEqual(order, value.Events[previousIndex], event) { + issues = append(issues, fmt.Sprintf("events[%d] duplicates event %d", eventIndex, previousIndex)) + break + } + } + } + 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.EnemyEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.EnemyEventList], 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/enemyevents/invariants/validator_test.go b/internal/modules/dnd/validate/enemyevents/invariants/validator_test.go new file mode 100644 index 0000000..f1e908d --- /dev/null +++ b/internal/modules/dnd/validate/enemyevents/invariants/validator_test.go @@ -0,0 +1,145 @@ +package invariants + +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" + npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" + npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry" +) + +func TestValidatorApprovesNormalizedEventsAndGenericGroups(t *testing.T) { + references := registryReferences(t, "Ária") + value := normalizedList() + result, err := newValidator(t, references).Validate(context.Background(), request(references, value)) + if err != nil || !result.Approved { + t.Fatalf("Validate() = %#v, %v", result, err) + } +} + +func TestValidatorRejectsNormalizationDrift(t *testing.T) { + references := registryReferences(t, "Ária") + for _, test := range []struct { + name string + mutate func(*dnd.EnemyEventList) + want string + }{ + {"registry display", func(value *dnd.EnemyEventList) { value.Events[0].Name = " ária " }, "canonical NPC display name"}, + {"generic whitespace", func(value *dnd.EnemyEventList) { value.Events[1].Name = " Remaining\tOrcs " }, "whitespace-normalized"}, + {"evidence order", func(value *dnd.EnemyEventList) { + value.Events[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}} + }, "not in canonical order"}, + {"duplicate evidence", func(value *dnd.EnemyEventList) { + value.Events[0].SourceRefs = append(value.Events[0].SourceRefs, value.Events[0].SourceRefs[0]) + }, "duplicates the previous reference"}, + {"event order", func(value *dnd.EnemyEventList) { value.Events[0], value.Events[1] = value.Events[1], value.Events[0] }, "events are not in canonical order"}, + {"exact duplicate", func(value *dnd.EnemyEventList) { value.Events = append(value.Events, value.Events[0]) }, "duplicates event"}, + } { + t.Run(test.name, func(t *testing.T) { + value := normalizedList() + test.mutate(&value) + result, err := newValidator(t, references).Validate(context.Background(), request(references, 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) + } + }) + } +} + +func TestValidatorDefersEarlierFailuresAndResolvesOperationRegistryWithoutMutation(t *testing.T) { + references := registryReferences(t, "Ária") + for _, value := range []dnd.EnemyEventList{ + {Events: []dnd.EnemyEvent{{Name: "Ária"}}}, + {Events: []dnd.EnemyEvent{{Name: "Ária", Kind: dnd.EnemyEventKindEngaged, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}}, + } { + result, err := newValidator(t, references).Validate(context.Background(), request(references, value)) + if err != nil || !result.Approved { + t.Fatalf("deferral = %#v, %v", result, err) + } + } + value := normalizedList() + before := cloneList(value) + result, err := newValidator(t).Validate(context.Background(), request(references, value)) + if err != nil || !result.Approved || !reflect.DeepEqual(value, before) { + t.Fatalf("operation registry = %#v, %v; value=%#v", result, err, value) + } + result, err = newValidator(t).Validate(context.Background(), request(contracts.ReferenceSet{}, normalizedList())) + if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "required") { + t.Fatalf("unbound registry = %#v, %v", result, err) + } +} + +func TestValidatorMetadataFingerprintsAndRegistrationAreSafe(t *testing.T) { + references := registryReferences(t, "Ária") + metadata, err := json.Marshal(newValidator(t, references).ManifestMetadata()) + if err != nil || strings.Contains(string(metadata), "Ária") { + t.Fatalf("ManifestMetadata() = %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("CheckpointFingerprints() = %#v", got) + } + registry := pipeline.NewValidatorRegistry() + if err := Register(registry); err != nil { + t.Fatal(err) + } + if Spec().Key != Key || Spec().ExecutionClass != contracts.ExecutionClassDeterministic { + t.Fatalf("Spec() = %#v", Spec()) + } + if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil { + t.Fatal("DecodeOptions() accepted unknown option") + } +} + +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.EnemyEventList) contracts.TypedValidationRequest[dnd.EnemyEventList] { + return contracts.TypedValidationRequest[dnd.EnemyEventList]{Source: document(), References: references, Value: value} +} + +func document() *source.SourceDocument { + return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}}} +} + +func normalizedList() dnd.EnemyEventList { + return dnd.EnemyEventList{Events: []dnd.EnemyEvent{ + {Name: "Ária", Kind: dnd.EnemyEventKindEngaged, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}}, + {Name: "Remaining Orcs", Kind: dnd.EnemyEventKindFled, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}, + }} +} + +func cloneList(value dnd.EnemyEventList) dnd.EnemyEventList { + clone := dnd.EnemyEventList{Events: make([]dnd.EnemyEvent, len(value.Events))} + for index, event := range value.Events { + clone.Events[index] = event + clone.Events[index].SourceRefs = append([]source.SourceRef(nil), event.SourceRefs...) + } + return clone +} + +func registryReferences(t *testing.T, names ...string) contracts.ReferenceSet { + t.Helper() + npcs := make([]dnd.NPC, len(names)) + for index, name := range names { + npcs[index] = dnd.NPC{ID: identity.DeriveID(name), Name: name, SourceRefs: []source.SourceRef{{SourceID: "other", StartUnitID: index + 1, EndUnitID: index + 1}}} + } + content, err := npccodec.New().Encode(dnd.NPCList{NPCs: npcs}) + if err != nil { + t.Fatal(err) + } + return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{npcregistry.ReferenceSlot: {Slot: contracts.ReferenceSlot{Name: npcregistry.ReferenceSlot}, Items: []contracts.ReferenceItem{{SlotName: npcregistry.ReferenceSlot, MediaType: npccodec.MediaType, Content: content, Origin: contracts.ReferenceOrigin{Type: "generated"}}}}}} +} diff --git a/internal/modules/dnd/validate/enemyevents/shape/validator.go b/internal/modules/dnd/validate/enemyevents/shape/validator.go new file mode 100644 index 0000000..6f8e5e9 --- /dev/null +++ b/internal/modules/dnd/validate/enemyevents/shape/validator.go @@ -0,0 +1,93 @@ +// Package shape validates the candidate shape of D&D enemy-event artifacts. +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" + enemyeventmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/enemyevents" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" +) + +const ( + Key = "extract/dnd/enemy-events/shape" + ReasonCode = "invalid_enemy_event_shape" + policy = "dnd.enemy_events.validator.shape.v1" +) + +type Options struct{} +type Validator struct{} + +var _ contracts.TypedValidator[dnd.EnemyEventList] = (*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.EnemyEventList]) (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.EnemyEventList) error { + issues := issuesFor(value) + if len(issues) == 0 { + return nil + } + return fmt.Errorf("%s", diagnostics.Aggregate("invalid enemy event shape", issues)) +} + +func issuesFor(value dnd.EnemyEventList) []string { + if value.Events == nil { + return []string{"events must be present"} + } + issues := make([]string, 0) + for eventIndex, event := range value.Events { + prefix := fmt.Sprintf("events[%d]", eventIndex) + if strings.TrimSpace(event.Name) == "" { + issues = append(issues, prefix+".name must not be empty: "+diagnostics.Quote(event.Name)) + } + if !enemyeventmodel.SupportedKind(event.Kind) { + issues = append(issues, prefix+".kind is unsupported: "+diagnostics.Quote(string(event.Kind))) + } + if len(event.SourceRefs) == 0 { + issues = append(issues, prefix+".source_refs must contain at least one reference") + } + } + return issues +} + +func Spec() pipeline.ValidatorSpec { + return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} +} + +func Register(registry *pipeline.ValidatorRegistry) error { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.EnemyEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.EnemyEventList], 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/enemyevents/shape/validator_test.go b/internal/modules/dnd/validate/enemyevents/shape/validator_test.go new file mode 100644 index 0000000..3b75780 --- /dev/null +++ b/internal/modules/dnd/validate/enemyevents/shape/validator_test.go @@ -0,0 +1,76 @@ +package shape + +import ( + "context" + "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 TestValidatorAcceptsEmptyAndWellFormedEventLists(t *testing.T) { + for _, value := range []dnd.EnemyEventList{{Events: []dnd.EnemyEvent{}}, validEventList()} { + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Value: value}) + if err != nil || !result.Approved || len(result.Warnings) != 0 { + t.Fatalf("Validate() = %#v, %v", result, err) + } + } +} + +func TestValidatorRejectsOwnedShapeBoundaries(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*dnd.EnemyEventList) + want string + }{ + {"missing events", func(value *dnd.EnemyEventList) { value.Events = nil }, "events must be present"}, + {"blank name", func(value *dnd.EnemyEventList) { value.Events[0].Name = " \t" }, "name must not be empty"}, + {"unsupported kind", func(value *dnd.EnemyEventList) { value.Events[0].Kind = "unknown" }, "kind is unsupported"}, + {"missing evidence", func(value *dnd.EnemyEventList) { value.Events[0].SourceRefs = nil }, "source_refs must contain"}, + } { + t.Run(test.name, func(t *testing.T) { + value := validEventList() + test.mutate(&value) + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Value: 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) + } + }) + } +} + +func TestValidatorBoundsDiagnosticsAndRegistersStrictly(t *testing.T) { + value := dnd.EnemyEventList{Events: make([]dnd.EnemyEvent, 30)} + for index := range value.Events { + value.Events[index].Name = strings.Repeat("火", 300) + value.Events[index].Kind = "invalid" + } + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{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("Validate() = %#v, %v", 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.Fatal(err) + } + if Spec().Key != Key || Spec().ExecutionClass != contracts.ExecutionClassDeterministic { + t.Fatalf("Spec() = %#v", Spec()) + } + if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil { + t.Fatal("DecodeOptions() accepted unknown option") + } +} + +func validEventList() dnd.EnemyEventList { + return dnd.EnemyEventList{Events: []dnd.EnemyEvent{{ + Name: "Ashfang", Kind: dnd.EnemyEventKindEngaged, + SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}, + }}} +} diff --git a/internal/modules/dnd/validate/enemyevents/source_refs/validator.go b/internal/modules/dnd/validate/enemyevents/source_refs/validator.go new file mode 100644 index 0000000..0c3f0b0 --- /dev/null +++ b/internal/modules/dnd/validate/enemyevents/source_refs/validator.go @@ -0,0 +1,123 @@ +// Package sourcerefs validates enemy-event evidence against the current source document. +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" + enemyeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/shape" +) + +const ( + Key = "extract/dnd/enemy-events/source_refs" + ReasonCode = "invalid_enemy_event_source_refs" + policy = "dnd.enemy_events.validator.source_refs.v1" +) + +type Options struct{} +type Validator struct{} + +var _ contracts.TypedValidator[dnd.EnemyEventList] = (*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.EnemyEventList]) (contracts.ValidationResult, error) { + if req.Stage == string(pipeline.StageExtract) && req.Chunk == nil { + return contracts.ValidationResult{}, fmt.Errorf("enemy event source-reference validator requires the current extraction chunk") + } + if err := enemyeventshape.Validate(req.Value); err != nil { + return contracts.ValidationResult{Approved: true}, nil + } + index := source.NewDocumentIndex(req.Source) + var coverage *chunkCoverage + if req.Stage == string(pipeline.StageExtract) { + coverage = newChunkCoverage(req.Chunk) + } + issues := sourceRefIssues(index, req.Source, coverage, req.Value) + if len(issues) == 0 { + return contracts.ValidationResult{Approved: true}, nil + } + return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid enemy event source references", issues)}, nil +} + +func sourceRefIssues(index source.DocumentIndex, doc *source.SourceDocument, coverage *chunkCoverage, value dnd.EnemyEventList) []string { + issues := make([]string, 0) + for eventIndex, event := range value.Events { + for refIndex, ref := range event.SourceRefs { + if err := index.ValidateRef(ref); err != nil { + issues = append(issues, fmt.Sprintf("events[%d].source_refs[%d]: %s", eventIndex, refIndex, diagnostics.Truncate(err.Error()))) + continue + } + if coverage != nil && !coverage.contains(doc, ref) { + issues = append(issues, fmt.Sprintf("events[%d].source_refs[%d]: source reference is outside the current extraction chunk", eventIndex, refIndex)) + } + } + } + return issues +} + +type chunkCoverage struct { + sourceID string + unitIDs map[int]struct{} +} + +func newChunkCoverage(chunk *source.Chunk) *chunkCoverage { + coverage := &chunkCoverage{sourceID: chunk.SourceID, unitIDs: make(map[int]struct{}, len(chunk.Units))} + for _, unit := range chunk.Units { + coverage.unitIDs[unit.ID] = struct{}{} + } + return coverage +} + +func (coverage *chunkCoverage) contains(doc *source.SourceDocument, ref source.SourceRef) bool { + if coverage == nil || doc == nil || ref.SourceID != coverage.sourceID { + return false + } + start, startOK := source.UnitIndex(doc, ref.StartUnitID) + end, endOK := source.UnitIndex(doc, ref.EndUnitID) + if !startOK || !endOK || start > end { + return false + } + for position := start; position <= end; position++ { + if _, found := coverage.unitIDs[doc.Units[position].ID]; !found { + return false + } + } + return true +} + +func Spec() pipeline.ValidatorSpec { + return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} +} + +func Register(registry *pipeline.ValidatorRegistry) error { + return pipeline.RegisterTypedValidatorBuilder(registry, dnd.EnemyEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.EnemyEventList], 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/enemyevents/source_refs/validator_test.go b/internal/modules/dnd/validate/enemyevents/source_refs/validator_test.go new file mode 100644 index 0000000..b7bfef8 --- /dev/null +++ b/internal/modules/dnd/validate/enemyevents/source_refs/validator_test.go @@ -0,0 +1,86 @@ +package sourcerefs + +import ( + "context" + "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 TestValidatorAcceptsCurrentDocumentAndChunkEvidence(t *testing.T) { + value := validEventList() + chunk := &source.Chunk{SourceID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}}} + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Stage: string(pipeline.StageExtract), Source: document(), Chunk: chunk, Value: value}) + if err != nil || !result.Approved { + t.Fatalf("Validate() = %#v, %v", result, err) + } +} + +func TestValidatorRejectsInvalidOrOutOfChunkEvidence(t *testing.T) { + for _, test := range []struct { + name string + ref source.SourceRef + chunk *source.Chunk + want string + missing bool + }{ + {"wrong source", source.SourceRef{SourceID: "other", StartUnitID: 1, EndUnitID: 1}, nil, "does not match document", false}, + {"unknown unit", source.SourceRef{SourceID: "session", StartUnitID: 99, EndUnitID: 99}, nil, "was not found", false}, + {"backward range", source.SourceRef{SourceID: "session", StartUnitID: 3, EndUnitID: 1}, nil, "appears after", false}, + {"outside chunk", source.SourceRef{SourceID: "session", StartUnitID: 2, EndUnitID: 3}, &source.Chunk{SourceID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}}}, "outside the current extraction chunk", false}, + {"missing chunk", source.SourceRef{SourceID: "session", StartUnitID: 1, EndUnitID: 1}, nil, "current extraction chunk", true}, + } { + t.Run(test.name, func(t *testing.T) { + value := validEventList() + value.Events[0].SourceRefs[0] = test.ref + request := contracts.TypedValidationRequest[dnd.EnemyEventList]{Source: document(), Value: value} + if test.chunk != nil || test.missing { + request.Stage = string(pipeline.StageExtract) + request.Chunk = test.chunk + } + result, err := New(Options{}).Validate(context.Background(), request) + if test.missing { + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Validate() error = %v; want %q", err, test.want) + } + return + } + 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) + } + }) + } +} + +func TestValidatorDefersShapeFailureAndRegistersStrictly(t *testing.T) { + malformed := dnd.EnemyEventList{Events: []dnd.EnemyEvent{{Name: "Ashfang"}}} + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Source: document(), Value: malformed}) + 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 Spec().Key != Key || Spec().ExecutionClass != contracts.ExecutionClassDeterministic { + t.Fatalf("Spec() = %#v", Spec()) + } + if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Value != policy { + t.Fatalf("CheckpointFingerprints() = %#v", got) + } + if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil { + t.Fatal("DecodeOptions() accepted unknown option") + } +} + +func validEventList() dnd.EnemyEventList { + return dnd.EnemyEventList{Events: []dnd.EnemyEvent{{Name: "Ashfang", Kind: dnd.EnemyEventKindEngaged, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 2}}}}} +} + +func document() *source.SourceDocument { + return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "Ashfang attacks."}, {ID: 2, Text: "Ashfang retreats."}, {ID: 3, Text: "Later."}}} +} diff --git a/internal/modules/dnd/validate/enemyevents/source_relatedness/validator.go b/internal/modules/dnd/validate/enemyevents/source_relatedness/validator.go new file mode 100644 index 0000000..9eccbea --- /dev/null +++ b/internal/modules/dnd/validate/enemyevents/source_relatedness/validator.go @@ -0,0 +1,84 @@ +// Package sourcerelatedness reports advisory enemy-subject evidence concerns. +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" + enemyeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/shape" +) + +const ( + Key = "extract/dnd/enemy-events/source_relatedness" + WarningReasonCode = "enemy_event_not_near_source" + OmittedReasonCode = "enemy_event_relatedness_warnings_omitted" + policy = "dnd.enemy_events.validator.source_relatedness.v1" +) + +type Options struct{} +type Validator struct{} + +var _ contracts.TypedValidator[dnd.EnemyEventList] = (*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.EnemyEventList]) (contracts.ValidationResult, error) { + if enemyeventshape.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 + } + warnings := make([]contracts.Warning, 0) + for eventIndex, event := range req.Value.Events { + 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]", eventIndex), + ReasonCode: WarningReasonCode, + Message: diagnostics.Aggregate("enemy event subject not near source", []string{ + fmt.Sprintf("subject %s was not found in cited source text", diagnostics.Quote(event.Name)), + }), + }) + } + return contracts.ValidationResult{Approved: true, Warnings: diagnostics.LimitWarnings(warnings, "enemy_events", 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.EnemyEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.EnemyEventList], 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/enemyevents/source_relatedness/validator_test.go b/internal/modules/dnd/validate/enemyevents/source_relatedness/validator_test.go new file mode 100644 index 0000000..5ac59ac --- /dev/null +++ b/internal/modules/dnd/validate/enemyevents/source_relatedness/validator_test.go @@ -0,0 +1,66 @@ +package sourcerelatedness + +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/framework/pipeline" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" +) + +func TestValidatorUsesOnlyCitedTranscriptEvidence(t *testing.T) { + value := validEventList() + references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{"npcs": {Items: []contracts.ReferenceItem{{Content: []byte("Ashfang")}}}}} + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Source: document("The party waits."), References: references, Value: value}) + if err != nil || !result.Approved || len(result.Warnings) != 1 || result.Warnings[0].ReasonCode != WarningReasonCode { + t.Fatalf("Validate() = %#v, %v", result, err) + } +} + +func TestValidatorAcceptsUnicodeSubjectInCitedEvidence(t *testing.T) { + value := validEventList() + value.Events[0].Name = "O'Rin Thorn" + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Source: document("o’rin\u2003thorn flees."), Value: value}) + if err != nil || !result.Approved || len(result.Warnings) != 0 { + t.Fatalf("Validate() = %#v, %v", result, err) + } +} + +func TestValidatorDefersMalformedValuesAndBoundsWarnings(t *testing.T) { + malformed := dnd.EnemyEventList{Events: []dnd.EnemyEvent{{Name: "Ashfang"}}} + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Source: document("none"), Value: malformed}) + if err != nil || !result.Approved || len(result.Warnings) != 0 { + t.Fatalf("shape deferral = %#v, %v", result, err) + } + value := dnd.EnemyEventList{Events: make([]dnd.EnemyEvent, 30)} + for index := range value.Events { + value.Events[index] = dnd.EnemyEvent{Name: "Missing", Kind: dnd.EnemyEventKindEngaged, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}} + } + result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Source: document("none"), Value: value}) + if err != nil || !result.Approved || len(result.Warnings) == 0 || result.Warnings[len(result.Warnings)-1].ReasonCode != OmittedReasonCode { + t.Fatalf("bounded warnings = %#v, %v", result, err) + } + registry := pipeline.NewValidatorRegistry() + if err := Register(registry); err != nil { + t.Fatal(err) + } + if Spec().Key != Key || Spec().ExecutionClass != contracts.ExecutionClassDeterministic { + t.Fatalf("Spec() = %#v", Spec()) + } + if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Value != policy { + t.Fatalf("CheckpointFingerprints() = %#v", got) + } + if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil { + t.Fatal("DecodeOptions() accepted unknown option") + } +} + +func validEventList() dnd.EnemyEventList { + return dnd.EnemyEventList{Events: []dnd.EnemyEvent{{Name: "Ashfang", Kind: dnd.EnemyEventKindFled, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}} +} + +func document(text string) *source.SourceDocument { + return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: text}}} +}