diff --git a/internal/modules/dnd/validate/npcinteractions/invariants/validator.go b/internal/modules/dnd/validate/npcinteractions/invariants/validator.go new file mode 100644 index 0000000..3f0548d --- /dev/null +++ b/internal/modules/dnd/validate/npcinteractions/invariants/validator.go @@ -0,0 +1,256 @@ +// Package invariants validates normalized D&D NPC interaction 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/npcs/identity" + npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" + interactionshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/shape" +) + +const ( + Key = "normalize/dnd/npc-interactions/invariants" + ReasonCode = "invalid_npc_interaction_normalization" + policy = "dnd.npc_interactions.validator.normalized.v1" +) + +type Options struct{} + +type Validator struct { + npcResolver *npcregistry.Resolver +} + +var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*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("NPC interaction 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} + seeded := v.npcResolver.Seeded() + if seeded.Bound() { + metadata["npc_registry_digest"] = seeded.Digest() + metadata["npc_count"] = seeded.Count() + } + 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.NPCInteractionList]) (contracts.ValidationResult, error) { + if interactionshape.Validate(req.Value) != nil || !sourceRefsValid(req.Source, req.Value) { + return contracts.ValidationResult{Approved: true}, nil + } + if v == nil || v.npcResolver == nil { + return contracts.ValidationResult{}, fmt.Errorf("NPC interaction invariants validator must not be nil") + } + npcRegistry, err := v.npcResolver.Resolve(req.References) + if err != nil { + return contracts.ValidationResult{}, fmt.Errorf("resolve NPC registry: %w", err) + } + if !npcRegistry.Bound() { + return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: "invalid NPC interaction normalization: NPC registry reference is required"}, nil + } + issues := issuesFor(req.Source, req.Value, npcRegistry) + if len(issues) == 0 { + return contracts.ValidationResult{Approved: true}, nil + } + return contracts.ValidationResult{ + Approved: false, + ReasonCode: ReasonCode, + Message: diagnostics.Aggregate("invalid NPC interaction normalization", issues), + }, nil +} + +func sourceRefsValid(doc *source.SourceDocument, value dnd.NPCInteractionList) bool { + for _, interaction := range value.Interactions { + for _, ref := range interaction.SourceRefs { + if source.ValidateRef(doc, ref) != nil { + return false + } + } + } + return true +} + +func issuesFor(doc *source.SourceDocument, value dnd.NPCInteractionList, npcRegistry *npcregistry.Registry) []string { + issues := make([]string, 0) + for index, interaction := range value.Interactions { + prefix := fmt.Sprintf("interactions[%d]", index) + if canonical, ok := npcRegistry.Lookup(interaction.Name); ok && interaction.Name != canonical.Name { + issues = append(issues, prefix+".name is not the canonical NPC display name: "+diagnostics.Quote(interaction.Name)) + } + for refIndex := 1; refIndex < len(interaction.SourceRefs); refIndex++ { + previous := interaction.SourceRefs[refIndex-1] + current := interaction.SourceRefs[refIndex] + if sourceRefLess(doc, 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.Interactions, func(left, right int) bool { + return interactionLess(doc, value.Interactions[left], value.Interactions[right]) + }) { + issues = append(issues, "interactions are not in canonical order") + } + seen := make(map[string]int) + for index, interaction := range value.Interactions { + key := duplicateKey(interaction) + if previous, ok := seen[key]; ok { + issues = append(issues, fmt.Sprintf("interactions[%d] duplicates interaction %d", index, previous)) + continue + } + seen[key] = index + } + return issues +} + +func interactionLess(doc *source.SourceDocument, left, right dnd.NPCInteraction) bool { + leftPosition, _ := earliestSourcePosition(doc, left) + rightPosition, _ := earliestSourcePosition(doc, right) + if leftPosition != rightPosition { + return leftPosition < rightPosition + } + leftKey := identity.ComparisonKey(left.Name) + rightKey := identity.ComparisonKey(right.Name) + if leftKey != rightKey { + return leftKey < rightKey + } + if left.Name != right.Name { + return left.Name < right.Name + } + if left.Kind != right.Kind { + return left.Kind < right.Kind + } + return sourceRefsLess(doc, left.SourceRefs, right.SourceRefs) +} + +func sourceRefsLess(doc *source.SourceDocument, left, right []source.SourceRef) bool { + for index := 0; index < len(left) && index < len(right); index++ { + if left[index] == right[index] { + continue + } + return sourceRefLess(doc, left[index], right[index]) + } + return len(left) < len(right) +} + +func sourceRefLess(doc *source.SourceDocument, left, right source.SourceRef) bool { + if left.SourceID != right.SourceID { + return left.SourceID < right.SourceID + } + leftStart, _ := source.UnitIndex(doc, left.StartUnitID) + rightStart, _ := source.UnitIndex(doc, right.StartUnitID) + if leftStart != rightStart { + return leftStart < rightStart + } + leftEnd, _ := source.UnitIndex(doc, left.EndUnitID) + rightEnd, _ := source.UnitIndex(doc, right.EndUnitID) + return leftEnd < rightEnd +} + +func earliestSourcePosition(doc *source.SourceDocument, interaction dnd.NPCInteraction) (int, bool) { + found := false + earliest := 0 + for _, ref := range interaction.SourceRefs { + if source.ValidateRef(doc, ref) != nil { + continue + } + position, ok := source.UnitIndex(doc, ref.StartUnitID) + if !ok || (found && position >= earliest) { + continue + } + earliest = position + found = true + } + return earliest, found +} + +func duplicateKey(interaction dnd.NPCInteraction) string { + var key strings.Builder + writeKeyString(&key, interaction.Name) + writeKeyString(&key, string(interaction.Kind)) + for _, ref := range interaction.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.NPCInteractionListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCInteractionList], 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/npcinteractions/invariants/validator_test.go b/internal/modules/dnd/validate/npcinteractions/invariants/validator_test.go new file mode 100644 index 0000000..3ba945a --- /dev/null +++ b/internal/modules/dnd/validate/npcinteractions/invariants/validator_test.go @@ -0,0 +1,157 @@ +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 TestValidatorApprovesCanonicalNormalizedInteractions(t *testing.T) { + references := registryReferences(t, "Aria", "Borin") + result, err := newValidator(t, references).Validate(context.Background(), request(references, normalizedList())) + if err != nil || !result.Approved { + t.Fatalf("Validate() = %#v, %v", result, err) + } +} + +func TestValidatorRejectsOwnedCanonicalNameReferenceOrderListOrderAndDuplicates(t *testing.T) { + references := registryReferences(t, "Aria", "Borin") + for _, test := range []struct { + name string + mutate func(*dnd.NPCInteractionList) + want string + }{ + {"canonical name", func(value *dnd.NPCInteractionList) { value.Interactions[0].Name = " aria " }, "canonical NPC display name"}, + {"reference order", func(value *dnd.NPCInteractionList) { + value.Interactions[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}} + }, "not in canonical order"}, + {"duplicate reference", func(value *dnd.NPCInteractionList) { + value.Interactions[0].SourceRefs = append(value.Interactions[0].SourceRefs, value.Interactions[0].SourceRefs[0]) + }, "duplicates the previous reference"}, + {"list order", func(value *dnd.NPCInteractionList) { + value.Interactions[0], value.Interactions[1] = value.Interactions[1], value.Interactions[0] + }, "interactions are not in canonical order"}, + {"name tie breaker", func(value *dnd.NPCInteractionList) { + value.Interactions[0] = dnd.NPCInteraction{Name: "Borin", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}} + value.Interactions[1] = dnd.NPCInteraction{Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}} + }, "interactions are not in canonical order"}, + {"duplicate record", func(value *dnd.NPCInteractionList) { + value.Interactions = append(value.Interactions, value.Interactions[0]) + }, "duplicates interaction"}, + } { + 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 TestValidatorDefersShapeAndSourceReferenceFailuresAndRequiresRegistry(t *testing.T) { + references := registryReferences(t, "Aria", "Borin") + for _, value := range []dnd.NPCInteractionList{ + {Interactions: []dnd.NPCInteraction{{Name: "Aria"}}}, + {Interactions: []dnd.NPCInteraction{{Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, 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) + } + } + 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 TestValidatorResolvesGeneratedRegistryAndKeepsMetadataAndInputsImmutable(t *testing.T) { + references := registryReferences(t, "Aria", "Borin") + validator := newValidator(t) + value := normalizedList() + before := cloneList(value) + result, err := validator.Validate(context.Background(), request(references, value)) + if err != nil || !result.Approved || !reflect.DeepEqual(value, before) { + t.Fatalf("generated validation = %#v, %v; value=%#v", result, err, value) + } + metadata, err := json.Marshal(newValidator(t, references).ManifestMetadata()) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(metadata), "Aria") || strings.Contains(string(metadata), "other-session") { + t.Fatalf("metadata leaked registry content: %s", metadata) + } + 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 _, 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.NPCInteractionList) contracts.TypedValidationRequest[dnd.NPCInteractionList] { + return contracts.TypedValidationRequest[dnd.NPCInteractionList]{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.NPCInteractionList { + return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{ + {Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}}, + {Name: "Borin", Kind: dnd.NPCInteractionKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}, + }} +} + +func cloneList(value dnd.NPCInteractionList) dnd.NPCInteractionList { + copyValue := dnd.NPCInteractionList{Interactions: make([]dnd.NPCInteraction, len(value.Interactions))} + for index, interaction := range value.Interactions { + copyValue.Interactions[index] = interaction + copyValue.Interactions[index].SourceRefs = append([]source.SourceRef(nil), interaction.SourceRefs...) + } + return copyValue +} + +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-session", 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/npcinteractions/registry/validator.go b/internal/modules/dnd/validate/npcinteractions/registry/validator.go new file mode 100644 index 0000000..51fa0c1 --- /dev/null +++ b/internal/modules/dnd/validate/npcinteractions/registry/validator.go @@ -0,0 +1,130 @@ +// Package registry validates D&D NPC interaction names against NPC 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" + npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" + interactionshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/shape" +) + +const ( + Key = "extract/dnd/npc-interactions/registry" + ReasonCode = "invalid_npc_interaction_registry" + policy = "dnd.npc_interactions.validator.registry.v1" +) + +type Options struct{} + +type Validator struct { + npcResolver *npcregistry.Resolver +} + +var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*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("NPC interaction registry 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} + seeded := v.npcResolver.Seeded() + if seeded.Bound() { + metadata["npc_registry_digest"] = seeded.Digest() + metadata["npc_count"] = seeded.Count() + } + 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.NPCInteractionList]) (contracts.ValidationResult, error) { + if interactionshape.Validate(req.Value) != nil { + return contracts.ValidationResult{Approved: true}, nil + } + if v == nil || v.npcResolver == nil { + return contracts.ValidationResult{}, fmt.Errorf("NPC interaction registry validator must not be nil") + } + npcRegistry, err := v.npcResolver.Resolve(req.References) + if err != nil { + return contracts.ValidationResult{}, fmt.Errorf("resolve NPC registry: %w", err) + } + if !npcRegistry.Bound() { + return rejection([]string{"NPC registry reference is required"}), nil + } + issues := make([]string, 0) + for index, interaction := range req.Value.Interactions { + if _, ok := npcRegistry.Lookup(interaction.Name); !ok { + issues = append(issues, fmt.Sprintf("interactions[%d].name is not in the NPC registry: %s", index, diagnostics.Quote(interaction.Name))) + } + } + 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 NPC interaction 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.NPCInteractionListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCInteractionList], 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/npcinteractions/registry/validator_test.go b/internal/modules/dnd/validate/npcinteractions/registry/validator_test.go new file mode 100644 index 0000000..0af4861 --- /dev/null +++ b/internal/modules/dnd/validate/npcinteractions/registry/validator_test.go @@ -0,0 +1,137 @@ +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" + 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 TestValidatorRecognizesRegistryNamesAndRejectsUnknownNames(t *testing.T) { + references := registryReferences(t, "Mira Thorn") + validator := newValidator(t, references) + value := validList(" mira thorn ") + result, err := validator.Validate(context.Background(), request(references, value)) + if err != nil || !result.Approved { + t.Fatalf("recognized result = %#v, %v", result, err) + } + value.Interactions[0].Name = "Unknown NPC" + result, err = validator.Validate(context.Background(), request(references, value)) + if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "interactions[0].name") { + t.Fatalf("unknown result = %#v, %v", result, err) + } +} + +func TestValidatorRequiresRegistryAndResolvesGeneratedReferenceAtOperationTime(t *testing.T) { + validator := newValidator(t) + value := validList("Mira Thorn") + result, err := validator.Validate(context.Background(), request(contracts.ReferenceSet{}, value)) + if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "required") { + t.Fatalf("unbound result = %#v, %v", result, err) + } + references := registryReferences(t, "Mira Thorn") + result, err = validator.Validate(context.Background(), request(references, value)) + if err != nil || !result.Approved { + t.Fatalf("generated result = %#v, %v", result, err) + } + + empty := registryReferences(t) + result, err = newValidator(t, empty).Validate(context.Background(), request(empty, dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{}})) + if err != nil || !result.Approved { + t.Fatalf("empty registry result = %#v, %v", result, err) + } +} + +func TestValidatorRejectsMalformedRegistryWithoutContentAndKeepsMetadataSafe(t *testing.T) { + malformed := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ + npcregistry.ReferenceSlot: { + Slot: contracts.ReferenceSlot{Name: npcregistry.ReferenceSlot}, + Items: []contracts.ReferenceItem{{SlotName: npcregistry.ReferenceSlot, MediaType: "application/json", Content: []byte(`{"secret":"private source"}`)}}, + }, + }} + if _, err := New(Options{}, malformed); err == nil || strings.Contains(err.Error(), "private source") { + t.Fatalf("New() error = %v", err) + } + references := registryReferences(t, "Mira Thorn") + validator := newValidator(t, references) + metadata, err := json.Marshal(validator.ManifestMetadata()) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(metadata), "Mira Thorn") || strings.Contains(string(metadata), "other-session") { + t.Fatalf("metadata leaked registry content: %s", metadata) + } + if got := validator.CheckpointFingerprints(); len(got) != 2 || got[0].Value != policy || !strings.HasPrefix(got[1].Value, "sha256:") { + t.Fatalf("CheckpointFingerprints() = %#v", got) + } +} + +func TestValidatorDefersShapeAndDoesNotMutateOrMisregister(t *testing.T) { + references := registryReferences(t, "Mira Thorn") + validator := newValidator(t, references) + malformed := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn"}}} + result, err := validator.Validate(context.Background(), request(references, malformed)) + if err != nil || !result.Approved { + t.Fatalf("shape deferral = %#v, %v", result, err) + } + value := validList("Mira Thorn") + before := value + _, err = validator.Validate(context.Background(), request(references, value)) + if err != nil || !reflect.DeepEqual(value, before) { + t.Fatalf("Validate() mutated value: %#v", value) + } + registry := pipeline.NewValidatorRegistry() + if err := Register(registry); err != nil { + t.Fatal(err) + } + 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.NPCInteractionList) contracts.TypedValidationRequest[dnd.NPCInteractionList] { + return contracts.TypedValidationRequest[dnd.NPCInteractionList]{References: references, Value: value} +} + +func validList(name string) dnd.NPCInteractionList { + return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{ + Name: name, Kind: dnd.NPCInteractionKindDialogue, + SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}, + }}} +} + +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-session", 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/npcinteractions/shape/validator.go b/internal/modules/dnd/validate/npcinteractions/shape/validator.go new file mode 100644 index 0000000..9b29d0f --- /dev/null +++ b/internal/modules/dnd/validate/npcinteractions/shape/validator.go @@ -0,0 +1,106 @@ +// Package shape validates required D&D NPC interaction 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/npc-interactions/shape" + ReasonCode = "invalid_npc_interaction_shape" + policy = "dnd.npc_interactions.validator.shape.v1" +) + +type Options struct{} +type Validator struct{} + +var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*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.NPCInteractionList]) (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.NPCInteractionList) error { + issues := issuesFor(value) + if len(issues) == 0 { + return nil + } + return fmt.Errorf("%s", diagnostics.Aggregate("invalid NPC interaction shape", issues)) +} + +func issuesFor(value dnd.NPCInteractionList) []string { + if value.Interactions == nil { + return []string{"interactions must be present"} + } + issues := make([]string, 0) + for index, interaction := range value.Interactions { + prefix := fmt.Sprintf("interactions[%d]", index) + if strings.TrimSpace(interaction.Name) == "" { + issues = append(issues, prefix+".name must not be empty: "+diagnostics.Quote(interaction.Name)) + } + if !validKind(interaction.Kind) { + issues = append(issues, prefix+".kind is unsupported: "+diagnostics.Quote(string(interaction.Kind))) + } + if len(interaction.SourceRefs) == 0 { + issues = append(issues, prefix+".source_refs must contain at least one reference") + } + } + return issues +} + +func validKind(value dnd.NPCInteractionKind) bool { + switch value { + case dnd.NPCInteractionKindMentioned, + dnd.NPCInteractionKindNoncombatPresence, + dnd.NPCInteractionKindDialogue, + dnd.NPCInteractionKindCombatAlly, + dnd.NPCInteractionKindCombatOpponent, + dnd.NPCInteractionKindOther: + 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.NPCInteractionListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCInteractionList], 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/npcinteractions/shape/validator_test.go b/internal/modules/dnd/validate/npcinteractions/shape/validator_test.go new file mode 100644 index 0000000..208bbf7 --- /dev/null +++ b/internal/modules/dnd/validate/npcinteractions/shape/validator_test.go @@ -0,0 +1,70 @@ +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 TestValidatorOwnsRequiredNameKindAndEvidence(t *testing.T) { + valid := validList() + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{Value: valid}) + if err != nil || !result.Approved { + t.Fatalf("Validate() = %#v, %v", result, err) + } + for _, test := range []struct { + name string + value dnd.NPCInteractionList + want string + }{ + {"missing interactions", dnd.NPCInteractionList{}, "interactions must be present"}, + {"blank name", dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: " ", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: valid.Interactions[0].SourceRefs}}}, "name must not be empty"}, + {"unsupported kind", dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn", Kind: "unsupported", SourceRefs: valid.Interactions[0].SourceRefs}}}, "kind is unsupported"}, + {"missing evidence", dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue}}}, "source_refs must contain"}, + } { + t.Run(test.name, func(t *testing.T) { + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{Value: test.value}) + if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, test.want) { + t.Fatalf("Validate() = %#v, %v; want %q", result, err, test.want) + } + }) + } +} + +func TestValidatorBoundsDiagnosticsPreservesValueAndRegistersTypedContract(t *testing.T) { + value := dnd.NPCInteractionList{Interactions: make([]dnd.NPCInteraction, 24)} + for index := range value.Interactions { + value.Interactions[index] = dnd.NPCInteraction{Name: strings.Repeat("火", 220) + "\n", Kind: "unsupported"} + } + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{Value: value}) + if err != nil || result.Approved || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) || !strings.Contains(result.Message, "additional issue(s) omitted") { + t.Fatalf("Validate() = %#v, %v", result, err) + } + if value.Interactions[0].Name != strings.Repeat("火", 220)+"\n" { + t.Fatal("Validate() mutated input") + } + if got := New(Options{}).CheckpointFingerprints(); !reflect.DeepEqual(got, []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}) { + t.Fatalf("CheckpointFingerprints() = %#v", got) + } + registry := pipeline.NewValidatorRegistry() + if err := Register(registry); err != nil { + t.Fatal(err) + } + if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil { + t.Fatal("DecodeOptions() accepted unknown option") + } +} + +func validList() dnd.NPCInteractionList { + return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{ + Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue, + SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}, + }}} +} diff --git a/internal/modules/dnd/validate/npcinteractions/source_refs/validator.go b/internal/modules/dnd/validate/npcinteractions/source_refs/validator.go new file mode 100644 index 0000000..7c3c3e9 --- /dev/null +++ b/internal/modules/dnd/validate/npcinteractions/source_refs/validator.go @@ -0,0 +1,80 @@ +// Package sourcerefs validates D&D NPC interaction 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" + interactionshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/shape" +) + +const ( + Key = "extract/dnd/npc-interactions/source_refs" + ReasonCode = "invalid_npc_interaction_source_refs" + policy = "dnd.npc_interactions.validator.source_refs.v1" +) + +type Options struct{} +type Validator struct{} + +var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*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.NPCInteractionList]) (contracts.ValidationResult, error) { + if interactionshape.Validate(req.Value) != nil { + return contracts.ValidationResult{Approved: true}, nil + } + issues := make([]string, 0) + for interactionIndex, interaction := range req.Value.Interactions { + for refIndex, ref := range interaction.SourceRefs { + if err := source.ValidateRef(req.Source, ref); err != nil { + issues = append(issues, fmt.Sprintf("interactions[%d].source_refs[%d]: %s", interactionIndex, refIndex, diagnostics.Truncate(err.Error()))) + } + } + } + if len(issues) == 0 { + return contracts.ValidationResult{Approved: true}, nil + } + return contracts.ValidationResult{ + Approved: false, + ReasonCode: ReasonCode, + Message: diagnostics.Aggregate("invalid NPC interaction 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.NPCInteractionListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCInteractionList], 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/npcinteractions/source_refs/validator_test.go b/internal/modules/dnd/validate/npcinteractions/source_refs/validator_test.go new file mode 100644 index 0000000..f5c2792 --- /dev/null +++ b/internal/modules/dnd/validate/npcinteractions/source_refs/validator_test.go @@ -0,0 +1,66 @@ +package sourcerefs + +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 TestValidatorOwnsCurrentSourceUnitAndRangeValidation(t *testing.T) { + value := validList() + result, err := New(Options{}).Validate(context.Background(), request(document(), value)) + if err != nil || !result.Approved { + t.Fatalf("valid result = %#v, %v", result, err) + } + value.Interactions[0].SourceRefs = []source.SourceRef{ + {SourceID: "foreign", StartUnitID: 1, EndUnitID: 1}, + {SourceID: "session", StartUnitID: 99, EndUnitID: 99}, + {SourceID: "session", StartUnitID: 2, EndUnitID: 1}, + } + result, err = New(Options{}).Validate(context.Background(), request(document(), value)) + if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "interactions[0].source_refs[0]") { + t.Fatalf("invalid result = %#v, %v", result, err) + } +} + +func TestValidatorDefersShapeAndDoesNotMutate(t *testing.T) { + malformed := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn"}}} + result, err := New(Options{}).Validate(context.Background(), request(document(), malformed)) + if err != nil || !result.Approved { + t.Fatalf("shape deferral = %#v, %v", result, err) + } + value := validList() + before := value + _, err = New(Options{}).Validate(context.Background(), request(document(), value)) + if err != nil || !reflect.DeepEqual(value, before) { + t.Fatalf("Validate() mutated value: %#v", value) + } + 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("CheckpointFingerprints() = %#v", got) + } +} + +func request(doc *source.SourceDocument, value dnd.NPCInteractionList) contracts.TypedValidationRequest[dnd.NPCInteractionList] { + return contracts.TypedValidationRequest[dnd.NPCInteractionList]{Source: doc, Value: value} +} + +func document() *source.SourceDocument { + return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}}} +} + +func validList() dnd.NPCInteractionList { + return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{ + Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue, + SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 2}}, + }}} +} diff --git a/internal/modules/dnd/validate/npcinteractions/source_relatedness/validator.go b/internal/modules/dnd/validate/npcinteractions/source_relatedness/validator.go new file mode 100644 index 0000000..f5a0886 --- /dev/null +++ b/internal/modules/dnd/validate/npcinteractions/source_relatedness/validator.go @@ -0,0 +1,84 @@ +// Package sourcerelatedness warns about NPC interaction evidence unrelated to its NPC. +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" + interactionshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/shape" +) + +const ( + Key = "extract/dnd/npc-interactions/source_relatedness" + WarningReasonCode = "npc_interaction_not_near_source" + policy = "dnd.npc_interactions.validator.source_relatedness.v1" +) + +type Options struct{} +type Validator struct{} + +var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*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.NPCInteractionList]) (contracts.ValidationResult, error) { + if interactionshape.Validate(req.Value) != nil { + return contracts.ValidationResult{Approved: true}, nil + } + citedTexts := make([]string, len(req.Value.Interactions)) + for index, interaction := range req.Value.Interactions { + citedText, err := shared.CitedText(req.Source, interaction.SourceRefs) + if err != nil { + return contracts.ValidationResult{Approved: true}, nil + } + citedTexts[index] = citedText + } + warnings := make([]contracts.Warning, 0) + for index, interaction := range req.Value.Interactions { + if shared.ContainsTokenSequence(citedTexts[index], interaction.Name) { + continue + } + warnings = append(warnings, contracts.Warning{ + Scope: fmt.Sprintf("interactions[%d]", index), + ReasonCode: WarningReasonCode, + Message: fmt.Sprintf("NPC interaction name %s was not found in cited source text", diagnostics.Quote(interaction.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.NPCInteractionListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCInteractionList], 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/npcinteractions/source_relatedness/validator_test.go b/internal/modules/dnd/validate/npcinteractions/source_relatedness/validator_test.go new file mode 100644 index 0000000..e410de0 --- /dev/null +++ b/internal/modules/dnd/validate/npcinteractions/source_relatedness/validator_test.go @@ -0,0 +1,51 @@ +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 TestValidatorUsesOnlyCurrentTranscriptAndWarnsOncePerInteraction(t *testing.T) { + value := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{ + {Name: "O'Rin Thorn", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}, + {Name: "Missing\nNPC", Kind: dnd.NPCInteractionKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}, {SourceID: "session", StartUnitID: 2, EndUnitID: 2}}}, + }} + doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "O’Rin Thorn speaks."}, {ID: 2, Text: "The party waits."}}} + references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{"glossary": {Items: []contracts.ReferenceItem{{Content: []byte("Missing NPC")}}}}} + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{Source: doc, References: references, Value: value}) + if err != nil || !result.Approved || len(result.Warnings) != 1 { + t.Fatalf("Validate() = %#v, %v", result, err) + } + if warning := result.Warnings[0]; warning.Scope != "interactions[1]" || warning.ReasonCode != WarningReasonCode || !strings.Contains(warning.Message, `Missing\nNPC`) { + t.Fatalf("warning = %#v", warning) + } +} + +func TestValidatorDefersMalformedShapeAndInvalidRanges(t *testing.T) { + for _, value := range []dnd.NPCInteractionList{ + {Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn"}}}, + {Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}}, + } { + result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{Source: &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}}}, Value: value}) + if err != nil || !result.Approved || len(result.Warnings) != 0 { + t.Fatalf("deferral = %#v, %v", result, err) + } + } + value := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}} + before := value + _, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{Source: &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "Mira Thorn"}}}, Value: value}) + if err != nil || !reflect.DeepEqual(value, before) { + t.Fatalf("Validate() mutated value: %#v", value) + } + registry := pipeline.NewValidatorRegistry() + if err := Register(registry); err != nil { + t.Fatal(err) + } +}