Add hypothesis lifecycle, competition groups, experiments and promotion

FLUID-WP-0006 T01, T02, T04, T07. The lifecycle graph is explicit
because the states carry meaning a reader relies on: a hypothesis that
jumped from DRAFT to ACCEPTED would claim evidence it never gathered and
the audit trail would show nothing wrong. Drafting stays cheap and
completeness is checked at READY, which is the claim that an idea is
worth someone's time.

Competition membership is symmetric, so a reader looking at one
hypothesis cannot miss that a rival exists. Losing a competition
supersedes rather than rejects: rejection says the explanation was
wrong, superseded says a better one won, and the distinction matters
when the winner is later refuted.

The experiment controller never touches traffic. Start returns a routing
policy for the router to consume and Stop returns one with no rules, so
interrupting an experiment is a document replacement rather than an
unwind. Allocation must sum to one, or matching traffic would fall
through to the default and quietly contaminate the control arm.

Promotion consults the gate and never bypasses it. A human may promote
against an inconclusive verdict, but only with an acknowledged override
that is recorded as one; outcomes that reduce exposure need no gate at
all, since requiring permission to stop would point a safety property
the wrong way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014KmVxhJ35tCo7rE7UnLwWu

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1116572@bnt-lap001
Assistant-Session: 8ba9bb93-a72a-4883-b189-2499cce5c400
This commit is contained in:
tegwick 2026-09-04 06:39:33 +02:00
parent 7e0de9e5b7
commit 634807a0cb
7 changed files with 2008 additions and 0 deletions

View file

