Complete Phase 8 deterministic validators
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/audita/internal/core/schema"
|
||||
"gitea.maximumdirect.net/eric/audita/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/audita/internal/framework/proposals"
|
||||
"gitea.maximumdirect.net/eric/audita/internal/framework/validators"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -28,16 +29,38 @@ type Runner struct {
|
||||
|
||||
// ModuleResult captures deterministic per-module execution output.
|
||||
type ModuleResult struct {
|
||||
ModuleKey string `json:"module_key"`
|
||||
ModuleInstance string `json:"module_instance"`
|
||||
ReplacementPolicy proposals.ReplacementPolicy `json:"replacement_policy"`
|
||||
Status string `json:"status"`
|
||||
ProposalCount int `json:"proposal_count"`
|
||||
AppliedChanges []proposals.AppliedChange `json:"applied_changes,omitempty"`
|
||||
SkippedChanges []proposals.SkippedChange `json:"skipped_changes,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
ModuleKey string `json:"module_key"`
|
||||
ModuleInstance string `json:"module_instance"`
|
||||
ReplacementPolicy proposals.ReplacementPolicy `json:"replacement_policy"`
|
||||
Status string `json:"status"`
|
||||
ProposalCount int `json:"proposal_count"`
|
||||
ValidatorDecisions []ValidatorDecisionRecord `json:"validator_decisions,omitempty"`
|
||||
ValidatorRejected []ValidatorRejectedChange `json:"validator_rejected,omitempty"`
|
||||
AppliedChanges []proposals.AppliedChange `json:"applied_changes,omitempty"`
|
||||
SkippedChanges []proposals.SkippedChange `json:"skipped_changes,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
}
|
||||
|
||||
type ValidatorDecisionRecord struct {
|
||||
ValidatorName string `json:"validator_name"`
|
||||
ProposalIndex int `json:"proposal_index"`
|
||||
Approved bool `json:"approved"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type ValidatorRejectedChange struct {
|
||||
ValidatorName string `json:"validator_name"`
|
||||
ProposalIndex int `json:"proposal_index"`
|
||||
ModuleKey string `json:"module_key"`
|
||||
ModuleInstance string `json:"module_instance"`
|
||||
TargetSegmentID int `json:"target_segment_id"`
|
||||
OriginalText string `json:"original_text"`
|
||||
CorrectedText string `json:"corrected_text"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// RunInput is the deterministic runner input.
|
||||
@@ -121,19 +144,100 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (RunOutput, error) {
|
||||
})
|
||||
}
|
||||
|
||||
applyResult := proposals.ApplyProposals(working, enriched, policy)
|
||||
validatorDecisions := make([]ValidatorDecisionRecord, 0)
|
||||
validatorRejected := make([]ValidatorRejectedChange, 0)
|
||||
eligible := enriched
|
||||
for _, validator := range module.Validators() {
|
||||
vResult, vErr := validator.Validate(ctx, contracts.ValidationRequest{
|
||||
WorkingTranscript: working,
|
||||
CandidateProposal: eligible,
|
||||
ModuleKey: spec.ModuleKey,
|
||||
ModuleInstance: spec.InstanceName,
|
||||
ReplacementPolicy: policy,
|
||||
Glossary: input.Glossary,
|
||||
Config: input.Config,
|
||||
})
|
||||
if vErr != nil {
|
||||
failed := ModuleResult{
|
||||
ModuleKey: spec.ModuleKey,
|
||||
ModuleInstance: spec.InstanceName,
|
||||
ReplacementPolicy: policy,
|
||||
Status: ModuleStatusFailed,
|
||||
ProposalCount: len(enriched),
|
||||
ValidatorDecisions: validatorDecisions,
|
||||
ValidatorRejected: validatorRejected,
|
||||
ErrorMessage: vErr.Error(),
|
||||
StartedAt: startedAt,
|
||||
CompletedAt: time.Now().UTC(),
|
||||
}
|
||||
results = append(results, failed)
|
||||
return RunOutput{FinalTranscript: working, ModuleResults: results}, fmt.Errorf("module %q validator %q failed: %w", spec.InstanceName, validator.Name(), vErr)
|
||||
}
|
||||
if err := validators.EnforceDecisionCardinality(eligible, vResult.Decisions); err != nil {
|
||||
failed := ModuleResult{
|
||||
ModuleKey: spec.ModuleKey,
|
||||
ModuleInstance: spec.InstanceName,
|
||||
ReplacementPolicy: policy,
|
||||
Status: ModuleStatusFailed,
|
||||
ProposalCount: len(enriched),
|
||||
ValidatorDecisions: validatorDecisions,
|
||||
ValidatorRejected: validatorRejected,
|
||||
ErrorMessage: err.Error(),
|
||||
StartedAt: startedAt,
|
||||
CompletedAt: time.Now().UTC(),
|
||||
}
|
||||
results = append(results, failed)
|
||||
return RunOutput{FinalTranscript: working, ModuleResults: results}, fmt.Errorf("module %q validator %q cardinality failed: %w", spec.InstanceName, validator.Name(), err)
|
||||
}
|
||||
|
||||
nextEligible := make([]proposals.EnrichedCorrectionProposal, 0, len(eligible))
|
||||
byIndex := make(map[int]proposals.EnrichedCorrectionProposal, len(eligible))
|
||||
for _, p := range eligible {
|
||||
byIndex[p.ProposalIndex] = p
|
||||
}
|
||||
for _, d := range vResult.Decisions {
|
||||
validatorDecisions = append(validatorDecisions, ValidatorDecisionRecord{
|
||||
ValidatorName: validator.Name(),
|
||||
ProposalIndex: d.ProposalIndex,
|
||||
Approved: d.Approved,
|
||||
ReasonCode: d.ReasonCode,
|
||||
Message: d.Message,
|
||||
})
|
||||
if d.Approved {
|
||||
nextEligible = append(nextEligible, byIndex[d.ProposalIndex])
|
||||
continue
|
||||
}
|
||||
p := byIndex[d.ProposalIndex]
|
||||
validatorRejected = append(validatorRejected, ValidatorRejectedChange{
|
||||
ValidatorName: validator.Name(),
|
||||
ProposalIndex: p.ProposalIndex,
|
||||
ModuleKey: p.ModuleKey,
|
||||
ModuleInstance: p.ModuleInstance,
|
||||
TargetSegmentID: p.TargetSegmentID,
|
||||
OriginalText: p.OriginalText,
|
||||
CorrectedText: p.CorrectedText,
|
||||
ReasonCode: d.ReasonCode,
|
||||
Message: d.Message,
|
||||
})
|
||||
}
|
||||
eligible = nextEligible
|
||||
}
|
||||
|
||||
applyResult := proposals.ApplyProposals(working, eligible, policy)
|
||||
working = applyResult.Transcript
|
||||
|
||||
results = append(results, ModuleResult{
|
||||
ModuleKey: spec.ModuleKey,
|
||||
ModuleInstance: spec.InstanceName,
|
||||
ReplacementPolicy: policy,
|
||||
Status: ModuleStatusSuccess,
|
||||
ProposalCount: len(enriched),
|
||||
AppliedChanges: applyResult.Applied,
|
||||
SkippedChanges: applyResult.Skipped,
|
||||
StartedAt: startedAt,
|
||||
CompletedAt: time.Now().UTC(),
|
||||
ModuleKey: spec.ModuleKey,
|
||||
ModuleInstance: spec.InstanceName,
|
||||
ReplacementPolicy: policy,
|
||||
Status: ModuleStatusSuccess,
|
||||
ProposalCount: len(enriched),
|
||||
ValidatorDecisions: validatorDecisions,
|
||||
ValidatorRejected: validatorRejected,
|
||||
AppliedChanges: applyResult.Applied,
|
||||
SkippedChanges: applyResult.Skipped,
|
||||
StartedAt: startedAt,
|
||||
CompletedAt: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/audita/internal/core/schema"
|
||||
"gitea.maximumdirect.net/eric/audita/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/audita/internal/framework/proposals"
|
||||
"gitea.maximumdirect.net/eric/audita/internal/framework/validators"
|
||||
)
|
||||
|
||||
type fakeFactory struct {
|
||||
@@ -24,14 +25,15 @@ func (f fakeFactory) ModuleForSpec(spec contracts.ModuleRunSpec) (contracts.Tran
|
||||
}
|
||||
|
||||
type fakeModule struct {
|
||||
key string
|
||||
policy proposals.ReplacementPolicy
|
||||
proposeF func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error)
|
||||
key string
|
||||
policy proposals.ReplacementPolicy
|
||||
validators []contracts.Validator
|
||||
proposeF func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error)
|
||||
}
|
||||
|
||||
func (m fakeModule) Key() string { return m.key }
|
||||
func (m fakeModule) ReplacementPolicy() proposals.ReplacementPolicy { return m.policy }
|
||||
func (m fakeModule) Validators() []contracts.Validator { return nil }
|
||||
func (m fakeModule) Validators() []contracts.Validator { return m.validators }
|
||||
func (m fakeModule) Propose(ctx context.Context, req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
||||
if m.proposeF == nil {
|
||||
return nil, nil
|
||||
@@ -39,6 +41,17 @@ func (m fakeModule) Propose(ctx context.Context, req contracts.ProposalRequest)
|
||||
return m.proposeF(req)
|
||||
}
|
||||
|
||||
type fakeValidator struct {
|
||||
name string
|
||||
validateF func(req contracts.ValidationRequest) (validators.Result, error)
|
||||
}
|
||||
|
||||
func (v fakeValidator) Name() string { return v.name }
|
||||
func (v fakeValidator) Validate(ctx context.Context, req contracts.ValidationRequest) (validators.Result, error) {
|
||||
_ = ctx
|
||||
return v.validateF(req)
|
||||
}
|
||||
|
||||
func TestRunnerOneModuleAppliesProposal(t *testing.T) {
|
||||
transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Speaker: "A", Start: 0, End: 1, Text: "teh cat"}}}
|
||||
r := New(fakeFactory{modules: map[string]contracts.TranscriptModule{
|
||||
@@ -149,4 +162,148 @@ func TestRunnerRepeatedModuleInstanceNames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerValidatorApprovedProposalApplied(t *testing.T) {
|
||||
transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "teh cat"}}}
|
||||
allowAll := fakeValidator{name: "allow", validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
|
||||
decisions := make([]validators.Decision, len(req.CandidateProposal))
|
||||
for i, p := range req.CandidateProposal {
|
||||
decisions[i] = validators.Decision{ProposalIndex: p.ProposalIndex, Approved: true, ReasonCode: validators.ReasonApproved, Message: "approved"}
|
||||
}
|
||||
return validators.Result{ValidatorName: "allow", Decisions: decisions}, nil
|
||||
}}
|
||||
r := New(fakeFactory{modules: map[string]contracts.TranscriptModule{
|
||||
"m": fakeModule{
|
||||
key: "m",
|
||||
policy: proposals.ReplacementPolicyRequireUnique,
|
||||
validators: []contracts.Validator{allowAll},
|
||||
proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
||||
return []proposals.CorrectionProposal{{TargetSegmentID: 1, OriginalText: "teh", CorrectedText: "the", Confidence: 1}}, nil
|
||||
},
|
||||
},
|
||||
}})
|
||||
|
||||
out, err := r.Run(context.Background(), RunInput{Transcript: transcript, ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "m", InstanceName: "m"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error: %v", err)
|
||||
}
|
||||
if out.FinalTranscript.Segments[0].Text != "the cat" {
|
||||
t.Fatalf("expected proposal applied, got %q", out.FinalTranscript.Segments[0].Text)
|
||||
}
|
||||
if len(out.ModuleResults[0].ValidatorDecisions) != 1 {
|
||||
t.Fatalf("expected validator decisions recorded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerValidatorRejectedProposalNotApplied(t *testing.T) {
|
||||
transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "teh cat"}}}
|
||||
rejectAll := fakeValidator{name: "reject", validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
|
||||
decisions := make([]validators.Decision, len(req.CandidateProposal))
|
||||
for i, p := range req.CandidateProposal {
|
||||
decisions[i] = validators.Decision{ProposalIndex: p.ProposalIndex, Approved: false, ReasonCode: validators.ReasonNoEffect, Message: "rejected"}
|
||||
}
|
||||
return validators.Result{ValidatorName: "reject", Decisions: decisions}, nil
|
||||
}}
|
||||
r := New(fakeFactory{modules: map[string]contracts.TranscriptModule{
|
||||
"m": fakeModule{key: "m", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{rejectAll}, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
||||
return []proposals.CorrectionProposal{{TargetSegmentID: 1, OriginalText: "teh", CorrectedText: "the", Confidence: 1}}, nil
|
||||
}},
|
||||
}})
|
||||
out, err := r.Run(context.Background(), RunInput{Transcript: transcript, ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "m", InstanceName: "m"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error: %v", err)
|
||||
}
|
||||
if out.FinalTranscript.Segments[0].Text != "teh cat" {
|
||||
t.Fatalf("expected rejected proposal not applied, got %q", out.FinalTranscript.Segments[0].Text)
|
||||
}
|
||||
if len(out.ModuleResults[0].ValidatorRejected) != 1 {
|
||||
t.Fatalf("expected validator rejection recorded, got %+v", out.ModuleResults[0].ValidatorRejected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerMultipleValidatorsRunInOrderAndFilterSurvivors(t *testing.T) {
|
||||
transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "one two"}}}
|
||||
first := fakeValidator{name: "first", validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
|
||||
if len(req.CandidateProposal) != 2 {
|
||||
t.Fatalf("expected first validator to see 2 candidates, got %d", len(req.CandidateProposal))
|
||||
}
|
||||
return validators.Result{
|
||||
ValidatorName: "first",
|
||||
Decisions: []validators.Decision{
|
||||
{ProposalIndex: req.CandidateProposal[0].ProposalIndex, Approved: true, ReasonCode: validators.ReasonApproved, Message: "ok"},
|
||||
{ProposalIndex: req.CandidateProposal[1].ProposalIndex, Approved: false, ReasonCode: validators.ReasonNoEffect, Message: "reject"},
|
||||
},
|
||||
}, nil
|
||||
}}
|
||||
second := fakeValidator{name: "second", validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
|
||||
if len(req.CandidateProposal) != 1 {
|
||||
t.Fatalf("expected second validator to see only survivors, got %d", len(req.CandidateProposal))
|
||||
}
|
||||
return validators.Result{ValidatorName: "second", Decisions: []validators.Decision{{ProposalIndex: req.CandidateProposal[0].ProposalIndex, Approved: true, ReasonCode: validators.ReasonApproved, Message: "ok"}}}, nil
|
||||
}}
|
||||
r := New(fakeFactory{modules: map[string]contracts.TranscriptModule{
|
||||
"m": fakeModule{key: "m", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{first, second}, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
||||
return []proposals.CorrectionProposal{
|
||||
{TargetSegmentID: 1, OriginalText: "one", CorrectedText: "ONE", Confidence: 1},
|
||||
{TargetSegmentID: 1, OriginalText: "two", CorrectedText: "TWO", Confidence: 1},
|
||||
}, nil
|
||||
}},
|
||||
}})
|
||||
|
||||
out, err := r.Run(context.Background(), RunInput{Transcript: transcript, ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "m", InstanceName: "m"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error: %v", err)
|
||||
}
|
||||
if out.FinalTranscript.Segments[0].Text != "ONE two" {
|
||||
t.Fatalf("expected only survivor applied, got %q", out.FinalTranscript.Segments[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerValidatorCardinalityErrorStopsPipelineWithPartialProgress(t *testing.T) {
|
||||
transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "teh cat"}}}
|
||||
good := fakeModule{key: "m1", policy: proposals.ReplacementPolicyRequireUnique, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
||||
return []proposals.CorrectionProposal{{TargetSegmentID: 1, OriginalText: "teh", CorrectedText: "the", Confidence: 1}}, nil
|
||||
}}
|
||||
badValidator := fakeValidator{name: "bad", validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
|
||||
// Missing one decision triggers cardinality error.
|
||||
return validators.Result{ValidatorName: "bad", Decisions: nil}, nil
|
||||
}}
|
||||
bad := fakeModule{key: "m2", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{badValidator}, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
||||
return []proposals.CorrectionProposal{{TargetSegmentID: 1, OriginalText: "cat", CorrectedText: "dog", Confidence: 1}}, nil
|
||||
}}
|
||||
r := New(fakeFactory{modules: map[string]contracts.TranscriptModule{"m1": good, "m2": bad}})
|
||||
|
||||
out, err := r.Run(context.Background(), RunInput{Transcript: transcript, ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "m1", InstanceName: "m1"}, {ModuleKey: "m2", InstanceName: "m2"}}})
|
||||
if err == nil {
|
||||
t.Fatal("expected cardinality error")
|
||||
}
|
||||
if out.FinalTranscript.Segments[0].Text != "the cat" {
|
||||
t.Fatalf("expected partial progress preserved, got %q", out.FinalTranscript.Segments[0].Text)
|
||||
}
|
||||
if len(out.ModuleResults) != 2 || out.ModuleResults[1].Status != ModuleStatusFailed {
|
||||
t.Fatalf("expected second module failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerApplicationSkipAfterValidatorApprovalReported(t *testing.T) {
|
||||
transcript := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "word word"}}}
|
||||
allow := fakeValidator{name: "allow", validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
|
||||
return validators.Result{ValidatorName: "allow", Decisions: []validators.Decision{{ProposalIndex: req.CandidateProposal[0].ProposalIndex, Approved: true, ReasonCode: validators.ReasonApproved, Message: "ok"}}}, nil
|
||||
}}
|
||||
r := New(fakeFactory{modules: map[string]contracts.TranscriptModule{
|
||||
"m": fakeModule{key: "m", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{allow}, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
||||
return []proposals.CorrectionProposal{{TargetSegmentID: 1, OriginalText: "word", CorrectedText: "term", Confidence: 1}}, nil
|
||||
}},
|
||||
}})
|
||||
out, err := r.Run(context.Background(), RunInput{Transcript: transcript, ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "m", InstanceName: "m"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("Run error: %v", err)
|
||||
}
|
||||
if len(out.ModuleResults[0].SkippedChanges) != 1 {
|
||||
t.Fatalf("expected application skip recorded")
|
||||
}
|
||||
if len(out.ModuleResults[0].ValidatorRejected) != 0 {
|
||||
t.Fatalf("expected no validator rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func ptrConfig(c config.Config) *config.Config { return &c }
|
||||
|
||||
Reference in New Issue
Block a user