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
396 lines
14 KiB
Go
396 lines
14 KiB
Go
package science
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/tegwick/fluid-core/internal/contract"
|
|
"github.com/tegwick/fluid-core/internal/evidence"
|
|
)
|
|
|
|
// ExperimentController exposes verified candidates under bounded conditions.
|
|
//
|
|
// ArchitectureBlueprint.md section 17 is the constraint that shapes this type:
|
|
// the controller does not process traffic. It writes deterministic routing
|
|
// policy that the revision router consumes. Keeping experimental intent out of
|
|
// the runtime decision mechanism is what lets an experiment be stopped by
|
|
// replacing a document rather than by coordinating with a live request path.
|
|
type ExperimentController struct {
|
|
store evidence.Store
|
|
hypotheses *HypothesisStore
|
|
iface contract.InterfaceID
|
|
now func() time.Time
|
|
maxParallel int
|
|
}
|
|
|
|
// NewExperimentController returns a controller.
|
|
func NewExperimentController(store evidence.Store, h *HypothesisStore, iface contract.InterfaceID) *ExperimentController {
|
|
return &ExperimentController{
|
|
store: store,
|
|
hypotheses: h,
|
|
iface: iface,
|
|
now: time.Now,
|
|
// Concurrency is bounded because every running experiment splits the
|
|
// traffic the others are measuring. Blueprint section 23 treats
|
|
// evolution velocity as a control variable, not a free parameter.
|
|
maxParallel: 3,
|
|
}
|
|
}
|
|
|
|
// SetMaxParallel bounds concurrent running experiments.
|
|
func (c *ExperimentController) SetMaxParallel(n int) { c.maxParallel = n }
|
|
|
|
var (
|
|
// ErrTooManyExperiments reports the concurrency limit.
|
|
ErrTooManyExperiments = errors.New("too many experiments already running")
|
|
// ErrNotRunning reports an operation needing a running experiment.
|
|
ErrNotRunning = errors.New("experiment is not running")
|
|
// ErrGuardrailBreached reports an experiment stopped by its own conditions.
|
|
ErrGuardrailBreached = errors.New("experiment stopped: guardrail breached")
|
|
)
|
|
|
|
// Design creates an experiment in PLANNED.
|
|
//
|
|
// Every field the Blueprint section 16 list requires is validated here rather
|
|
// than at start, so that a badly specified experiment is caught while it is
|
|
// still cheap to fix.
|
|
func (c *ExperimentController) Design(ctx context.Context, e contract.FluidExperiment, actor contract.Actor) (contract.FluidExperiment, error) {
|
|
if e.ID == "" {
|
|
return e, errors.New("experiment needs an id")
|
|
}
|
|
if err := contract.RequireKind(string(e.ID), contract.KindExperiment); err != nil {
|
|
return e, err
|
|
}
|
|
if len(e.HypothesisRefs) == 0 {
|
|
// An experiment without a hypothesis measures nothing in particular,
|
|
// and its result cannot confirm or refute anything.
|
|
return e, errors.New("an experiment must reference at least one hypothesis")
|
|
}
|
|
if e.ControlRevision == "" || len(e.CandidateRevisions) == 0 {
|
|
return e, errors.New("an experiment needs a control and at least one candidate")
|
|
}
|
|
if len(e.Metrics.Primary) == 0 {
|
|
return e, errors.New("an experiment needs at least one primary metric")
|
|
}
|
|
if len(e.StopConditions) == 0 {
|
|
return e, errors.New("an experiment needs a stop condition; unbounded experiments do not end")
|
|
}
|
|
if err := validateAllocation(e.Allocation); err != nil {
|
|
return e, err
|
|
}
|
|
|
|
for _, h := range e.HypothesisRefs {
|
|
if _, err := c.hypotheses.Get(ctx, h); err != nil {
|
|
return e, fmt.Errorf("hypothesis %s: %w", h, err)
|
|
}
|
|
}
|
|
|
|
e.SchemaVersion = "0.1"
|
|
e.InterfaceID = c.iface
|
|
e.Result = contract.FluidExperimentResult{State: contract.FluidExperimentResultStatePLANNED}
|
|
|
|
if err := c.put(ctx, e); err != nil {
|
|
return e, err
|
|
}
|
|
for _, h := range e.HypothesisRefs {
|
|
if err := c.hypotheses.AttachExperiment(ctx, h, e.ID, actor); err != nil {
|
|
return e, err
|
|
}
|
|
}
|
|
return e, c.event(ctx, e, "EXPERIMENT_PLANNED", actor,
|
|
fmt.Sprintf("control %s against %v", e.ControlRevision, e.CandidateRevisions))
|
|
}
|
|
|
|
// validateAllocation checks traffic shares sum to one.
|
|
//
|
|
// A rounding tolerance is allowed, but a policy whose shares sum to 0.6 would
|
|
// leave forty percent of matching traffic with nowhere defined to go, and the
|
|
// router would silently fall through to the default — quietly contaminating the
|
|
// control arm.
|
|
func validateAllocation(allocation map[string]contract.UnitInterval) error {
|
|
if len(allocation) < 2 {
|
|
return errors.New("allocation needs at least a control and a candidate share")
|
|
}
|
|
var total float64
|
|
for _, share := range allocation {
|
|
total += float64(share)
|
|
}
|
|
if total < 0.999 || total > 1.001 {
|
|
return fmt.Errorf("allocation shares sum to %.3f, must sum to 1", total)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Start moves an experiment to RUNNING and emits the routing policy for it.
|
|
//
|
|
// The returned policy is what the router will consume. It is produced here and
|
|
// installed separately, so that the act of exposing traffic is a distinct,
|
|
// auditable step rather than a side effect of a state change.
|
|
func (c *ExperimentController) Start(ctx context.Context, id contract.ExperimentID, generation int64, defaultRevision contract.RevisionID, actor contract.Actor) (contract.FluidExperiment, contract.RoutingPolicy, error) {
|
|
e, err := c.Get(ctx, id)
|
|
if err != nil {
|
|
return e, contract.RoutingPolicy{}, err
|
|
}
|
|
if e.Result.State != contract.FluidExperimentResultStatePLANNED {
|
|
return e, contract.RoutingPolicy{}, fmt.Errorf("experiment %s is %s, not PLANNED", id, e.Result.State)
|
|
}
|
|
|
|
running, err := c.running(ctx)
|
|
if err != nil {
|
|
return e, contract.RoutingPolicy{}, err
|
|
}
|
|
if len(running) >= c.maxParallel {
|
|
return e, contract.RoutingPolicy{}, fmt.Errorf("%w: %d running, limit is %d",
|
|
ErrTooManyExperiments, len(running), c.maxParallel)
|
|
}
|
|
|
|
now := c.now().UTC()
|
|
e.StartAt = &now
|
|
e.Result.State = contract.FluidExperimentResultStateRUNNING
|
|
|
|
policy := c.policyFor(e, generation, defaultRevision, actor, now)
|
|
|
|
if err := c.put(ctx, e); err != nil {
|
|
return e, policy, err
|
|
}
|
|
for _, h := range e.HypothesisRefs {
|
|
// The hypothesis follows its experiment: leaving it in DESIGNING while
|
|
// traffic is already split would misreport where the work actually is.
|
|
if cur, err := c.hypotheses.Get(ctx, h); err == nil &&
|
|
CanTransition(cur.State, contract.FluidHypothesisStateEXPERIMENTING) {
|
|
_, _ = c.hypotheses.Transition(ctx, h, contract.FluidHypothesisStateEXPERIMENTING, actor,
|
|
fmt.Sprintf("experiment %s started", id))
|
|
}
|
|
}
|
|
|
|
return e, policy, c.event(ctx, e, "EXPERIMENT_STARTED", actor,
|
|
fmt.Sprintf("routing policy generation %d issued", generation))
|
|
}
|
|
|
|
// policyFor renders the routing policy that enacts an experiment.
|
|
func (c *ExperimentController) policyFor(e contract.FluidExperiment, generation int64, defaultRevision contract.RevisionID, actor contract.Actor, now time.Time) contract.RoutingPolicy {
|
|
allocation := make(map[string]contract.UnitInterval, len(e.Allocation))
|
|
for key, share := range e.Allocation {
|
|
// Experiment records may name arms "control" and "candidate"; the
|
|
// router only understands revision ids, so they are resolved here.
|
|
switch key {
|
|
case "control":
|
|
allocation[string(e.ControlRevision)] = share
|
|
case "candidate":
|
|
if len(e.CandidateRevisions) > 0 {
|
|
allocation[string(e.CandidateRevisions[0])] = share
|
|
}
|
|
default:
|
|
allocation[key] = share
|
|
}
|
|
}
|
|
|
|
sticky := contract.RoutingPolicyRulesItemStickyByConsumerID
|
|
expID := e.ID
|
|
rule := contract.RoutingPolicyRulesItem{
|
|
Experiment: &expID,
|
|
Allocation: allocation,
|
|
StickyBy: &sticky,
|
|
}
|
|
if len(e.Cohorts) == 1 {
|
|
cohort := e.Cohorts[0]
|
|
rule.Cohort = &cohort
|
|
}
|
|
|
|
rules := []contract.RoutingPolicyRulesItem{rule}
|
|
// With several eligible cohorts, one rule per cohort keeps each match
|
|
// explicit rather than relying on an implicit any-cohort wildcard.
|
|
if len(e.Cohorts) > 1 {
|
|
rules = rules[:0]
|
|
for _, cohort := range e.Cohorts {
|
|
cohort := cohort
|
|
r := rule
|
|
r.Cohort = &cohort
|
|
rules = append(rules, r)
|
|
}
|
|
}
|
|
|
|
return contract.RoutingPolicy{
|
|
SchemaVersion: "0.1",
|
|
ID: fmt.Sprintf("rp-%s-%d", e.ID, generation),
|
|
Interface: c.iface,
|
|
Generation: generation,
|
|
IssuedAt: &now,
|
|
IssuedBy: &actor,
|
|
DefaultRevision: defaultRevision,
|
|
Rules: rules,
|
|
}
|
|
}
|
|
|
|
// Stop ends an experiment and returns the policy that removes its traffic.
|
|
//
|
|
// Experiments must be interruptible (Blueprint invariant 7). Stopping produces
|
|
// a policy with no experiment rules, so the router falls back to the default
|
|
// revision on the next generation — a known-good state reached by replacing a
|
|
// document rather than by unwinding anything.
|
|
func (c *ExperimentController) Stop(ctx context.Context, id contract.ExperimentID, generation int64, defaultRevision contract.RevisionID, actor contract.Actor, reason string) (contract.FluidExperiment, contract.RoutingPolicy, error) {
|
|
e, err := c.Get(ctx, id)
|
|
if err != nil {
|
|
return e, contract.RoutingPolicy{}, err
|
|
}
|
|
if e.Result.State != contract.FluidExperimentResultStateRUNNING {
|
|
return e, contract.RoutingPolicy{}, fmt.Errorf("%w: %s is %s", ErrNotRunning, id, e.Result.State)
|
|
}
|
|
if reason == "" {
|
|
return e, contract.RoutingPolicy{}, errors.New("stopping an experiment requires a reason")
|
|
}
|
|
|
|
e.Result.State = contract.FluidExperimentResultStateSTOPPED
|
|
e.Result.StoppedReason = &reason
|
|
|
|
now := c.now().UTC()
|
|
policy := contract.RoutingPolicy{
|
|
SchemaVersion: "0.1",
|
|
ID: fmt.Sprintf("rp-stop-%s-%d", e.ID, generation),
|
|
Interface: c.iface,
|
|
Generation: generation,
|
|
IssuedAt: &now,
|
|
IssuedBy: &actor,
|
|
DefaultRevision: defaultRevision,
|
|
Rules: []contract.RoutingPolicyRulesItem{},
|
|
}
|
|
|
|
if err := c.put(ctx, e); err != nil {
|
|
return e, policy, err
|
|
}
|
|
return e, policy, c.event(ctx, e, "EXPERIMENT_STOPPED", actor, reason)
|
|
}
|
|
|
|
// Finalize records an experiment's conclusion.
|
|
func (c *ExperimentController) Finalize(ctx context.Context, id contract.ExperimentID, preferred contract.RevisionID, actor contract.Actor, reason string, refs []contract.EvidenceRef) (contract.FluidExperiment, error) {
|
|
e, err := c.Get(ctx, id)
|
|
if err != nil {
|
|
return e, err
|
|
}
|
|
if e.Result.State == contract.FluidExperimentResultStatePLANNED {
|
|
return e, errors.New("an experiment that never ran has nothing to finalize")
|
|
}
|
|
if reason == "" {
|
|
return e, errors.New("finalizing an experiment requires a reason")
|
|
}
|
|
|
|
e.Result.State = contract.FluidExperimentResultStateCOMPLETED
|
|
if preferred != "" {
|
|
e.Result.PreferredRevision = &preferred
|
|
}
|
|
e.Result.EvidenceRefs = refs
|
|
|
|
if err := c.put(ctx, e); err != nil {
|
|
return e, err
|
|
}
|
|
for _, h := range e.HypothesisRefs {
|
|
if cur, err := c.hypotheses.Get(ctx, h); err == nil &&
|
|
CanTransition(cur.State, contract.FluidHypothesisStateEVALUATING) {
|
|
_, _ = c.hypotheses.Transition(ctx, h, contract.FluidHypothesisStateEVALUATING, actor,
|
|
fmt.Sprintf("experiment %s completed", id))
|
|
}
|
|
}
|
|
return e, c.event(ctx, e, "EXPERIMENT_COMPLETED", actor, reason)
|
|
}
|
|
|
|
// Amend records a change to a running experiment's terms.
|
|
//
|
|
// Blueprint section 18: success criteria must not be changed after results are
|
|
// visible without recording the amendment. Making amendment a first-class
|
|
// operation is what makes the alternative — quietly editing the record —
|
|
// distinguishable from legitimate mid-flight adjustment.
|
|
func (c *ExperimentController) Amend(ctx context.Context, id contract.ExperimentID, change, reason string, actor contract.Actor) (contract.FluidExperiment, error) {
|
|
if change == "" || reason == "" {
|
|
return contract.FluidExperiment{}, errors.New("an amendment needs both a change and a reason")
|
|
}
|
|
|
|
e, err := c.Get(ctx, id)
|
|
if err != nil {
|
|
return e, err
|
|
}
|
|
|
|
e.Amendments = append(e.Amendments, contract.FluidExperimentAmendmentsItem{
|
|
At: c.now().UTC(),
|
|
Actor: actor,
|
|
Change: change,
|
|
Reason: reason,
|
|
})
|
|
|
|
if err := c.put(ctx, e); err != nil {
|
|
return e, err
|
|
}
|
|
return e, c.event(ctx, e, "EXPERIMENT_AMENDED", actor, fmt.Sprintf("%s: %s", change, reason))
|
|
}
|
|
|
|
// Get returns one experiment.
|
|
func (c *ExperimentController) Get(ctx context.Context, id contract.ExperimentID) (contract.FluidExperiment, error) {
|
|
body, err := c.store.Record(ctx, contract.KindExperiment, string(id))
|
|
if err != nil {
|
|
return contract.FluidExperiment{}, err
|
|
}
|
|
var doc contract.ExperimentDocument
|
|
if err := json.Unmarshal(body, &doc); err != nil {
|
|
return contract.FluidExperiment{}, fmt.Errorf("decode experiment %s: %w", id, err)
|
|
}
|
|
return doc.FluidExperiment, nil
|
|
}
|
|
|
|
// List returns experiments, optionally filtered by state.
|
|
func (c *ExperimentController) List(ctx context.Context, state contract.FluidExperimentResultState) ([]contract.FluidExperiment, error) {
|
|
records, err := c.store.Records(ctx, contract.KindExperiment)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := make([]contract.FluidExperiment, 0, len(records))
|
|
for _, body := range records {
|
|
var doc contract.ExperimentDocument
|
|
if err := json.Unmarshal(body, &doc); err != nil {
|
|
continue
|
|
}
|
|
if state != "" && doc.FluidExperiment.Result.State != state {
|
|
continue
|
|
}
|
|
out = append(out, doc.FluidExperiment)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
|
return out, nil
|
|
}
|
|
|
|
func (c *ExperimentController) running(ctx context.Context) ([]contract.FluidExperiment, error) {
|
|
return c.List(ctx, contract.FluidExperimentResultStateRUNNING)
|
|
}
|
|
|
|
func (c *ExperimentController) put(ctx context.Context, e contract.FluidExperiment) error {
|
|
body, err := json.Marshal(contract.ExperimentDocument{FluidExperiment: e})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.store.PutRecord(ctx, contract.KindExperiment, string(e.ID), body)
|
|
}
|
|
|
|
func (c *ExperimentController) event(ctx context.Context, e contract.FluidExperiment, kind string, actor contract.Actor, reason string) error {
|
|
return c.store.AppendEvent(ctx, contract.FluidEvent{
|
|
SchemaVersion: "0.1",
|
|
ID: contract.EventID(fmt.Sprintf("EV-%s-%d", e.ID, c.now().UnixNano())),
|
|
OccurredAt: c.now().UTC(),
|
|
EntityType: contract.FluidEventEntityTypeExperiment,
|
|
EntityID: string(e.ID),
|
|
EventType: kind,
|
|
Actor: actor,
|
|
Inputs: hypothesisInputs(e.HypothesisRefs),
|
|
Reason: reason,
|
|
})
|
|
}
|
|
|
|
func hypothesisInputs(refs []contract.HypothesisID) []string {
|
|
out := make([]string, len(refs))
|
|
for i, r := range refs {
|
|
out[i] = string(r)
|
|
}
|
|
return out
|
|
}
|