@ -0,0 +1,262 @@
// Package promotion records decisions about whether a candidate may progress.
//
// ArchitectureBlueprint.md section 19 lists the possible outcomes and section
// 28.1 keeps promotion authority separate from code-generation authority. This
// package records a decision and its justification; it does not make one. The
// deterministic policy gate decides what is permissible, a human or a policy
// decides what is wanted, and this is where that decision becomes evidence.
package promotion
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"time"
"github.com/tegwick/fluid-core/internal/contract"
"github.com/tegwick/fluid-core/internal/evidence"
"github.com/tegwick/fluid-core/internal/fitness"
"github.com/tegwick/fluid-core/internal/policy"
)
// Outcome is a promotion decision.
type Outcome string
const (
Promote Outcome = "PROMOTE"
ExpandExperiment Outcome = "EXPAND_EXPERIMENT"
RetainAsOption Outcome = "RETAIN_AS_OPTION"
Modify Outcome = "MODIFY"
Revert Outcome = "REVERT"
Abandon Outcome = "ABANDON"
Defer Outcome = "DEFER"
)
// Valid reports whether o is a defined outcome.
func (o Outcome) Valid() bool {
switch o {
case Promote, ExpandExperiment, RetainAsOption, Modify, Revert, Abandon, Defer:
return true
}
return false
}
// Decision is a recorded promotion decision.
type Decision struct {
ID contract.DecisionID `json:"id"`
Revision contract.RevisionID `json:"revision"`
Outcome Outcome `json:"outcome"`
Reason string `json:"reason"`
Actor contract.Actor `json:"actor"`
DecidedAt time.Time `json:"decided_at"`
// Experiment and Hypothesis tie the decision back to the evidence it rests
// on, so a later reader can check the reasoning rather than take it.
Experiment contract.ExperimentID `json:"experiment,omitempty"`
Hypotheses []contract.HypothesisID `json:"hypotheses,omitempty"`
// FitnessVerdict is the evaluator's reading at decision time.
FitnessVerdict fitness.Verdict `json:"fitness_verdict,omitempty"`
// GateDecision is the deterministic gate's verdict at decision time.
GateAllowed bool `json:"gate_allowed"`
GateReasons []string `json:"gate_reasons,omitempty"`
// Override marks a decision taken against the evidence or the gate.
Override bool `json:"override,omitempty"`
}
// Controller records promotion decisions.
type Controller struct {
store evidence.Store
gate *policy.Gate
now func() time.Time
}
// NewController returns a promotion controller.
func NewController(store evidence.Store, gate *policy.Gate) *Controller {
return &Controller{store: store, gate: gate, now: time.Now}
}
var (
// ErrGateRefused reports a promotion the deterministic gate forbids.
ErrGateRefused = errors.New("the deterministic policy gate refuses this promotion")
// ErrNoEvidence reports a promotion with nothing behind it.
ErrNoEvidence = errors.New("promotion requires a fitness verdict")
)
// Request is a proposed promotion.
type Request struct {
Revision contract.RevisionID
Outcome Outcome
Reason string
Actor contract.Actor
Experiment contract.ExperimentID
Hypotheses []contract.HypothesisID
// Evaluation is the fitness reading the decision rests on.
Evaluation *fitness.Evaluation
// GateInput lets the controller re-run the deterministic gate at decision
// time rather than trusting a verdict computed earlier, since the intent or
// the limits may have changed since.
GateInput *policy.Input
// AcknowledgeOverride is required to promote against the evidence. It does
// not disable the gate — nothing does — but it does permit promoting a
// candidate whose fitness verdict was not a success, with the override
// recorded as such.
AcknowledgeOverride bool
}
// Decide records a promotion decision.
//
// The gate is consulted, never bypassed. A human may promote a candidate whose
// experiment was inconclusive — that is a legitimate judgement call — but they
// may not promote one the gate refuses, and choosing to act against the
// evidence is recorded as an override rather than washed into the reason text.
func (c *Controller) Decide(ctx context.Context, req Request) (Decision, error) {
if !req.Outcome.Valid() {
return Decision{}, fmt.Errorf("unknown promotion outcome %q", req.Outcome)
}
if req.Reason == "" {
return Decision{}, errors.New("a promotion decision requires a reason")
}
if req.Actor.ID == "" {
return Decision{}, errors.New("a promotion decision must name its actor")
}
d := Decision{
ID: contract.DecisionID(fmt.Sprintf("D-%s-%d", req.Revision, c.now().UnixNano())),
Revision: req.Revision,
Outcome: req.Outcome,
Reason: req.Reason,
Actor: req.Actor,
DecidedAt: c.now().UTC(),
Experiment: req.Experiment,
Hypotheses: req.Hypotheses,
GateAllowed: true,
}
if req.Evaluation != nil {
d.FitnessVerdict = req.Evaluation.Verdict
}
// Only outcomes that widen exposure need the gate. Reverting, abandoning
// or deferring reduce risk, and requiring permission to stop would be a
// safety property pointed the wrong way.
if wideningExposure(req.Outcome) {
if req.GateInput == nil {
return d, errors.New("promoting requires gate input describing the candidate")
}
decision := c.gate.Evaluate(*req.GateInput)
d.GateAllowed = decision.Allowed
d.GateReasons = decision.Reasons
if !decision.Allowed {
// Record the refusal before returning: an attempted promotion that
// the gate blocked is exactly the kind of thing an audit wants.
_ = c.record(ctx, d, "PROMOTION_REFUSED")
return d, fmt.Errorf("%w: %v", ErrGateRefused, decision.Reasons)
}
if req.Evaluation == nil {
return d, ErrNoEvidence
}
if req.Evaluation.Verdict != fitness.VerdictSucceeded {
if !req.AcknowledgeOverride {
return d, fmt.Errorf(
"fitness verdict is %s, not %s: pass an acknowledged override to promote anyway (%v)",
req.Evaluation.Verdict, fitness.VerdictSucceeded, req.Evaluation.Reasons)
}
d.Override = true
}
}
if err := c.put(ctx, d); err != nil {
return d, err
}
return d, c.record(ctx, d, "PROMOTION_DECIDED")
}
// wideningExposure reports whether an outcome increases a candidate's reach.
func wideningExposure(o Outcome) bool {
switch o {
case Promote, ExpandExperiment:
return true
}
return false
}
// Get returns one decision.
func (c *Controller) Get(ctx context.Context, id contract.DecisionID) (Decision, error) {
body, err := c.store.Record(ctx, contract.KindDecision, string(id))
if err != nil {
return Decision{}, err
}
var d Decision
if err := json.Unmarshal(body, &d); err != nil {
return Decision{}, fmt.Errorf("decode decision %s: %w", id, err)
}
return d, nil
}
// ForRevision returns the decisions taken about a revision, oldest first.
func (c *Controller) ForRevision(ctx context.Context, rev contract.RevisionID) ([]Decision, error) {
records, err := c.store.Records(ctx, contract.KindDecision)
if err != nil {
return nil, err
}
var out []Decision
for _, body := range records {
var d Decision
if err := json.Unmarshal(body, &d); err != nil {
continue
}
if d.Revision != rev {
continue
}
out = append(out, d)
}
sort.Slice(out, func(i, j int) bool { return out[i].DecidedAt.Before(out[j].DecidedAt) })
return out, nil
}
func (c *Controller) put(ctx context.Context, d Decision) error {
body, err := json.Marshal(d)
if err != nil {
return err
}
return c.store.PutRecord(ctx, contract.KindDecision, string(d.ID), body)
}
func (c *Controller) record(ctx context.Context, d Decision, kind string) error {
inputs := make([]string, 0, len(d.Hypotheses)+1)
for _, h := range d.Hypotheses {
inputs = append(inputs, string(h))
}
if d.Experiment != "" {
inputs = append(inputs, string(d.Experiment))
}
reason := d.Reason
if d.Override {
// The override is part of the record, not a footnote in the prose.
reason = fmt.Sprintf("[override: fitness verdict was %s] %s", d.FitnessVerdict, reason)
}
if !d.GateAllowed {
reason = fmt.Sprintf("[gate refused: %v] %s", d.GateReasons, reason)
}
return c.store.AppendEvent(ctx, contract.FluidEvent{
SchemaVersion: "0.1",
ID: contract.EventID(fmt.Sprintf("EV-%s-%d", d.ID, c.now().UnixNano())),
OccurredAt: d.DecidedAt,
EntityType: contract.FluidEventEntityTypeRevision,
EntityID: string(d.Revision),
EventType: fmt.Sprintf("%s_%s", kind, d.Outcome),
Actor: d.Actor,
Inputs: inputs,
Reason: reason,
})
}

