397 lines
14 KiB
Go
397 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
|
||
|
|
}
|