Add producer attempt state machine
This commit is contained in:
310
internal/framework/pipeline/producer_attempts.go
Normal file
310
internal/framework/pipeline/producer_attempts.go
Normal file
@@ -0,0 +1,310 @@
|
||||
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)}
|
||||
}
|
||||
Reference in New Issue
Block a user