311 lines
14 KiB
Go
311 lines
14 KiB
Go
package pipeline
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
|
)
|
|
|
|
// producerAttemptKind records why a producer invocation followed the prior
|
|
// one. It is deliberately independent of any artifact family.
|
|
type producerAttemptKind string
|
|
|
|
const (
|
|
producerAttemptInitial producerAttemptKind = "initial"
|
|
producerAttemptOperationalRetry producerAttemptKind = "operational_error_retry"
|
|
producerAttemptStructuralRetry producerAttemptKind = "structural_retry"
|
|
producerAttemptModuleRetry producerAttemptKind = "module_requested_retry"
|
|
producerAttemptSemanticRetry producerAttemptKind = "semantic_correction"
|
|
)
|
|
|
|
type producerAttemptOutcome string
|
|
|
|
const (
|
|
producerAttemptAccepted producerAttemptOutcome = "accepted"
|
|
producerAttemptRejected producerAttemptOutcome = "rejected"
|
|
producerAttemptIncompleteAccepted producerAttemptOutcome = "incomplete_accepted"
|
|
producerAttemptRetried producerAttemptOutcome = "retried"
|
|
producerAttemptFailed producerAttemptOutcome = "failed"
|
|
)
|
|
|
|
type producerTerminalAction string
|
|
|
|
const (
|
|
producerTerminalAccepted producerTerminalAction = "accepted"
|
|
producerTerminalRejected producerTerminalAction = "reject_output"
|
|
producerTerminalIncompleteAccepted producerTerminalAction = "warn_continue"
|
|
producerTerminalFailed producerTerminalAction = "fail_run"
|
|
)
|
|
|
|
// producerAttemptRequest contains only attempt-local control material. The
|
|
// producer reconstructs its ordinary request from its own durable inputs.
|
|
type producerAttemptRequest struct {
|
|
Number int
|
|
Kind producerAttemptKind
|
|
Correction *contracts.SemanticCorrection
|
|
}
|
|
|
|
// producerRetryDirective asks for another producer invocation while retaining
|
|
// the current value as a safe fallback if its shared budget is exhausted.
|
|
// Artifact-specific adapters are responsible for validating and populating it.
|
|
type producerRetryDirective struct {
|
|
FallbackWarnings []contracts.Warning
|
|
}
|
|
|
|
func (directive *producerRetryDirective) clone() *producerRetryDirective {
|
|
if directive == nil {
|
|
return nil
|
|
}
|
|
return &producerRetryDirective{FallbackWarnings: cloneWarnings(directive.FallbackWarnings)}
|
|
}
|
|
|
|
// producerAttemptOutput is intentionally artifact-neutral. Value remains
|
|
// opaque to the state machine; Candidate is the attempt-local response that
|
|
// may support semantic correction.
|
|
type producerAttemptOutput struct {
|
|
Value any
|
|
Candidate *contracts.ModelCandidate
|
|
Warnings []contracts.Warning
|
|
Retry *producerRetryDirective
|
|
}
|
|
|
|
func (output producerAttemptOutput) clone() (producerAttemptOutput, error) {
|
|
candidate, err := contracts.CloneModelCandidate(output.Candidate)
|
|
if err != nil {
|
|
return producerAttemptOutput{}, fmt.Errorf("clone model candidate: %w", err)
|
|
}
|
|
return producerAttemptOutput{
|
|
Value: output.Value,
|
|
Candidate: candidate,
|
|
Warnings: cloneWarnings(output.Warnings),
|
|
Retry: output.Retry.clone(),
|
|
}, nil
|
|
}
|
|
|
|
type producerAttemptProducer func(context.Context, producerAttemptRequest) (producerAttemptOutput, error)
|
|
type producerAttemptValidator func(context.Context, producerAttemptOutput) (validationReport, error)
|
|
|
|
type producerAttemptConfig struct {
|
|
Retries int
|
|
Policy ValidationPolicy
|
|
AllowStructuralRetry bool
|
|
}
|
|
|
|
// producerAttemptProvenance is ordered by producer invocation. It retains the
|
|
// settled validation report for later debug and durable-provenance adapters.
|
|
type producerAttemptProvenance struct {
|
|
Number int
|
|
Kind producerAttemptKind
|
|
Outcome producerAttemptOutcome
|
|
Validation validationReport
|
|
}
|
|
|
|
func (provenance producerAttemptProvenance) clone() producerAttemptProvenance {
|
|
provenance.Validation = cloneValidationReport(provenance.Validation)
|
|
return provenance
|
|
}
|
|
|
|
// producerAttemptTerminal describes the state-machine decision without
|
|
// materializing an artifact or persisting stage-specific diagnostics.
|
|
type producerAttemptTerminal struct {
|
|
Action producerTerminalAction
|
|
Value any
|
|
Warnings []contracts.Warning
|
|
Rejection *contracts.RejectedOutput
|
|
Validation validationReport
|
|
ValidationIncomplete bool
|
|
Provenance []producerAttemptProvenance
|
|
}
|
|
|
|
func (terminal producerAttemptTerminal) clone() producerAttemptTerminal {
|
|
terminal.Warnings = cloneWarnings(terminal.Warnings)
|
|
if terminal.Rejection != nil {
|
|
rejection := *terminal.Rejection
|
|
terminal.Rejection = &rejection
|
|
}
|
|
terminal.Validation = cloneValidationReport(terminal.Validation)
|
|
terminal.Provenance = cloneProducerAttemptProvenance(terminal.Provenance)
|
|
return terminal
|
|
}
|
|
|
|
func cloneProducerAttemptProvenance(provenance []producerAttemptProvenance) []producerAttemptProvenance {
|
|
if len(provenance) == 0 {
|
|
return nil
|
|
}
|
|
cloned := make([]producerAttemptProvenance, len(provenance))
|
|
for index, item := range provenance {
|
|
cloned[index] = item.clone()
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
func cloneValidationReport(report validationReport) validationReport {
|
|
return validationReport{records: report.Records()}
|
|
}
|
|
|
|
func runProducerAttempts(ctx context.Context, config producerAttemptConfig, produce producerAttemptProducer, validate producerAttemptValidator) (producerAttemptTerminal, error) {
|
|
if ctx == nil {
|
|
return producerAttemptTerminal{}, errors.New("producer attempt context must not be nil")
|
|
}
|
|
if config.Retries < 0 {
|
|
return producerAttemptTerminal{}, errors.New("producer retries must not be negative")
|
|
}
|
|
if produce == nil {
|
|
return producerAttemptTerminal{}, errors.New("producer attempt closure must not be nil")
|
|
}
|
|
if validate == nil {
|
|
return producerAttemptTerminal{}, errors.New("producer validation closure must not be nil")
|
|
}
|
|
|
|
attemptLimit := config.Retries + 1
|
|
provenance := make([]producerAttemptProvenance, 0, attemptLimit)
|
|
kind := producerAttemptInitial
|
|
var correction *contracts.SemanticCorrection
|
|
|
|
for number := 1; number <= attemptLimit; number++ {
|
|
if err := ctx.Err(); err != nil {
|
|
return failedProducerAttempt(provenance), err
|
|
}
|
|
requestCorrection, err := contracts.CloneSemanticCorrection(correction)
|
|
if err != nil {
|
|
return failedProducerAttempt(provenance), fmt.Errorf("clone semantic correction: %w", err)
|
|
}
|
|
output, err := produce(ctx, producerAttemptRequest{Number: number, Kind: kind, Correction: requestCorrection})
|
|
if err != nil {
|
|
provenance = append(provenance, producerAttemptProvenance{Number: number, Kind: kind, Outcome: producerAttemptFailed})
|
|
if isImmediateProducerFailure(err) {
|
|
return failedProducerAttempt(provenance), err
|
|
}
|
|
if errors.Is(err, contracts.ErrInvalidStructuredOutput) && config.AllowStructuralRetry {
|
|
if number < attemptLimit {
|
|
kind, correction = producerAttemptStructuralRetry, nil
|
|
continue
|
|
}
|
|
return applyStructuralTerminalPolicy(config.Policy, provenance, number, err)
|
|
}
|
|
if number < attemptLimit {
|
|
kind, correction = producerAttemptOperationalRetry, nil
|
|
continue
|
|
}
|
|
return failedProducerAttempt(provenance), fmt.Errorf("producer failed after %d attempt(s): %w", number, err)
|
|
}
|
|
|
|
output, err = output.clone()
|
|
if err != nil {
|
|
provenance = append(provenance, producerAttemptProvenance{Number: number, Kind: kind, Outcome: producerAttemptFailed})
|
|
return failedProducerAttempt(provenance), err
|
|
}
|
|
if output.Retry != nil && number < attemptLimit {
|
|
provenance = append(provenance, producerAttemptProvenance{Number: number, Kind: kind, Outcome: producerAttemptRetried})
|
|
kind, correction = producerAttemptModuleRetry, nil
|
|
continue
|
|
}
|
|
if output.Retry != nil {
|
|
output.Warnings = append(output.Warnings, cloneWarnings(output.Retry.FallbackWarnings)...)
|
|
}
|
|
correctionCandidate, err := contracts.CloneModelCandidate(output.Candidate)
|
|
if err != nil {
|
|
provenance = append(provenance, producerAttemptProvenance{Number: number, Kind: kind, Outcome: producerAttemptFailed})
|
|
return failedProducerAttempt(provenance), fmt.Errorf("clone correction candidate: %w", err)
|
|
}
|
|
|
|
report, err := validate(ctx, output)
|
|
if err != nil {
|
|
provenance = append(provenance, producerAttemptProvenance{Number: number, Kind: kind, Outcome: producerAttemptFailed})
|
|
return failedProducerAttempt(provenance), err
|
|
}
|
|
report = cloneValidationReport(report)
|
|
if err := ctx.Err(); err != nil {
|
|
provenance = append(provenance, producerAttemptProvenance{Number: number, Kind: kind, Outcome: producerAttemptFailed, Validation: report})
|
|
return failedProducerAttempt(provenance), err
|
|
}
|
|
|
|
if rejection := report.FirstRejection(); rejection != nil {
|
|
if correctionCandidate != nil {
|
|
if err := correctionCandidate.Validate(); err != nil {
|
|
provenance = append(provenance, producerAttemptProvenance{Number: number, Kind: kind, Outcome: producerAttemptFailed, Validation: report})
|
|
return failedProducerAttempt(provenance), fmt.Errorf("validate producer model candidate: %w", err)
|
|
}
|
|
}
|
|
if number < attemptLimit && correctionCandidate != nil && correctionCandidate.Protocol == contracts.CorrectionProtocolSingleResponseV1 {
|
|
correction, err = contracts.NewSemanticCorrection(correctionCandidate.Response, report.CorrectionGuidance())
|
|
if err != nil {
|
|
provenance = append(provenance, producerAttemptProvenance{Number: number, Kind: kind, Outcome: producerAttemptFailed, Validation: report})
|
|
return failedProducerAttempt(provenance), fmt.Errorf("construct semantic correction: %w", err)
|
|
}
|
|
provenance = append(provenance, producerAttemptProvenance{Number: number, Kind: kind, Outcome: producerAttemptRetried, Validation: report})
|
|
kind = producerAttemptSemanticRetry
|
|
continue
|
|
}
|
|
provenance = append(provenance, producerAttemptProvenance{Number: number, Kind: kind, Outcome: producerAttemptRejected, Validation: report})
|
|
return applySemanticTerminalPolicy(config.Policy, provenance, number, output, report, *rejection)
|
|
}
|
|
|
|
if incomplete := firstIncompleteValidation(report); incomplete != nil {
|
|
provenance = append(provenance, producerAttemptProvenance{Number: number, Kind: kind, Outcome: producerAttemptIncompleteAccepted, Validation: report})
|
|
if config.Policy.ValidatorFailure == ValidatorFailureWarnContinue {
|
|
return producerAttemptTerminal{Action: producerTerminalIncompleteAccepted, Value: output.Value, Warnings: terminalWarnings(output, report), Validation: report, ValidationIncomplete: true, Provenance: cloneProducerAttemptProvenance(provenance)}, nil
|
|
}
|
|
return failedProducerAttempt(provenance), validatorFailureError(*incomplete)
|
|
}
|
|
|
|
provenance = append(provenance, producerAttemptProvenance{Number: number, Kind: kind, Outcome: producerAttemptAccepted, Validation: report})
|
|
return producerAttemptTerminal{Action: producerTerminalAccepted, Value: output.Value, Warnings: terminalWarnings(output, report), Validation: report, Provenance: cloneProducerAttemptProvenance(provenance)}, nil
|
|
}
|
|
|
|
return failedProducerAttempt(provenance), errors.New("producer attempt budget was not exhausted deterministically")
|
|
}
|
|
|
|
func isImmediateProducerFailure(err error) bool {
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
|
return true
|
|
}
|
|
var debugErr *attemptDebugPersistenceError
|
|
return errors.As(err, &debugErr)
|
|
}
|
|
|
|
func applyStructuralTerminalPolicy(policy ValidationPolicy, provenance []producerAttemptProvenance, number int, err error) (producerAttemptTerminal, error) {
|
|
switch policy.ProducerStructuralFailure {
|
|
case ProducerStructuralFailureRejectOutput:
|
|
return producerAttemptTerminal{Action: producerTerminalRejected, Rejection: &contracts.RejectedOutput{ReasonCode: "invalid_structured_output", Message: "producer returned invalid structured output", AttemptCount: number}, Provenance: cloneProducerAttemptProvenance(provenance)}, nil
|
|
case ProducerStructuralFailureFailRun:
|
|
return failedProducerAttempt(provenance), fmt.Errorf("producer returned invalid structured output after %d attempt(s): %w", number, err)
|
|
default:
|
|
return failedProducerAttempt(provenance), fmt.Errorf("unknown producer structural-failure action %q", policy.ProducerStructuralFailure)
|
|
}
|
|
}
|
|
|
|
func applySemanticTerminalPolicy(policy ValidationPolicy, provenance []producerAttemptProvenance, number int, output producerAttemptOutput, report validationReport, rejection validationRecord) (producerAttemptTerminal, error) {
|
|
rejected := contracts.RejectedOutput{ReasonCode: rejection.reasonCode, Message: rejection.message, AttemptCount: number}
|
|
switch policy.SemanticRejection {
|
|
case SemanticRejectionRejectOutput:
|
|
return producerAttemptTerminal{Action: producerTerminalRejected, Warnings: terminalWarnings(output, report), Rejection: &rejected, Validation: report, ValidationIncomplete: firstIncompleteValidation(report) != nil, Provenance: cloneProducerAttemptProvenance(provenance)}, nil
|
|
case SemanticRejectionFailRun:
|
|
return failedProducerAttempt(provenance), fmt.Errorf("producer candidate rejected after %d attempt(s): %s", number, rejection.message)
|
|
default:
|
|
return failedProducerAttempt(provenance), fmt.Errorf("unknown semantic-rejection action %q", policy.SemanticRejection)
|
|
}
|
|
}
|
|
|
|
func firstIncompleteValidation(report validationReport) *validationRecord {
|
|
for _, record := range report.records {
|
|
if record.outcome == validationFailed || record.outcome == validationSkipped {
|
|
clone := record.clone()
|
|
return &clone
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func terminalWarnings(output producerAttemptOutput, report validationReport) []contracts.Warning {
|
|
warnings := cloneWarnings(output.Warnings)
|
|
warnings = append(warnings, report.Warnings()...)
|
|
return warnings
|
|
}
|
|
|
|
func failedProducerAttempt(provenance []producerAttemptProvenance) producerAttemptTerminal {
|
|
return producerAttemptTerminal{Action: producerTerminalFailed, Provenance: cloneProducerAttemptProvenance(provenance)}
|
|
}
|