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 }