Align validator identity and span domain checks

This commit is contained in:
2026-09-25 02:48:57 +00:00
parent 31979d4081
commit 640f663ace
19 changed files with 148 additions and 49 deletions

View File

@@ -23,7 +23,8 @@ Deterministic:
- `proposal_shape`
- `confidence_threshold`
- `original_text_presence`
- `non_empty_corrected_text`
- `non_empty_resulting_segment` — rejects edits that would leave the segment
blank, regardless of replacement-span length.
- `no_effect`
- `protected_terms`

View File

@@ -2,7 +2,7 @@
**Last updated:** 2026-09-25
**Status:** Ready for implementation; no stages completed.
**Status:** In progress; Stage 1 complete.
**Source baseline:** `a83a518` (feature roadmap); application baseline `0b6cd98`.
**Audience:** gpt-5.6-terra coding agent.
@@ -192,6 +192,11 @@ Inspect remaining old-name references, retaining only deliberately historical on
**End state:** The accurately named domain validator and application preserve the
agreed behavior. No schema or retry workaround has been introduced.
**Completed 2026-09-25.** Renamed the validator, admitted nonempty whitespace
original spans, and verified literal spacing, blank-result protection, protected
terms, module-policy ambiguity, and apply-time conflict attribution. The specified
focused package tests passed.
## Stage 2 — Align Response Schema, Prompts, and LLM Validation Inputs
**Work**

View File