View file

@ -0,0 +1,228 @@
package promotion
import (
"context"
"errors"
"path/filepath"
"testing"
_ "modernc.org/sqlite"
"github.com/tegwick/fluid-core/internal/contract"
"github.com/tegwick/fluid-core/internal/evidence"
"github.com/tegwick/fluid-core/internal/fitness"
"github.com/tegwick/fluid-core/internal/intent"
"github.com/tegwick/fluid-core/internal/policy"
)
var human = contract.Actor{Type: contract.ActorTypeHuman, ID: "worsch"}
func newController(t *testing.T) (*Controller, *evidence.SQLStore) {
t.Helper()
store, err := evidence.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "e.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
return NewController(store, policy.NewGate(policy.DefaultLimits())), store
}
func passingGateInput() *policy.Input {
pc := contract.RevisionPolicyPolicyCheckPassed
return &policy.Input{
Descriptor: contract.Revision{
ID: "R-2",
Policy: contract.RevisionPolicy{
Compatibility: contract.RevisionPolicyCompatibilityAdditive,
SecurityCheck: contract.RevisionPolicySecurityCheckPassed,
PolicyCheck: &pc,
},
},
GoverningMode: intent.ModeAdvisory,
AdaptationClasses: []contract.AdaptationClass{contract.AdaptationClassPresentation},
ComplexityDelta: 0.2,
RequestedTrafficShare: 0.1,
Approved: true,
ApprovedBy: &human,
}
}
func succeeded() *fitness.Evaluation {
return &fitness.Evaluation{Verdict: fitness.VerdictSucceeded, Candidate: "R-2", Control: "R-1"}
}
func TestPromoteOnGoodEvidence(t *testing.T) {
ctx := context.Background()
c, store := newController(t)
d, err := c.Decide(ctx, Request{
Revision: "R-2", Outcome: Promote,
Reason: "requests per task fell from 2.7 to 1.15 with no guardrail breach",
Actor: human, Experiment: "E-1", Hypotheses: []contract.HypothesisID{"H-1"},
Evaluation: succeeded(), GateInput: passingGateInput(),
})
if err != nil {
t.Fatalf("a well-evidenced promotion was refused: %v", err)
}
if !d.GateAllowed || d.Override {
t.Errorf("decision = %+v", d)
}
// The decision must be traceable back to the evidence it rests on.
if d.Experiment != "E-1" || len(d.Hypotheses) != 1 {
t.Error("decision does not cite its evidence")
}
events, err := store.Events(ctx, evidence.EventFilter{EntityID: "R-2"})
if err != nil {
t.Fatal(err)
}
if len(events) != 1 || events[0].EventType != "PROMOTION_DECIDED_PROMOTE" {
t.Errorf("promotion left %d events: %+v", len(events), events)
}
}
// TestGateRefusalCannotBeOverridden: a human may act against the evidence, but
// never against the deterministic gate.
func TestGateRefusalCannotBeOverridden(t *testing.T) {
ctx := context.Background()
c, store := newController(t)
gate := passingGateInput()
gate.Descriptor.Policy.Compatibility = contract.RevisionPolicyCompatibilityBreaking
_, err := c.Decide(ctx, Request{
Revision: "R-2", Outcome: Promote, Reason: "I am confident",
Actor: human, Evaluation: succeeded(), GateInput: gate,
AcknowledgeOverride: true, // must not help
})
if !errors.Is(err, ErrGateRefused) {
t.Fatalf("the gate was overridden: %v", err)
}
// The blocked attempt is itself worth auditing.
events, qerr := store.Events(ctx, evidence.EventFilter{EntityID: "R-2"})
if qerr != nil {
t.Fatal(qerr)
}
if len(events) != 1 || events[0].EventType != "PROMOTION_REFUSED_PROMOTE" {
t.Errorf("a refused promotion left no audit trail: %+v", events)
}
}
// TestPromotingAgainstEvidenceRequiresAcknowledgement keeps a judgement call
// visible as one instead of letting it read like a normal promotion.
func TestPromotingAgainstEvidenceRequiresAcknowledgement(t *testing.T) {
ctx := context.Background()
c, _ := newController(t)
inconclusive := &fitness.Evaluation{
Verdict: fitness.VerdictInconclusive,
Reasons: []string{"only 11 samples per arm"},
}
_, err := c.Decide(ctx, Request{
Revision: "R-2", Outcome: Promote, Reason: "we need this before the launch",
Actor: human, Evaluation: inconclusive, GateInput: passingGateInput(),
})
if err == nil {
t.Fatal("an inconclusive candidate was promoted without acknowledgement")
}
d, err := c.Decide(ctx, Request{
Revision: "R-2", Outcome: Promote, Reason: "we need this before the launch",
Actor: human, Evaluation: inconclusive, GateInput: passingGateInput(),
AcknowledgeOverride: true,
})
if err != nil {
t.Fatalf("an acknowledged override was refused: %v", err)
}
if !d.Override {
t.Error("the override was not recorded on the decision")
}
if d.FitnessVerdict != fitness.VerdictInconclusive {
t.Error("the verdict being overridden was not recorded")
}
}
// TestReducingExposureNeedsNoGate: requiring permission to stop would point a
// safety property the wrong way.
func TestReducingExposureNeedsNoGate(t *testing.T) {
ctx := context.Background()
c, _ := newController(t)
for _, outcome := range []Outcome{Revert, Abandon, Defer, RetainAsOption, Modify} {
if _, err := c.Decide(ctx, Request{
Revision: "R-2", Outcome: outcome,
Reason: "rolling back after a latency regression",
Actor: human,
// No gate input and no evaluation: stopping must always be possible.
}); err != nil {
t.Errorf("%s was refused: %v", outcome, err)
}
}
}
func TestPromotionNeedsEvidenceAndAttribution(t *testing.T) {
ctx := context.Background()
c, _ := newController(t)
if _, err := c.Decide(ctx, Request{
Revision: "R-2", Outcome: Promote, Reason: "because",
Actor: human, GateInput: passingGateInput(),
}); !errors.Is(err, ErrNoEvidence) {
t.Errorf("promotion with no fitness verdict returned %v", err)
}
if _, err := c.Decide(ctx, Request{
Revision: "R-2", Outcome: Promote, Reason: "because",
Evaluation: succeeded(), GateInput: passingGateInput(),
}); err == nil {
t.Error("an unattributed decision was accepted")
}
if _, err := c.Decide(ctx, Request{
Revision: "R-2", Outcome: Promote, Actor: human,
Evaluation: succeeded(), GateInput: passingGateInput(),
}); err == nil {
t.Error("a decision with no reason was accepted")
}
}
func TestDecisionHistoryIsOrdered(t *testing.T) {
ctx := context.Background()
c, _ := newController(t)
for _, outcome := range []Outcome{ExpandExperiment, Revert} {
req := Request{
Revision: "R-2", Outcome: outcome, Reason: "step", Actor: human,
}
if wideningExposure(outcome) {
req.Evaluation = succeeded()
req.GateInput = passingGateInput()
}
if _, err := c.Decide(ctx, req); err != nil {
t.Fatalf("%s: %v", outcome, err)
}
}
history, err := c.ForRevision(ctx, "R-2")
if err != nil {
t.Fatal(err)
}
if len(history) != 2 {
t.Fatalf("history has %d entries, want 2", len(history))
}
if history[0].DecidedAt.After(history[1].DecidedAt) {
t.Error("decision history is not in chronological order")
}
}
func TestUnknownOutcomeRefused(t *testing.T) {
ctx := context.Background()
c, _ := newController(t)
if _, err := c.Decide(ctx, Request{
Revision: "R-2", Outcome: Outcome("SHIP_IT"), Reason: "r", Actor: human,
}); err == nil {
t.Error("an undefined outcome was accepted")
}
}