Improve D&D validation reliability

This commit is contained in:
2026-08-29 01:24:45 +00:00
parent 4da9360d74
commit 917d150279
55 changed files with 1300 additions and 565 deletions

View File

@@ -14,9 +14,10 @@ import (
)
const (
Key = "normalize/dnd/item-registry/identity"
ReasonCode = "invalid_item_identity"
policy = domainidentity.Policy
Key = "normalize/dnd/item-registry/identity"
ReasonCode = "invalid_item_identity"
policy = domainidentity.Policy
correctionPolicy = "dnd.item_registry.validator.identity.v2"
)
type Options struct{}
@@ -31,7 +32,7 @@ func (v *Validator) ExecutionClass() contracts.ExecutionClass {
return contracts.ExecutionClassDeterministic
}
func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}, {Name: "correction_policy", Value: correctionPolicy}}
}
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemRegistry]) (contracts.ValidationResult, error) {
@@ -43,10 +44,31 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
return contracts.ValidationResult{Approved: true}, nil
}
issues := make([]string, len(identityIssues))
var corrections diagnostics.Corrections
for index, issue := range identityIssues {
issues[index] = fmt.Sprintf("items[%d] %s: %s", issue.RecordIndex, issue.Code, diagnostics.Quote(issue.Value))
if issue.RecordIndex < 0 || issue.RecordIndex >= len(req.Value.Items) {
continue
}
item := req.Value.Items[issue.RecordIndex]
corrections.Add(string(issue.Code), itemIdentityCorrection(issue.Code), fmt.Sprintf("Affected item %s %s.", diagnostics.Quote(item.Name), diagnostics.SourceRange(item.SourceRefs)))
}
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid item identity", issues), CorrectionGuidance: corrections.Guidance("Correct the duplicate-group proposals and return the complete replacement proposal response")}, nil
}
func itemIdentityCorrection(code domainidentity.IssueCode) string {
switch code {
case domainidentity.IssueEmptyCanonicalName:
return "Select a canonical proposal member with a nonblank, transcript-supported item name."
case domainidentity.IssueDuplicateCanonical:
return "Put duplicate mentions of the same item in one proposal group and select one transcript-supported canonical member."
case domainidentity.IssueInvalidID, domainidentity.IssueIDMismatch:
return "Revise the proposal so its canonical item member has a valid transcript-supported name; Notarius derives durable identity without model input."
case domainidentity.IssueDuplicateID:
return "Do not use proposals that collapse distinct items into one canonical identity; group only records that describe the same item."
default:
return "Revise the duplicate-group proposal so every canonical item is transcript-supported and distinct."
}
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid item identity", issues), CorrectionGuidance: "Return one canonical registry entry per distinct properly named item, combining duplicate mentions under the same transcript-supported name."}, nil
}
func Spec() pipeline.ValidatorSpec {

View File

@@ -21,6 +21,9 @@ func TestValidatorRejectsInvalidAndDuplicateItemIdentity(t *testing.T) {
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "invalid_id") || !strings.Contains(result.Message, "duplicate_canonical_identity") {
t.Fatalf("Validate() = %#v, %v; want identity rejection", result, err)
}
if !strings.Contains(result.CorrectionGuidance, "duplicate-group proposals") || !strings.Contains(result.CorrectionGuidance, "Rope") || strings.Contains(result.CorrectionGuidance, "wrong") || strings.Contains(result.CorrectionGuidance, "items[") {
t.Fatalf("CorrectionGuidance = %q, want contextual proposal guidance without IDs or operator paths", result.CorrectionGuidance)
}
}
func TestValidatorRegistersPolicy(t *testing.T) {
@@ -31,7 +34,7 @@ func TestValidatorRegistersPolicy(t *testing.T) {
if got, ok := registry.Spec(Key); !ok || got != Spec() || got.ExecutionClass != contracts.ExecutionClassDeterministic {
t.Fatalf("registered spec = %#v, present = %t", got, ok)
}
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Value != domainidentity.Policy {
if got := New(Options{}).CheckpointFingerprints(); len(got) != 2 || got[0].Value != domainidentity.Policy || got[1].Name != "correction_policy" || got[1].Value != correctionPolicy {
t.Fatalf("fingerprints = %#v", got)
}
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {

View File

@@ -15,7 +15,7 @@ import (
const (
Key = "extract/dnd/item-registry/shape"
ReasonCode = "invalid_item_shape"
policy = "dnd.item_registry.validator.shape.v1"
policy = "dnd.item_registry.validator.shape.v2"
)
type Options struct{}
@@ -34,39 +34,58 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
}
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemRegistry]) (contracts.ValidationResult, error) {
issues := issuesFor(req.Value)
normalization := req.Stage == string(pipeline.StageNormalize)
issues, corrections := assess(req.Value, normalization)
if len(issues) > 0 {
return rejection(diagnostics.Aggregate("invalid item shape", issues)), nil
prefix := "Correct every rejected item and return the complete replacement registry"
if normalization {
prefix = "Correct the duplicate-group proposals and return the complete replacement proposal response"
}
return rejection(diagnostics.Aggregate("invalid item shape", issues), corrections.Guidance(prefix)), nil
}
return contracts.ValidationResult{Approved: true}, nil
}
func Validate(value dnd.ItemRegistry) error {
issues := issuesFor(value)
issues, _ := assess(value, false)
if len(issues) == 0 {
return nil
}
return fmt.Errorf("%s", diagnostics.Aggregate("invalid item shape", issues))
}
func issuesFor(value dnd.ItemRegistry) []string {
func assess(value dnd.ItemRegistry, normalization bool) ([]string, diagnostics.Corrections) {
var corrections diagnostics.Corrections
nameRule := "Provide one nonblank, transcript-supported item name for every registry entry."
refRule := "Provide at least one transcript source range that directly supports every named item."
listRule := "Return an `items` array; use an empty array when the transcript establishes no named items."
if normalization {
nameRule = "Revise the duplicate-group proposals so every selected canonical item has a nonblank, transcript-supported name."
refRule = "Revise the duplicate-group proposals so every selected canonical item preserves direct transcript evidence."
listRule = "Revise the duplicate-group proposals so normalization retains the complete item candidate registry."
}
if value.Items == nil {
return []string{"items must be present"}
corrections.Add("list", listRule, "")
return []string{"items must be present"}, corrections
}
issues := make([]string, 0)
for index, item := range value.Items {
prefix := fmt.Sprintf("items[%d]", index)
record := "Affected item " + diagnostics.Quote(strings.TrimSpace(item.Name)) + " " + diagnostics.SourceRange(item.SourceRefs) + "."
if strings.TrimSpace(item.ID) == "" {
issues = append(issues, prefix+".id must not be empty")
corrections.Add("name", nameRule, record)
}
if strings.TrimSpace(item.Name) == "" {
issues = append(issues, prefix+".name must not be empty")
corrections.Add("name", nameRule, record)
}
if len(item.SourceRefs) == 0 {
issues = append(issues, prefix+".source_refs must not be empty")
corrections.Add("source-refs", refRule, record)
}
}
return issues
return issues, corrections
}
func Spec() pipeline.ValidatorSpec {
@@ -92,6 +111,6 @@ func DecodeOptions(options map[string]any) (Options, error) {
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, CorrectionGuidance: "Return a complete item registry containing only properly named items with valid source references."}
func rejection(message, guidance string) contracts.ValidationResult {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: guidance}
}

View File

@@ -9,6 +9,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
itemshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemregistry/shape"
)
@@ -16,7 +17,7 @@ import (
const (
Key = "extract/dnd/item-registry/source_refs"
ReasonCode = "invalid_item_source_refs"
policy = "dnd.item_registry.validator.source_refs.v1"
policy = "dnd.item_registry.validator.source_refs.v2"
)
type Options struct{}
@@ -42,35 +43,37 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
return contracts.ValidationResult{Approved: true}, nil
}
index := source.NewDocumentIndex(req.Source)
coverage := shared.NewChunkCoverage(req.Chunk)
issues := make([]string, 0)
var corrections diagnostics.Corrections
normalization := req.Stage == string(pipeline.StageNormalize)
validRangeRule := "Use positive source range endpoints that occur in the supplied transcript, with the earlier unit first."
guidancePrefix := "Correct every rejected item citation and return the complete replacement item registry"
if normalization {
validRangeRule = "Revise the duplicate-group proposals so every selected canonical item preserves valid transcript evidence; do not reproduce application IDs."
guidancePrefix = "Correct the duplicate-group proposals and return the complete replacement proposal response"
}
for itemIndex, item := range req.Value.Items {
for refIndex, ref := range item.SourceRefs {
record := fmt.Sprintf("Affected item %s, citing %s.", diagnostics.Quote(item.Name), diagnostics.SourceRefRange(ref))
if err := index.ValidateRef(ref); err != nil {
issues = append(issues, fmt.Sprintf("items[%d].source_refs[%d]: %s", itemIndex, refIndex, diagnostics.Truncate(err.Error())))
corrections.Add("valid-range", validRangeRule, record)
continue
}
if req.Stage == string(pipeline.StageExtract) && !chunkContainsRef(req.Chunk, ref) {
if req.Stage == string(pipeline.StageExtract) && !coverage.Contains(index, req.Source, ref) {
issues = append(issues, fmt.Sprintf("items[%d].source_refs[%d]: source reference is outside the current extraction chunk", itemIndex, refIndex))
corrections.Add("chunk-range", "Use only source ranges wholly contained in the supplied extraction chunk.", record)
}
}
}
if len(issues) == 0 {
return contracts.ValidationResult{Approved: true}, nil
}
return rejection(diagnostics.Aggregate("invalid item source references", issues)), nil
}
func chunkContainsRef(chunk *source.Chunk, ref source.SourceRef) bool {
if chunk == nil || ref.SourceID != chunk.SourceID {
return false
}
startFound := false
endFound := false
for _, unit := range chunk.Units {
startFound = startFound || unit.ID == ref.StartUnitID
endFound = endFound || unit.ID == ref.EndUnitID
}
return startFound && endFound
return rejection(
diagnostics.Aggregate("invalid item source references", issues),
corrections.Guidance(guidancePrefix),
), nil
}
func Spec() pipeline.ValidatorSpec {
@@ -96,6 +99,6 @@ func DecodeOptions(options map[string]any) (Options, error) {
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, CorrectionGuidance: "Return registry items whose source references identify valid transcript ranges within the supplied extraction chunk and directly support each named item."}
func rejection(message, guidance string) contracts.ValidationResult {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: guidance}
}

View File

@@ -47,6 +47,17 @@ func TestValidatorDefersMalformedShapeAndRegisters(t *testing.T) {
}
}
func TestValidatorRequiresEveryUnitInCitedSpanToBeInChunk(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}, {ID: 20}}}
value := validItemRegistry()
value.Items[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 20}}
chunk := &source.Chunk{SourceID: doc.ID, Units: []source.SourceUnit{{ID: 30}, {ID: 20}}}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemRegistry]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: chunk, Value: value})
if err != nil || result.Approved || !strings.Contains(result.Message, "outside the current extraction chunk") {
t.Fatalf("Validate() = %#v, %v; want missing-middle-unit rejection", result, err)
}
}
func request(doc *source.SourceDocument, value dnd.ItemRegistry) contracts.TypedValidationRequest[dnd.ItemRegistry] {
return contracts.TypedValidationRequest[dnd.ItemRegistry]{Source: doc, Value: value}
}