@@ -109,6 +109,26 @@ func TestApplyProposalsStaleProposalSkippedAfterEarlierChange(t *testing.T) {
}
}
func TestApplyProposalsRechecksBlankResultAfterEarlierDeletion(t *testing.T) {
transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "a b"}}}
proposed := []EnrichedCorrectionProposal{
{CorrectionProposal: CorrectionProposal{TargetSegmentID: 1, OriginalText: "a", CorrectedText: "", Confidence: 1}, ProposalMetadata: ProposalMetadata{ProposalIndex: 0}},
{CorrectionProposal: CorrectionProposal{TargetSegmentID: 1, OriginalText: "b", CorrectedText: "", Confidence: 1}, ProposalMetadata: ProposalMetadata{ProposalIndex: 1}},
}
for _, candidate := range proposed {
if preview := PreviewProposalForSegment(&transcript.Segments[0], candidate.CorrectionProposal, ReplacementPolicyRequireUnique); !preview.Applicable {
t.Fatalf("proposal must be initially applicable: %+v", preview)
}
}
result := ApplyProposals(transcript, proposed, ReplacementPolicyRequireUnique)
if got := result.Transcript.Segments[0].Text; got != " b" {
t.Fatalf("expected literal surviving text, got %q", got)
}
if len(result.Applied) != 1 || len(result.Skipped) != 1 || result.Skipped[0].SkipReason != SkipReasonEmptyResultingText {
t.Fatalf("expected second deletion skipped after reevaluation: %+v", result)
}
}
func TestApplyProposalsMissingSegmentSkip(t *testing.T) {
transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "hello"}}}
proposals := []EnrichedCorrectionProposal{

View File

@@ -139,6 +139,30 @@ func TestPreviewProposalForSegmentAllowsEmptyCorrectedTextWhenSegmentRemainsNonE
}
}
func TestPreviewProposalForSegmentWhitespaceSpan(t *testing.T) {
proposal := CorrectionProposal{TargetSegmentID: 8, OriginalText: " ", CorrectedText: " ", Confidence: 0.9}
unique := PreviewProposalForSegment(&schema.Segment{ID: 8, Text: "hello world"}, proposal, ReplacementPolicyRequireUnique)
if !unique.Applicable || unique.CorrectedSegmentText != "hello world" || unique.ReplacementCount != 1 {
t.Fatalf("unexpected spacing preview: %+v", unique)
}
segment := &schema.Segment{ID: 8, Text: "a b c"}
ambiguous := PreviewProposalForSegment(segment, proposal, ReplacementPolicyRequireUnique)
if ambiguous.Applicable || ambiguous.SkipReason != SkipReasonAmbiguousOriginal {
t.Fatalf("expected ambiguity under require_unique: %+v", ambiguous)
}
all := PreviewProposalForSegment(segment, proposal, ReplacementPolicyReplaceAll)
if !all.Applicable || all.CorrectedSegmentText != "a b c" || all.ReplacementCount != 2 {
t.Fatalf("expected replace_all to correct both spans: %+v", all)
}
}
func TestPreviewProposalForSegmentRejectsEmptyOriginalText(t *testing.T) {
preview := PreviewProposalForSegment(&schema.Segment{ID: 8, Text: "hello"}, CorrectionProposal{TargetSegmentID: 8, OriginalText: "", CorrectedText: "x", Confidence: 0.9}, ReplacementPolicyRequireUnique)
if preview.Applicable || preview.SkipReason != SkipReasonInvalidProposal {
t.Fatalf("expected invalid empty original: %+v", preview)
}
}
func TestPreviewProposalForSegmentRejectsEmptyResultingSegment(t *testing.T) {
segment := &schema.Segment{ID: 8, Text: "uh"}
proposal := CorrectionProposal{

View File

@@ -2,7 +2,6 @@ package proposals
import (
"fmt"
"strings"
)
// CorrectionProposal is the base proposal emitted by correction modules.
@@ -35,7 +34,7 @@ func (p CorrectionProposal) Validate() error {
if p.TargetSegmentID <= 0 {
return fmt.Errorf("proposal id must be positive")
}
if strings.TrimSpace(p.OriginalText) == "" {
if p.OriginalText == "" {
return fmt.Errorf("proposal original_text must not be empty")
}
if p.Confidence < 0.0 || p.Confidence > 1.0 {

View File

@@ -48,6 +48,13 @@ func TestCorrectionProposalValidate_AllowsEmptyCorrectedText(t *testing.T) {
}
}
func TestCorrectionProposalValidate_AllowsWhitespaceOriginalText(t *testing.T) {
proposal := CorrectionProposal{TargetSegmentID: 42, OriginalText: " ", CorrectedText: " ", Confidence: 0.95}
if err := proposal.Validate(); err != nil {
t.Fatalf("expected whitespace span to be valid, got %v", err)
}
}
func TestCorrectionProposalValidate_InvalidConfidence(t *testing.T) {
proposal := CorrectionProposal{
TargetSegmentID: 42,

View File

@@ -873,6 +873,36 @@ func TestRunnerApplicationSkipAfterValidatorApprovalReported(t *testing.T) {
}
}
func TestRunnerApplyConflictAfterTwoInitiallyValidDeletions(t *testing.T) {
transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "a b"}}}
r := New(fakeFactory{modules: map[string]contracts.TranscriptModule{
"m": fakeModule{key: "m", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{validators.ProposalShapeValidator{}, validators.NonEmptyResultingSegmentValidator{}}, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
return []proposals.CorrectionProposal{
{TargetSegmentID: 1, OriginalText: "a", CorrectedText: "", Confidence: 1},
{TargetSegmentID: 1, OriginalText: "b", CorrectedText: "", Confidence: 1},
}, nil
}},
}})
out, err := r.Run(context.Background(), RunInput{Transcript: transcript, ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "m", InstanceName: "m"}}, CaptureCandidates: true})
if err != nil {
t.Fatal(err)
}
if got := out.FinalTranscript.Segments[0].Text; got != " b" {
t.Fatalf("expected first deletion only, got %q", got)
}
if len(out.ModuleResults) != 1 || len(out.ModuleResults[0].SkippedChanges) != 1 || out.ModuleResults[0].SkippedChanges[0].SkipReason != proposals.SkipReasonEmptyResultingText {
t.Fatalf("expected skipped debug evidence: %+v", out.ModuleResults)
}
if len(out.ModuleResults[0].ValidatorRejected) != 0 || len(out.WorkUnits) != 1 || out.WorkUnits[0].CorrectionsApplied != 1 {
t.Fatalf("unexpected validation or application outcome: %+v", out)
}
for _, operation := range out.WorkUnits[0].Operations {
if operation.FailureReason != "" || len(operation.RecoveryReasons) != 0 {
t.Fatalf("apply-time conflict must not count as model error or filtering recovery: %+v", operation)
}
}
}
func ptrConfig(c config.Config) *config.Config { return &c }
type fakeStructuredClient struct {

View File

@@ -19,7 +19,7 @@ func (v ProposalShapeValidator) Validate(_ context.Context, req Request) (Result
switch {
case c.TargetSegmentID <= 0:
decisions = append(decisions, rejection(c.ProposalIndex, ReasonInvalidTargetSegment, "proposal target segment id must be positive"))
case strings.TrimSpace(c.OriginalText) == "":
case c.OriginalText == "":
decisions = append(decisions, rejection(c.ProposalIndex, ReasonEmptyOriginalText, "proposal original_text must not be empty"))
case c.Confidence < 0.0 || c.Confidence > 1.0:
decisions = append(decisions, rejection(c.ProposalIndex, ReasonInvalidConfidence, "proposal confidence must be between 0.0 and 1.0"))
@@ -84,11 +84,11 @@ func (v OriginalTextPresenceValidator) Validate(_ context.Context, req Request)
return Result{ValidatorName: v.Name(), Decisions: decisions}, nil
}
type NonEmptyCorrectionValidator struct{}
type NonEmptyResultingSegmentValidator struct{}
func (v NonEmptyCorrectionValidator) Name() string { return "non_empty_corrected_text" }
func (v NonEmptyResultingSegmentValidator) Name() string { return "non_empty_resulting_segment" }
func (v NonEmptyCorrectionValidator) Validate(_ context.Context, req Request) (Result, error) {
func (v NonEmptyResultingSegmentValidator) Validate(_ context.Context, req Request) (Result, error) {
segmentsByID := make(map[int]schema.Segment)
if req.WorkingTranscript != nil {
for _, seg := range req.WorkingTranscript.Segments {

View File

@@ -46,13 +46,17 @@ func TestValidateProtectedTermsAppliesToNonGlossaryModule(t *testing.T) {
},
ProposalMetadata: proposals.ProposalMetadata{ProposalIndex: 0},
},
{
CorrectionProposal: proposals.CorrectionProposal{TargetSegmentID: 1, OriginalText: "Jesters", CorrectedText: "", Confidence: 0.9},
ProposalMetadata: proposals.ProposalMetadata{ProposalIndex: 1},
},
},
}
res, err := ValidateProtectedTerms(req, false)
if err != nil {
t.Fatalf("validator error: %v", err)
}
if len(res.Decisions) != 1 || res.Decisions[0].Approved {
if len(res.Decisions) != 2 || res.Decisions[0].Approved || res.Decisions[1].Approved || res.Decisions[1].ReasonCode != ReasonProtectedGlossaryTerm {
t.Fatalf("expected protected-term rejection, got %+v", res.Decisions)
}
}

View File

@@ -72,8 +72,9 @@ func TestProposalShapeValidator(t *testing.T) {
req := Request{CandidateProposal: []proposals.EnrichedCorrectionProposal{
mkCandidate(0, 1, "teh", "the", 0.9),
mkCandidate(1, 0, "teh", "the", 0.9),
mkCandidate(2, 1, " ", "the", 0.9),
mkCandidate(2, 1, "", "the", 0.9),
mkCandidate(3, 1, "teh", "the", 1.5),
mkCandidate(4, 1, " ", " ", 0.9),
}}
res, err := (ProposalShapeValidator{}).Validate(context.Background(), req)
if err != nil {
@@ -91,6 +92,9 @@ func TestProposalShapeValidator(t *testing.T) {
if res.Decisions[3].ReasonCode != ReasonInvalidConfidence {
t.Fatalf("expected invalid confidence rejection, got %+v", res.Decisions[3])
}
if !res.Decisions[4].Approved {
t.Fatalf("expected whitespace original approved, got %+v", res.Decisions[4])
}
}
func TestOriginalTextPresenceValidator(t *testing.T) {
@@ -114,15 +118,17 @@ func TestOriginalTextPresenceValidator(t *testing.T) {
}
}
func TestNonEmptyCorrectionValidator(t *testing.T) {
func TestNonEmptyResultingSegmentValidator(t *testing.T) {
req := Request{
WorkingTranscript: &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "hello world"}}},
ReplacementPolicy: proposals.ReplacementPolicyRequireUnique,
CandidateProposal: []proposals.EnrichedCorrectionProposal{
mkCandidate(0, 1, "hello", "hi", 0.9),
mkCandidate(1, 1, "hello world", " ", 0.9),
mkCandidate(2, 1, "hello ", "", 0.9),
mkCandidate(3, 1, "hello world", "", 0.9),
}}
res, err := (NonEmptyCorrectionValidator{}).Validate(context.Background(), req)
res, err := (NonEmptyResultingSegmentValidator{}).Validate(context.Background(), req)
if err != nil {
t.Fatalf("Validate error: %v", err)
}
@@ -132,6 +138,9 @@ func TestNonEmptyCorrectionValidator(t *testing.T) {
if res.Decisions[1].ReasonCode != ReasonEmptyResultingText {
t.Fatalf("expected empty_resulting_segment, got %+v", res.Decisions[1])
}
if !res.Decisions[2].Approved || res.Decisions[3].ReasonCode != ReasonEmptyResultingText {
t.Fatalf("unexpected deletion decisions: %+v", res.Decisions)
}
}
func TestNoEffectValidator(t *testing.T) {

View File

@@ -78,7 +78,7 @@ func TestGlossaryModuleValidatorChain(t *testing.T) {
"original_text_presence",
"confidence_threshold",
"protected_terms",
"non_empty_corrected_text",
"non_empty_resulting_segment",
"spoken_form_plausibility",
"meaning_reversal_review",
}

View File

@@ -88,7 +88,7 @@ func TestGrammarModuleValidatorChain(t *testing.T) {
"original_text_presence",
"confidence_threshold",
"protected_terms",
"non_empty_corrected_text",
"non_empty_resulting_segment",
"editorial_review",
"meaning_reversal_review",
}

View File

@@ -79,7 +79,7 @@ func TestHomophonesModuleValidatorChain(t *testing.T) {
"original_text_presence",
"confidence_threshold",
"protected_terms",
"non_empty_corrected_text",
"non_empty_resulting_segment",
"spoken_form_plausibility",
"meaning_reversal_review",
}

View File

@@ -79,7 +79,7 @@ func TestSpokenWordModuleValidatorChain(t *testing.T) {
"original_text_presence",
"confidence_threshold",
"protected_terms",
"non_empty_corrected_text",
"non_empty_resulting_segment",
"editorial_review",
"meaning_reversal_review",
}

View File

@@ -16,7 +16,7 @@ var builtInChains = map[string][]string{
KeyOriginalTextPresence,
KeyConfidenceThreshold,
KeyProtectedTerms,
KeyNonEmptyCorrectedText,
KeyNonEmptyResultingSegment,
KeySpokenFormPlausibility,
KeyMeaningReversalReview,
},
@@ -26,7 +26,7 @@ var builtInChains = map[string][]string{
KeyOriginalTextPresence,
KeyConfidenceThreshold,
KeyProtectedTerms,
KeyNonEmptyCorrectedText,
KeyNonEmptyResultingSegment,
KeySpokenFormPlausibility,
KeyMeaningReversalReview,
},
@@ -36,7 +36,7 @@ var builtInChains = map[string][]string{
KeyOriginalTextPresence,
KeyConfidenceThreshold,
KeyProtectedTerms,
KeyNonEmptyCorrectedText,
KeyNonEmptyResultingSegment,
KeyEditorialReview,
KeyMeaningReversalReview,
},
@@ -46,7 +46,7 @@ var builtInChains = map[string][]string{
KeyOriginalTextPresence,
KeyConfidenceThreshold,
KeyProtectedTerms,
KeyNonEmptyCorrectedText,
KeyNonEmptyResultingSegment,
KeyEditorialReview,
KeyMeaningReversalReview,
},

View File

@@ -14,12 +14,12 @@ const (
)
const (
KeyProposalShape = "proposal_shape"
KeyConfidenceThreshold = "confidence_threshold"
KeyOriginalTextPresence = "original_text_presence"
KeyNonEmptyCorrectedText = "non_empty_corrected_text"
KeyNoEffect = "no_effect"
KeyProtectedTerms = "protected_terms"
KeyProposalShape = "proposal_shape"
KeyConfidenceThreshold = "confidence_threshold"
KeyOriginalTextPresence = "original_text_presence"
KeyNonEmptyResultingSegment = "non_empty_resulting_segment"
KeyNoEffect = "no_effect"
KeyProtectedTerms = "protected_terms"
KeySpokenFormPlausibility = "spoken_form_plausibility"
KeyMeaningReversalReview = "meaning_reversal_review"
@@ -27,15 +27,15 @@ const (
)
var executionClassByKey = map[string]ExecutionClass{
KeyProposalShape: ExecutionClassDeterministic,
KeyConfidenceThreshold: ExecutionClassDeterministic,
KeyOriginalTextPresence: ExecutionClassDeterministic,
KeyNonEmptyCorrectedText: ExecutionClassDeterministic,
KeyNoEffect: ExecutionClassDeterministic,
KeyProtectedTerms: ExecutionClassDeterministic,
KeySpokenFormPlausibility: ExecutionClassLLMBacked,
KeyMeaningReversalReview: ExecutionClassLLMBacked,
KeyEditorialReview: ExecutionClassLLMBacked,
KeyProposalShape: ExecutionClassDeterministic,
KeyConfidenceThreshold: ExecutionClassDeterministic,
KeyOriginalTextPresence: ExecutionClassDeterministic,
KeyNonEmptyResultingSegment: ExecutionClassDeterministic,
KeyNoEffect: ExecutionClassDeterministic,
KeyProtectedTerms: ExecutionClassDeterministic,
KeySpokenFormPlausibility: ExecutionClassLLMBacked,
KeyMeaningReversalReview: ExecutionClassLLMBacked,
KeyEditorialReview: ExecutionClassLLMBacked,
}
type ClassifiedValidator interface {

View File

@@ -1,4 +1,4 @@
package non_empty_corrected_text
package non_empty_resulting_segment
import (
"gitea.maximumdirect.net/eric/audita/internal/framework/contracts"
@@ -6,8 +6,8 @@ import (
validatormetadata "gitea.maximumdirect.net/eric/audita/internal/validators/metadata"
)
const Key = "non_empty_corrected_text"
const Key = "non_empty_resulting_segment"
func New() (contracts.Validator, error) {
return validatormetadata.Wrap(frameworkvalidators.NonEmptyCorrectionValidator{}, validatormetadata.ExecutionClassDeterministic), nil
return validatormetadata.Wrap(frameworkvalidators.NonEmptyResultingSegmentValidator{}, validatormetadata.ExecutionClassDeterministic), nil
}

View File

@@ -10,7 +10,7 @@ import (
"gitea.maximumdirect.net/eric/audita/internal/validators/meaning_reversal_review"
validatormetadata "gitea.maximumdirect.net/eric/audita/internal/validators/metadata"
"gitea.maximumdirect.net/eric/audita/internal/validators/no_effect"
"gitea.maximumdirect.net/eric/audita/internal/validators/non_empty_corrected_text"
"gitea.maximumdirect.net/eric/audita/internal/validators/non_empty_resulting_segment"
"gitea.maximumdirect.net/eric/audita/internal/validators/original_text_presence"
"gitea.maximumdirect.net/eric/audita/internal/validators/proposal_shape"
"gitea.maximumdirect.net/eric/audita/internal/validators/protected_terms"
@@ -18,12 +18,12 @@ import (
)
const (
KeyProposalShape = validatormetadata.KeyProposalShape
KeyConfidenceThreshold = validatormetadata.KeyConfidenceThreshold
KeyOriginalTextPresence = validatormetadata.KeyOriginalTextPresence
KeyNonEmptyCorrectedText = validatormetadata.KeyNonEmptyCorrectedText
KeyNoEffect = validatormetadata.KeyNoEffect
KeyProtectedTerms = validatormetadata.KeyProtectedTerms
KeyProposalShape = validatormetadata.KeyProposalShape
KeyConfidenceThreshold = validatormetadata.KeyConfidenceThreshold
KeyOriginalTextPresence = validatormetadata.KeyOriginalTextPresence
KeyNonEmptyResultingSegment = validatormetadata.KeyNonEmptyResultingSegment
KeyNoEffect = validatormetadata.KeyNoEffect
KeyProtectedTerms = validatormetadata.KeyProtectedTerms
KeySpokenFormPlausibility = validatormetadata.KeySpokenFormPlausibility
KeyMeaningReversalReview = validatormetadata.KeyMeaningReversalReview
@@ -44,7 +44,7 @@ func NewBuiltInRegistry() *Registry {
{Key: KeyProposalShape, Build: proposal_shape.New},
{Key: KeyConfidenceThreshold, Build: confidence_threshold.New},
{Key: KeyOriginalTextPresence, Build: original_text_presence.New},
{Key: KeyNonEmptyCorrectedText, Build: non_empty_corrected_text.New},
{Key: KeyNonEmptyResultingSegment, Build: non_empty_resulting_segment.New},
{Key: KeyNoEffect, Build: no_effect.New},
{Key: KeyProtectedTerms, Build: protected_terms.New},
{Key: KeySpokenFormPlausibility, Build: spoken_form_plausibility.New},

View File

@@ -13,7 +13,7 @@ import (
"gitea.maximumdirect.net/eric/audita/internal/validators/meaning_reversal_review"
validatormetadata "gitea.maximumdirect.net/eric/audita/internal/validators/metadata"
"gitea.maximumdirect.net/eric/audita/internal/validators/no_effect"
"gitea.maximumdirect.net/eric/audita/internal/validators/non_empty_corrected_text"
"gitea.maximumdirect.net/eric/audita/internal/validators/non_empty_resulting_segment"
"gitea.maximumdirect.net/eric/audita/internal/validators/original_text_presence"
"gitea.maximumdirect.net/eric/audita/internal/validators/proposal_shape"
"gitea.maximumdirect.net/eric/audita/internal/validators/protected_terms"
@@ -26,7 +26,7 @@ func TestBuiltInRegistryRegistersAllKeys(t *testing.T) {
KeyProposalShape,
KeyConfidenceThreshold,
KeyOriginalTextPresence,
KeyNonEmptyCorrectedText,
KeyNonEmptyResultingSegment,
KeyNoEffect,
KeyProtectedTerms,
KeySpokenFormPlausibility,
@@ -58,7 +58,7 @@ func TestBuiltInValidatorPackagesConstruct(t *testing.T) {
{name: "proposal_shape", key: KeyProposalShape, build: proposal_shape.New, wantClass: validatormetadata.ExecutionClassDeterministic},
{name: "confidence_threshold", key: KeyConfidenceThreshold, build: confidence_threshold.New, wantClass: validatormetadata.ExecutionClassDeterministic},
{name: "original_text_presence", key: KeyOriginalTextPresence, build: original_text_presence.New, wantClass: validatormetadata.ExecutionClassDeterministic},
{name: "non_empty_corrected_text", key: KeyNonEmptyCorrectedText, build: non_empty_corrected_text.New, wantClass: validatormetadata.ExecutionClassDeterministic},
{name: "non_empty_resulting_segment", key: KeyNonEmptyResultingSegment, build: non_empty_resulting_segment.New, wantClass: validatormetadata.ExecutionClassDeterministic},
{name: "no_effect", key: KeyNoEffect, build: no_effect.New, wantClass: validatormetadata.ExecutionClassDeterministic},
{name: "protected_terms", key: KeyProtectedTerms, build: protected_terms.New, wantClass: validatormetadata.ExecutionClassDeterministic},
{name: "spoken_form_plausibility", key: KeySpokenFormPlausibility, build: spoken_form_plausibility.New, wantClass: validatormetadata.ExecutionClassLLMBacked},