Execute complete validator chains
This commit is contained in:
214
internal/framework/pipeline/validation_executor.go
Normal file
214
internal/framework/pipeline/validation_executor.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
type validationOutcome string
|
||||
|
||||
const (
|
||||
validationApproved validationOutcome = "approved"
|
||||
validationRejected validationOutcome = "rejected"
|
||||
validationFailed validationOutcome = "failed"
|
||||
validationSkipped validationOutcome = "skipped"
|
||||
)
|
||||
|
||||
const defaultCorrectionGuidance = "Correct the candidate to satisfy the validator requirements."
|
||||
|
||||
// validationRecord captures the settled result of one configured validator.
|
||||
// Its fields remain private so reports cannot expose mutable warning storage.
|
||||
type validationRecord struct {
|
||||
validatorName string
|
||||
outcome validationOutcome
|
||||
attemptCount int
|
||||
reasonCode string
|
||||
message string
|
||||
diagnosticPath string
|
||||
warnings []contracts.Warning
|
||||
correctionGuidance string
|
||||
failure error
|
||||
}
|
||||
|
||||
func (record validationRecord) clone() validationRecord {
|
||||
record.warnings = cloneWarnings(record.warnings)
|
||||
return record
|
||||
}
|
||||
|
||||
// validationReport is the immutable, ordered result of one validator chain.
|
||||
type validationReport struct {
|
||||
records []validationRecord
|
||||
}
|
||||
|
||||
func (report validationReport) Records() []validationRecord {
|
||||
records := make([]validationRecord, len(report.records))
|
||||
for index, record := range report.records {
|
||||
records[index] = record.clone()
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func (report validationReport) Warnings() []contracts.Warning {
|
||||
var warnings []contracts.Warning
|
||||
for _, record := range report.records {
|
||||
if record.outcome == validationApproved || record.outcome == validationRejected {
|
||||
warnings = append(warnings, cloneWarnings(record.warnings)...)
|
||||
}
|
||||
}
|
||||
return warnings
|
||||
}
|
||||
|
||||
func (report validationReport) FirstRejection() *validationRecord {
|
||||
for _, record := range report.records {
|
||||
if record.outcome == validationRejected {
|
||||
clone := record.clone()
|
||||
return &clone
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (report validationReport) FirstFailure() *validationRecord {
|
||||
for _, record := range report.records {
|
||||
if record.outcome == validationFailed {
|
||||
clone := record.clone()
|
||||
return &clone
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (report validationReport) CorrectionGuidance() string {
|
||||
seen := make(map[string]struct{})
|
||||
parts := make([]string, 0, len(report.records))
|
||||
used := 0
|
||||
for _, record := range report.records {
|
||||
if record.outcome != validationRejected {
|
||||
continue
|
||||
}
|
||||
guidance := strings.TrimSpace(record.correctionGuidance)
|
||||
if guidance == "" {
|
||||
guidance = defaultCorrectionGuidance
|
||||
}
|
||||
if _, exists := seen[guidance]; exists {
|
||||
continue
|
||||
}
|
||||
separator := 0
|
||||
if len(parts) > 0 {
|
||||
separator = 1
|
||||
}
|
||||
if used+separator+len(guidance) > contracts.MaxCorrectionGuidanceBytes {
|
||||
break
|
||||
}
|
||||
seen[guidance] = struct{}{}
|
||||
parts = append(parts, guidance)
|
||||
used += separator + len(guidance)
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
type validationInvocation struct {
|
||||
result contracts.ValidationResult
|
||||
skipped bool
|
||||
reason string
|
||||
message string
|
||||
}
|
||||
|
||||
func skippedValidation(reason, message string) validationInvocation {
|
||||
return validationInvocation{skipped: true, reason: strings.TrimSpace(reason), message: strings.TrimSpace(message)}
|
||||
}
|
||||
|
||||
type validationInvoker func(context.Context, preparedValidator, int) (validationInvocation, error)
|
||||
|
||||
type validationFrameworkError struct{ err error }
|
||||
|
||||
func (err validationFrameworkError) Error() string { return err.err.Error() }
|
||||
|
||||
func (err validationFrameworkError) Unwrap() error { return err.err }
|
||||
|
||||
func fatalValidationError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return validationFrameworkError{err: err}
|
||||
}
|
||||
|
||||
func executeValidationChain(ctx context.Context, chain preparedValidatorChain, invoke validationInvoker) (validationReport, error) {
|
||||
if ctx == nil {
|
||||
return validationReport{}, errors.New("validator execution context must not be nil")
|
||||
}
|
||||
if invoke == nil {
|
||||
return validationReport{}, errors.New("validator invocation must not be nil")
|
||||
}
|
||||
report := validationReport{records: make([]validationRecord, 0, len(chain.validators))}
|
||||
for index, validator := range chain.validators {
|
||||
validator.position = index + 1
|
||||
if err := ctx.Err(); err != nil {
|
||||
return validationReport{}, err
|
||||
}
|
||||
binding := validator.resolved.Binding
|
||||
attemptLimit := 1
|
||||
if validator.resolved.ExecutionClass == contracts.ExecutionClassLLMBacked {
|
||||
attemptLimit += binding.Retries
|
||||
}
|
||||
for attempt := 1; attempt <= attemptLimit; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return validationReport{}, err
|
||||
}
|
||||
invocation, err := invoke(ctx, validator, attempt)
|
||||
if err != nil {
|
||||
var frameworkErr validationFrameworkError
|
||||
if errors.As(err, &frameworkErr) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return validationReport{}, err
|
||||
}
|
||||
if attempt == attemptLimit {
|
||||
report.records = append(report.records, validationRecord{validatorName: binding.Module, outcome: validationFailed, attemptCount: attempt, message: "validator failed after retry exhaustion", failure: err})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if invocation.skipped {
|
||||
report.records = append(report.records, validationRecord{validatorName: binding.Module, outcome: validationSkipped, attemptCount: attempt, reasonCode: invocation.reason, message: invocation.message})
|
||||
break
|
||||
}
|
||||
if err := contracts.ValidateValidationResult(invocation.result); err != nil {
|
||||
if attempt == attemptLimit {
|
||||
report.records = append(report.records, validationRecord{validatorName: binding.Module, outcome: validationFailed, attemptCount: attempt, message: "validator returned an invalid result", failure: err})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if invocation.result.Approved {
|
||||
report.records = append(report.records, validationRecord{validatorName: binding.Module, outcome: validationApproved, attemptCount: attempt, warnings: cloneWarnings(invocation.result.Warnings), diagnosticPath: invocation.result.DiagnosticArtifactPath})
|
||||
break
|
||||
}
|
||||
reason := invocation.result.ReasonCode
|
||||
if reason == "" {
|
||||
reason = "output_rejected"
|
||||
}
|
||||
message := invocation.result.Message
|
||||
if message == "" {
|
||||
message = "output rejected"
|
||||
}
|
||||
report.records = append(report.records, validationRecord{validatorName: binding.Module, outcome: validationRejected, attemptCount: attempt, reasonCode: reason, message: message, diagnosticPath: invocation.result.DiagnosticArtifactPath, warnings: cloneWarnings(invocation.result.Warnings), correctionGuidance: invocation.result.CorrectionGuidance})
|
||||
break
|
||||
}
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func validatorFailureError(record validationRecord) error {
|
||||
if record.failure != nil {
|
||||
return record.failure
|
||||
}
|
||||
return fmt.Errorf("validator %q failed after %d attempt(s)", record.validatorName, record.attemptCount)
|
||||
}
|
||||
|
||||
func validatorAttemptPath(base string, attempt int) string {
|
||||
if attempt == 1 {
|
||||
return base
|
||||
}
|
||||
return fmt.Sprintf("%s-validator-%02d", base, attempt)
|
||||
}
|
||||
Reference in New Issue
Block a user