Complete Phase 8 deterministic validators
This commit is contained in:
161
internal/framework/validators/deterministic.go
Normal file
161
internal/framework/validators/deterministic.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package validators
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/audita/internal/framework/proposals"
|
||||
)
|
||||
|
||||
type ConfidenceThresholdValidator struct{}
|
||||
|
||||
func (v ConfidenceThresholdValidator) Name() string { return "confidence_threshold" }
|
||||
|
||||
func (v ConfidenceThresholdValidator) Validate(_ context.Context, req Request) (Result, error) {
|
||||
threshold := confidenceThresholdForModule(req.ModuleKey, req.Config)
|
||||
decisions := make([]Decision, 0, len(req.CandidateProposal))
|
||||
for _, c := range req.CandidateProposal {
|
||||
if c.Confidence < threshold {
|
||||
decisions = append(decisions, rejection(c.ProposalIndex, ReasonLowConfidence, fmt.Sprintf("confidence %.4f below threshold %.4f", c.Confidence, threshold)))
|
||||
continue
|
||||
}
|
||||
decisions = append(decisions, approval(c.ProposalIndex))
|
||||
}
|
||||
if err := EnforceDecisionCardinality(req.CandidateProposal, decisions); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return Result{ValidatorName: v.Name(), Decisions: decisions}, nil
|
||||
}
|
||||
|
||||
type OriginalTextPresenceValidator struct{}
|
||||
|
||||
func (v OriginalTextPresenceValidator) Name() string { return "original_text_presence" }
|
||||
|
||||
func (v OriginalTextPresenceValidator) Validate(_ context.Context, req Request) (Result, error) {
|
||||
byID := make(map[int]string)
|
||||
if req.WorkingTranscript != nil {
|
||||
for _, seg := range req.WorkingTranscript.Segments {
|
||||
byID[seg.ID] = seg.Text
|
||||
}
|
||||
}
|
||||
|
||||
decisions := make([]Decision, 0, len(req.CandidateProposal))
|
||||
for _, c := range req.CandidateProposal {
|
||||
text, ok := byID[c.TargetSegmentID]
|
||||
if !ok {
|
||||
decisions = append(decisions, rejection(c.ProposalIndex, ReasonMissingTargetSegment, "target segment was not found"))
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(text, c.OriginalText) {
|
||||
decisions = append(decisions, rejection(c.ProposalIndex, ReasonMissingOriginalText, "original_text was not found in current segment text"))
|
||||
continue
|
||||
}
|
||||
decisions = append(decisions, approval(c.ProposalIndex))
|
||||
}
|
||||
if err := EnforceDecisionCardinality(req.CandidateProposal, decisions); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return Result{ValidatorName: v.Name(), Decisions: decisions}, nil
|
||||
}
|
||||
|
||||
type NonEmptyCorrectionValidator struct{}
|
||||
|
||||
func (v NonEmptyCorrectionValidator) Name() string { return "non_empty_correction" }
|
||||
|
||||
func (v NonEmptyCorrectionValidator) Validate(_ context.Context, req Request) (Result, error) {
|
||||
decisions := make([]Decision, 0, len(req.CandidateProposal))
|
||||
for _, c := range req.CandidateProposal {
|
||||
if strings.TrimSpace(c.CorrectedText) == "" {
|
||||
decisions = append(decisions, rejection(c.ProposalIndex, ReasonEmptyCorrectedText, "corrected_text must not be empty"))
|
||||
continue
|
||||
}
|
||||
decisions = append(decisions, approval(c.ProposalIndex))
|
||||
}
|
||||
if err := EnforceDecisionCardinality(req.CandidateProposal, decisions); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return Result{ValidatorName: v.Name(), Decisions: decisions}, nil
|
||||
}
|
||||
|
||||
type NoEffectValidator struct{}
|
||||
|
||||
func (v NoEffectValidator) Name() string { return "no_effect" }
|
||||
|
||||
func (v NoEffectValidator) Validate(_ context.Context, req Request) (Result, error) {
|
||||
decisions := make([]Decision, 0, len(req.CandidateProposal))
|
||||
for _, c := range req.CandidateProposal {
|
||||
if c.OriginalText == c.CorrectedText {
|
||||
decisions = append(decisions, rejection(c.ProposalIndex, ReasonNoEffect, "original_text and corrected_text are identical"))
|
||||
continue
|
||||
}
|
||||
decisions = append(decisions, approval(c.ProposalIndex))
|
||||
}
|
||||
if err := EnforceDecisionCardinality(req.CandidateProposal, decisions); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return Result{ValidatorName: v.Name(), Decisions: decisions}, nil
|
||||
}
|
||||
|
||||
type ProtectedGlossaryTermValidator struct{}
|
||||
|
||||
func (v ProtectedGlossaryTermValidator) Name() string { return "protected_glossary_terms" }
|
||||
|
||||
func (v ProtectedGlossaryTermValidator) Validate(_ context.Context, req Request) (Result, error) {
|
||||
if req.ModuleKey == "glossary" {
|
||||
decisions := make([]Decision, 0, len(req.CandidateProposal))
|
||||
for _, c := range req.CandidateProposal {
|
||||
decisions = append(decisions, approval(c.ProposalIndex))
|
||||
}
|
||||
return Result{ValidatorName: v.Name(), Decisions: decisions}, nil
|
||||
}
|
||||
|
||||
terms := glossaryTerms(req)
|
||||
decisions := make([]Decision, 0, len(req.CandidateProposal))
|
||||
for _, c := range req.CandidateProposal {
|
||||
if altersProtectedTerm(c.CorrectionProposal, terms) {
|
||||
decisions = append(decisions, rejection(c.ProposalIndex, ReasonProtectedGlossaryTerm, "proposal may alter protected glossary terminology"))
|
||||
continue
|
||||
}
|
||||
decisions = append(decisions, approval(c.ProposalIndex))
|
||||
}
|
||||
if err := EnforceDecisionCardinality(req.CandidateProposal, decisions); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return Result{ValidatorName: v.Name(), Decisions: decisions}, nil
|
||||
}
|
||||
|
||||
func glossaryTerms(req Request) []string {
|
||||
if req.Glossary == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0)
|
||||
for _, e := range req.Glossary.Entries {
|
||||
if t := strings.TrimSpace(strings.ToLower(e.Name)); t != "" {
|
||||
out = append(out, t)
|
||||
}
|
||||
for _, a := range e.Aliases {
|
||||
if t := strings.TrimSpace(strings.ToLower(a)); t != "" {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
if t := strings.TrimSpace(strings.ToLower(e.Plural)); t != "" {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func altersProtectedTerm(p proposals.CorrectionProposal, terms []string) bool {
|
||||
if len(terms) == 0 {
|
||||
return false
|
||||
}
|
||||
orig := strings.ToLower(p.OriginalText)
|
||||
corr := strings.ToLower(p.CorrectedText)
|
||||
for _, t := range terms {
|
||||
if strings.Contains(orig, t) && !strings.Contains(corr, t) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
105
internal/framework/validators/models.go
Normal file
105
internal/framework/validators/models.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package validators
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/audita/internal/core/config"
|
||||
"gitea.maximumdirect.net/eric/audita/internal/core/schema"
|
||||
"gitea.maximumdirect.net/eric/audita/internal/framework/proposals"
|
||||
)
|
||||
|
||||
const (
|
||||
ReasonApproved = "approved"
|
||||
ReasonLowConfidence = "low_confidence"
|
||||
ReasonMissingOriginalText = "missing_original_text"
|
||||
ReasonMissingTargetSegment = "missing_target_segment"
|
||||
ReasonEmptyCorrectedText = "empty_corrected_text"
|
||||
ReasonNoEffect = "no_effect"
|
||||
ReasonProtectedGlossaryTerm = "protected_glossary_term"
|
||||
)
|
||||
|
||||
// Request is the runtime input shared by deterministic validators.
|
||||
type Request struct {
|
||||
WorkingTranscript *schema.Transcript `json:"-"`
|
||||
CandidateProposal []proposals.EnrichedCorrectionProposal `json:"candidate_proposals"`
|
||||
ModuleKey string `json:"module_key"`
|
||||
ModuleInstance string `json:"module_instance"`
|
||||
ReplacementPolicy proposals.ReplacementPolicy `json:"replacement_policy"`
|
||||
Glossary *schema.Glossary `json:"-"`
|
||||
Config *config.Config `json:"-"`
|
||||
}
|
||||
|
||||
// Decision is one validator decision for one proposal index.
|
||||
type Decision struct {
|
||||
ProposalIndex int `json:"proposal_index"`
|
||||
Approved bool `json:"approved"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Result is one validator output containing exactly one decision per proposal index.
|
||||
type Result struct {
|
||||
ValidatorName string `json:"validator_name"`
|
||||
Decisions []Decision `json:"decisions"`
|
||||
}
|
||||
|
||||
// Validator is the deterministic runtime validator interface.
|
||||
type Validator interface {
|
||||
Name() string
|
||||
Validate(ctx context.Context, req Request) (Result, error)
|
||||
}
|
||||
|
||||
// EnforceDecisionCardinality verifies every candidate proposal index receives exactly one decision.
|
||||
func EnforceDecisionCardinality(candidate []proposals.EnrichedCorrectionProposal, decisions []Decision) error {
|
||||
expected := make(map[int]struct{}, len(candidate))
|
||||
for _, c := range candidate {
|
||||
expected[c.ProposalIndex] = struct{}{}
|
||||
}
|
||||
|
||||
seen := make(map[int]int, len(decisions))
|
||||
for _, d := range decisions {
|
||||
if _, ok := expected[d.ProposalIndex]; !ok {
|
||||
return fmt.Errorf("unknown decision proposal index %d", d.ProposalIndex)
|
||||
}
|
||||
seen[d.ProposalIndex]++
|
||||
if seen[d.ProposalIndex] > 1 {
|
||||
return fmt.Errorf("duplicate decision proposal index %d", d.ProposalIndex)
|
||||
}
|
||||
}
|
||||
|
||||
for idx := range expected {
|
||||
if seen[idx] == 0 {
|
||||
return fmt.Errorf("missing decision proposal index %d", idx)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func approval(index int) Decision {
|
||||
return Decision{ProposalIndex: index, Approved: true, ReasonCode: ReasonApproved, Message: "approved"}
|
||||
}
|
||||
|
||||
func rejection(index int, reasonCode string, msg string) Decision {
|
||||
return Decision{ProposalIndex: index, Approved: false, ReasonCode: reasonCode, Message: strings.TrimSpace(msg)}
|
||||
}
|
||||
|
||||
func confidenceThresholdForModule(moduleKey string, cfg *config.Config) float64 {
|
||||
if cfg == nil {
|
||||
return 0.0
|
||||
}
|
||||
switch moduleKey {
|
||||
case "glossary":
|
||||
return cfg.Thresholds.Glossary
|
||||
case "grammar":
|
||||
return cfg.Thresholds.Grammar
|
||||
case "homophones":
|
||||
return cfg.Thresholds.Homophones
|
||||
case "spoken_word":
|
||||
return cfg.Thresholds.SpokenWord
|
||||
default:
|
||||
return 0.0
|
||||
}
|
||||
}
|
||||
181
internal/framework/validators/validators_test.go
Normal file
181
internal/framework/validators/validators_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package validators
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/audita/internal/core/config"
|
||||
"gitea.maximumdirect.net/eric/audita/internal/core/schema"
|
||||
"gitea.maximumdirect.net/eric/audita/internal/framework/proposals"
|
||||
)
|
||||
|
||||
func mkCandidate(index int, segID int, orig, corr string, conf float64) proposals.EnrichedCorrectionProposal {
|
||||
return proposals.EnrichedCorrectionProposal{
|
||||
CorrectionProposal: proposals.CorrectionProposal{TargetSegmentID: segID, OriginalText: orig, CorrectedText: corr, Confidence: conf},
|
||||
ProposalMetadata: proposals.ProposalMetadata{ProposalIndex: index, ModuleKey: "grammar", ModuleInstance: "grammar_1"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnforceDecisionCardinalitySuccess(t *testing.T) {
|
||||
candidates := []proposals.EnrichedCorrectionProposal{mkCandidate(0, 1, "teh", "the", 0.9), mkCandidate(1, 1, "recieve", "receive", 0.9)}
|
||||
decisions := []Decision{{ProposalIndex: 0, Approved: true}, {ProposalIndex: 1, Approved: false}}
|
||||
if err := EnforceDecisionCardinality(candidates, decisions); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnforceDecisionCardinalityMissingDecision(t *testing.T) {
|
||||
candidates := []proposals.EnrichedCorrectionProposal{mkCandidate(0, 1, "teh", "the", 0.9), mkCandidate(1, 1, "recieve", "receive", 0.9)}
|
||||
err := EnforceDecisionCardinality(candidates, []Decision{{ProposalIndex: 0, Approved: true}})
|
||||
if err == nil || !strings.Contains(err.Error(), "missing decision") {
|
||||
t.Fatalf("expected missing decision error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnforceDecisionCardinalityDuplicateDecision(t *testing.T) {
|
||||
candidates := []proposals.EnrichedCorrectionProposal{mkCandidate(0, 1, "teh", "the", 0.9)}
|
||||
err := EnforceDecisionCardinality(candidates, []Decision{{ProposalIndex: 0, Approved: true}, {ProposalIndex: 0, Approved: false}})
|
||||
if err == nil || !strings.Contains(err.Error(), "duplicate decision") {
|
||||
t.Fatalf("expected duplicate decision error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnforceDecisionCardinalityUnknownDecision(t *testing.T) {
|
||||
candidates := []proposals.EnrichedCorrectionProposal{mkCandidate(0, 1, "teh", "the", 0.9)}
|
||||
err := EnforceDecisionCardinality(candidates, []Decision{{ProposalIndex: 99, Approved: true}})
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown decision") {
|
||||
t.Fatalf("expected unknown decision error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfidenceThresholdValidator(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Thresholds.Grammar = 0.8
|
||||
req := Request{ModuleKey: "grammar", Config: &cfg, CandidateProposal: []proposals.EnrichedCorrectionProposal{
|
||||
mkCandidate(0, 1, "teh", "the", 0.9),
|
||||
mkCandidate(1, 1, "recieve", "receive", 0.7),
|
||||
}}
|
||||
res, err := (ConfidenceThresholdValidator{}).Validate(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Validate error: %v", err)
|
||||
}
|
||||
if !res.Decisions[0].Approved || res.Decisions[0].ReasonCode != ReasonApproved {
|
||||
t.Fatalf("expected first decision approved, got %+v", res.Decisions[0])
|
||||
}
|
||||
if res.Decisions[1].Approved || res.Decisions[1].ReasonCode != ReasonLowConfidence {
|
||||
t.Fatalf("expected second decision low confidence reject, got %+v", res.Decisions[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOriginalTextPresenceValidator(t *testing.T) {
|
||||
req := Request{WorkingTranscript: &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "hello world"}}}, CandidateProposal: []proposals.EnrichedCorrectionProposal{
|
||||
mkCandidate(0, 1, "hello", "hi", 0.9),
|
||||
mkCandidate(1, 1, "missing", "x", 0.9),
|
||||
mkCandidate(2, 5, "hello", "hi", 0.9),
|
||||
}}
|
||||
res, err := (OriginalTextPresenceValidator{}).Validate(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Validate error: %v", err)
|
||||
}
|
||||
if !res.Decisions[0].Approved {
|
||||
t.Fatalf("expected proposal 0 approved")
|
||||
}
|
||||
if res.Decisions[1].ReasonCode != ReasonMissingOriginalText {
|
||||
t.Fatalf("expected missing_original_text, got %+v", res.Decisions[1])
|
||||
}
|
||||
if res.Decisions[2].ReasonCode != ReasonMissingTargetSegment {
|
||||
t.Fatalf("expected missing_target_segment, got %+v", res.Decisions[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonEmptyCorrectionValidator(t *testing.T) {
|
||||
req := Request{CandidateProposal: []proposals.EnrichedCorrectionProposal{
|
||||
mkCandidate(0, 1, "hello", "hi", 0.9),
|
||||
mkCandidate(1, 1, "hello", " ", 0.9),
|
||||
}}
|
||||
res, err := (NonEmptyCorrectionValidator{}).Validate(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Validate error: %v", err)
|
||||
}
|
||||
if !res.Decisions[0].Approved {
|
||||
t.Fatalf("expected proposal 0 approved")
|
||||
}
|
||||
if res.Decisions[1].ReasonCode != ReasonEmptyCorrectedText {
|
||||
t.Fatalf("expected empty_corrected_text, got %+v", res.Decisions[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoEffectValidator(t *testing.T) {
|
||||
req := Request{CandidateProposal: []proposals.EnrichedCorrectionProposal{
|
||||
mkCandidate(0, 1, "hello", "hello", 0.9),
|
||||
mkCandidate(1, 1, "hello", "hi", 0.9),
|
||||
}}
|
||||
res, err := (NoEffectValidator{}).Validate(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Validate error: %v", err)
|
||||
}
|
||||
if res.Decisions[0].ReasonCode != ReasonNoEffect || res.Decisions[0].Approved {
|
||||
t.Fatalf("expected no_effect rejection, got %+v", res.Decisions[0])
|
||||
}
|
||||
if !res.Decisions[1].Approved {
|
||||
t.Fatalf("expected proposal 1 approved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectedGlossaryTermValidator(t *testing.T) {
|
||||
glossary := &schema.Glossary{Entries: []schema.GlossaryEntry{{Name: "OpenAI", Aliases: []string{"Open AI"}, Plural: "OpenAIs", Category: "brand", Summary: "brand"}}}
|
||||
req := Request{
|
||||
Glossary: glossary,
|
||||
ModuleKey: "grammar",
|
||||
CandidateProposal: []proposals.EnrichedCorrectionProposal{
|
||||
mkCandidate(0, 1, "OpenAI", "Open A I", 0.9),
|
||||
mkCandidate(1, 1, "teh", "the", 0.9),
|
||||
},
|
||||
}
|
||||
res, err := (ProtectedGlossaryTermValidator{}).Validate(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Validate error: %v", err)
|
||||
}
|
||||
if res.Decisions[0].ReasonCode != ReasonProtectedGlossaryTerm || res.Decisions[0].Approved {
|
||||
t.Fatalf("expected protected glossary rejection, got %+v", res.Decisions[0])
|
||||
}
|
||||
if !res.Decisions[1].Approved {
|
||||
t.Fatalf("expected non-glossary proposal approved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectedGlossaryTermValidatorAllowsGlossaryModule(t *testing.T) {
|
||||
glossary := &schema.Glossary{Entries: []schema.GlossaryEntry{{Name: "OpenAI", Category: "brand", Summary: "brand"}}}
|
||||
req := Request{
|
||||
Glossary: glossary,
|
||||
ModuleKey: "glossary",
|
||||
CandidateProposal: []proposals.EnrichedCorrectionProposal{
|
||||
mkCandidate(0, 1, "OpenAI", "Open A I", 0.9),
|
||||
},
|
||||
}
|
||||
res, err := (ProtectedGlossaryTermValidator{}).Validate(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Validate error: %v", err)
|
||||
}
|
||||
if !res.Decisions[0].Approved {
|
||||
t.Fatalf("expected glossary module approval, got %+v", res.Decisions[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStableReasonCodes(t *testing.T) {
|
||||
codes := []string{
|
||||
ReasonApproved,
|
||||
ReasonLowConfidence,
|
||||
ReasonMissingOriginalText,
|
||||
ReasonMissingTargetSegment,
|
||||
ReasonEmptyCorrectedText,
|
||||
ReasonNoEffect,
|
||||
ReasonProtectedGlossaryTerm,
|
||||
}
|
||||
for _, code := range codes {
|
||||
if strings.TrimSpace(code) == "" {
|
||||
t.Fatalf("reason code must not be empty")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user