2026-08-27 00:18:16 +00:00
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"
)
2026-08-27 11:50:37 +00:00
const correctionRequestIntroduction = "The previous response failed semantic validation. Return one complete corrected replacement response, not a patch, explanation, or commentary.\n\nCorrect all of the following:\n"
2026-08-27 00:18:16 +00:00
// validationRecord captures the settled result of one configured validator.
2026-08-27 18:41:59 +00:00
// Its fields remain private so reports cannot expose mutable diagnostic storage.
2026-08-27 00:18:16 +00:00
type validationRecord struct {
validatorName string
outcome validationOutcome
attemptCount int
reasonCode string
message string
diagnosticPath string
2026-08-27 15:29:10 +00:00
diagnostics [ ] contracts . ProducerDiagnostic
2026-08-27 00:18:16 +00:00
correctionGuidance string
failure error
}
func ( record validationRecord ) clone ( ) validationRecord {
2026-08-27 15:29:10 +00:00
record . diagnostics = contracts . CloneProducerDiagnostics ( record . diagnostics )
2026-08-27 00:18:16 +00:00
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
}
2026-08-27 15:29:10 +00:00
func ( report validationReport ) Diagnostics ( ) [ ] validationDiagnosticRecord {
var diagnostics [ ] validationDiagnosticRecord
for _ , record := range report . records {
if record . outcome != validationApproved && record . outcome != validationRejected {
continue
}
for _ , diagnostic := range record . diagnostics {
diagnostics = append ( diagnostics , validationDiagnosticRecord { validatorName : record . validatorName , diagnostic : cloneProducerDiagnostics ( [ ] contracts . ProducerDiagnostic { diagnostic } ) [ 0 ] } )
}
}
return diagnostics
}
type validationDiagnosticRecord struct {
validatorName string
diagnostic contracts . ProducerDiagnostic
}
2026-08-27 00:18:16 +00:00
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
}
2026-08-27 11:50:37 +00:00
func ( report validationReport ) CorrectionRequest ( ) ( string , error ) {
2026-08-27 00:18:16 +00:00
seen := make ( map [ string ] struct { } )
parts := make ( [ ] string , 0 , len ( report . records ) )
for _ , record := range report . records {
if record . outcome != validationRejected {
continue
}
guidance := strings . TrimSpace ( record . correctionGuidance )
if guidance == "" {
2026-08-27 11:50:37 +00:00
return "" , fmt . Errorf ( "validator %q rejected output without correction guidance" , record . validatorName )
2026-08-27 00:18:16 +00:00
}
if _ , exists := seen [ guidance ] ; exists {
continue
}
seen [ guidance ] = struct { } { }
parts = append ( parts , guidance )
}
2026-08-27 11:50:37 +00:00
if len ( parts ) == 0 {
return "" , errors . New ( "validation report contains no correction guidance" )
}
var builder strings . Builder
builder . WriteString ( correctionRequestIntroduction )
for index , guidance := range parts {
fmt . Fprintf ( & builder , "%d. %s" , index + 1 , guidance )
if index + 1 < len ( parts ) {
builder . WriteByte ( '\n' )
}
}
request := builder . String ( )
if len ( request ) > contracts . MaxCorrectionGuidanceBytes {
return "" , fmt . Errorf ( "aggregate correction request exceeds maximum length of %d bytes" , contracts . MaxCorrectionGuidanceBytes )
}
return request , nil
2026-08-27 00:18:16 +00:00
}
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
}
2026-08-27 18:41:59 +00:00
if err := contracts . ValidateProducerDiagnostics ( invocation . result . Diagnostics ) ; err != nil {
return validationReport { } , fatalValidationError ( fmt . Errorf ( "validator %q returned invalid diagnostics: %w" , binding . Module , err ) )
}
2026-08-27 00:18:16 +00:00
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 {
2026-08-27 16:40:17 +00:00
report . records = append ( report . records , validationRecord { validatorName : binding . Module , outcome : validationApproved , attemptCount : attempt , diagnostics : cloneProducerDiagnostics ( invocation . result . Diagnostics ) , diagnosticPath : invocation . result . DiagnosticArtifactPath } )
2026-08-27 00:18:16 +00:00
break
}
message := invocation . result . Message
if message == "" {
message = "output rejected"
}
2026-08-27 16:40:17 +00:00
report . records = append ( report . records , validationRecord { validatorName : binding . Module , outcome : validationRejected , attemptCount : attempt , reasonCode : invocation . result . ReasonCode , message : message , diagnosticPath : invocation . result . DiagnosticArtifactPath , diagnostics : cloneProducerDiagnostics ( invocation . result . Diagnostics ) , correctionGuidance : invocation . result . CorrectionGuidance } )
2026-08-27 00:18:16 +00:00
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 )
}