291 lines
8.9 KiB
Go
291 lines
8.9 KiB
Go
|
|
package science
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
"github.com/tegwick/fluid-core/internal/contract"
|
||
|
|
)
|
||
|
|
|
||
|
|
func plannedExperiment(id contract.ExperimentID) contract.FluidExperiment {
|
||
|
|
return contract.FluidExperiment{
|
||
|
|
ID: id,
|
||
|
|
HypothesisRefs: []contract.HypothesisID{"H-1"},
|
||
|
|
ControlRevision: "R-1",
|
||
|
|
CandidateRevisions: []contract.RevisionID{"R-2"},
|
||
|
|
Cohorts: []contract.CohortID{"coding-agents"},
|
||
|
|
Allocation: map[string]contract.UnitInterval{"control": 0.9, "candidate": 0.1},
|
||
|
|
Metrics: contract.FluidExperimentMetrics{
|
||
|
|
Primary: []string{"requests_per_completed_task"},
|
||
|
|
Guardrails: []string{"p95_latency_ms", "error_rate"},
|
||
|
|
},
|
||
|
|
StopConditions: []string{"hard_guardrail_violation", "manual_stop"},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func withHypothesis(t *testing.T, hs *HypothesisStore) {
|
||
|
|
t.Helper()
|
||
|
|
ctx := context.Background()
|
||
|
|
if _, err := hs.Create(ctx, complete("H-1", "latest entry"), operator); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
for _, s := range []State{
|
||
|
|
contract.FluidHypothesisStateREADY,
|
||
|
|
contract.FluidHypothesisStatePRIORITIZED,
|
||
|
|
contract.FluidHypothesisStateDESIGNING,
|
||
|
|
} {
|
||
|
|
if _, err := hs.Transition(ctx, "H-1", s, operator, "advancing"); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestDesignValidatesTheBlueprintRequirements(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
hs, ec, _ := newStores(t)
|
||
|
|
withHypothesis(t, hs)
|
||
|
|
|
||
|
|
for _, tc := range []struct {
|
||
|
|
name string
|
||
|
|
mutate func(*contract.FluidExperiment)
|
||
|
|
}{
|
||
|
|
{"no hypothesis", func(e *contract.FluidExperiment) { e.HypothesisRefs = nil }},
|
||
|
|
{"no stop condition", func(e *contract.FluidExperiment) { e.StopConditions = nil }},
|
||
|
|
{"no primary metric", func(e *contract.FluidExperiment) { e.Metrics.Primary = nil }},
|
||
|
|
{"no candidate", func(e *contract.FluidExperiment) { e.CandidateRevisions = nil }},
|
||
|
|
} {
|
||
|
|
e := plannedExperiment("E-1")
|
||
|
|
tc.mutate(&e)
|
||
|
|
if _, err := ec.Design(ctx, e, operator); err == nil {
|
||
|
|
t.Errorf("%s: accepted", tc.name)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestAllocationMustSumToOne: shares summing to less would leave matching
|
||
|
|
// traffic falling through to the default, quietly contaminating the control.
|
||
|
|
func TestAllocationMustSumToOne(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
hs, ec, _ := newStores(t)
|
||
|
|
withHypothesis(t, hs)
|
||
|
|
|
||
|
|
e := plannedExperiment("E-1")
|
||
|
|
e.Allocation = map[string]contract.UnitInterval{"control": 0.5, "candidate": 0.1}
|
||
|
|
if _, err := ec.Design(ctx, e, operator); err == nil {
|
||
|
|
t.Error("an allocation summing to 0.6 was accepted")
|
||
|
|
}
|
||
|
|
|
||
|
|
e.Allocation = map[string]contract.UnitInterval{"control": 0.9, "candidate": 0.1}
|
||
|
|
if _, err := ec.Design(ctx, e, operator); err != nil {
|
||
|
|
t.Errorf("a valid allocation was refused: %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestStartProducesRoutingPolicyRatherThanTouchingTraffic is the section 17
|
||
|
|
// separation: the controller writes policy the router consumes.
|
||
|
|
func TestStartProducesRoutingPolicy(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
hs, ec, _ := newStores(t)
|
||
|
|
withHypothesis(t, hs)
|
||
|
|
|
||
|
|
if _, err := ec.Design(ctx, plannedExperiment("E-1"), operator); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
|
||
|
|
e, policy, err := ec.Start(ctx, "E-1", 7, "R-1", operator)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if e.Result.State != contract.FluidExperimentResultStateRUNNING {
|
||
|
|
t.Errorf("state = %s", e.Result.State)
|
||
|
|
}
|
||
|
|
if policy.Generation != 7 || policy.DefaultRevision != "R-1" {
|
||
|
|
t.Errorf("policy = %+v", policy)
|
||
|
|
}
|
||
|
|
if len(policy.Rules) != 1 {
|
||
|
|
t.Fatalf("policy has %d rules, want 1", len(policy.Rules))
|
||
|
|
}
|
||
|
|
|
||
|
|
// Arm names must be resolved to revision ids: the router only understands
|
||
|
|
// those, and a rule naming "candidate" would match nothing.
|
||
|
|
rule := policy.Rules[0]
|
||
|
|
if _, ok := rule.Allocation["R-1"]; !ok {
|
||
|
|
t.Errorf("control arm not resolved to a revision id: %+v", rule.Allocation)
|
||
|
|
}
|
||
|
|
if got := rule.Allocation["R-2"]; got != 0.1 {
|
||
|
|
t.Errorf("candidate share = %v, want 0.1", got)
|
||
|
|
}
|
||
|
|
if rule.Experiment == nil || *rule.Experiment != "E-1" {
|
||
|
|
t.Error("rule does not name its experiment")
|
||
|
|
}
|
||
|
|
|
||
|
|
// The hypothesis follows its experiment.
|
||
|
|
h, err := hs.Get(ctx, "H-1")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if h.State != contract.FluidHypothesisStateEXPERIMENTING {
|
||
|
|
t.Errorf("hypothesis state = %s, want EXPERIMENTING", h.State)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestStopReturnsToAKnownGoodState: experiments must be interruptible, and
|
||
|
|
// stopping should be a document replacement, not an unwind.
|
||
|
|
func TestStopReturnsToAKnownGoodState(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
hs, ec, _ := newStores(t)
|
||
|
|
withHypothesis(t, hs)
|
||
|
|
|
||
|
|
if _, err := ec.Design(ctx, plannedExperiment("E-1"), operator); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, _, err := ec.Start(ctx, "E-1", 1, "R-1", operator); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
|
||
|
|
e, policy, err := ec.Stop(ctx, "E-1", 2, "R-1", operator, "p95 latency breached its guardrail")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if e.Result.State != contract.FluidExperimentResultStateSTOPPED {
|
||
|
|
t.Errorf("state = %s", e.Result.State)
|
||
|
|
}
|
||
|
|
if len(policy.Rules) != 0 {
|
||
|
|
t.Errorf("the stop policy still carries %d rules", len(policy.Rules))
|
||
|
|
}
|
||
|
|
if policy.DefaultRevision != "R-1" || policy.Generation != 2 {
|
||
|
|
t.Errorf("stop policy does not return traffic to the default: %+v", policy)
|
||
|
|
}
|
||
|
|
|
||
|
|
if _, _, err := ec.Stop(ctx, "E-1", 3, "R-1", operator, "again"); !errors.Is(err, ErrNotRunning) {
|
||
|
|
t.Errorf("stopping a stopped experiment returned %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestStopRequiresAReason(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
hs, ec, _ := newStores(t)
|
||
|
|
withHypothesis(t, hs)
|
||
|
|
|
||
|
|
if _, err := ec.Design(ctx, plannedExperiment("E-1"), operator); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, _, err := ec.Start(ctx, "E-1", 1, "R-1", operator); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, _, err := ec.Stop(ctx, "E-1", 2, "R-1", operator, ""); err == nil {
|
||
|
|
t.Error("an experiment was stopped with no reason recorded")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestConcurrencyIsBounded: every running experiment splits the traffic the
|
||
|
|
// others are measuring.
|
||
|
|
func TestConcurrencyIsBounded(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
hs, ec, _ := newStores(t)
|
||
|
|
withHypothesis(t, hs)
|
||
|
|
ec.SetMaxParallel(2)
|
||
|
|
|
||
|
|
for _, id := range []contract.ExperimentID{"E-1", "E-2", "E-3"} {
|
||
|
|
if _, err := ec.Design(ctx, plannedExperiment(id), operator); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if _, _, err := ec.Start(ctx, "E-1", 1, "R-1", operator); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, _, err := ec.Start(ctx, "E-2", 2, "R-1", operator); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, _, err := ec.Start(ctx, "E-3", 3, "R-1", operator); !errors.Is(err, ErrTooManyExperiments) {
|
||
|
|
t.Errorf("a third experiment started past the limit: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Stopping one frees a slot.
|
||
|
|
if _, _, err := ec.Stop(ctx, "E-1", 4, "R-1", operator, "done"); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, _, err := ec.Start(ctx, "E-3", 5, "R-1", operator); err != nil {
|
||
|
|
t.Errorf("a slot did not free after stopping: %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestAmendmentIsRecorded makes changing the terms distinguishable from
|
||
|
|
// quietly editing the record.
|
||
|
|
func TestAmendmentIsRecorded(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
hs, ec, _ := newStores(t)
|
||
|
|
withHypothesis(t, hs)
|
||
|
|
|
||
|
|
if _, err := ec.Design(ctx, plannedExperiment("E-1"), operator); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if _, err := ec.Amend(ctx, "E-1", "", "no change given", operator); err == nil {
|
||
|
|
t.Error("an empty amendment was accepted")
|
||
|
|
}
|
||
|
|
|
||
|
|
e, err := ec.Amend(ctx, "E-1", "extended the window by 14 days",
|
||
|
|
"weekly publishing cadence gives too few samples in 7 days", operator)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if len(e.Amendments) != 1 {
|
||
|
|
t.Fatalf("amendments = %d, want 1", len(e.Amendments))
|
||
|
|
}
|
||
|
|
if e.Amendments[0].Actor.ID != operator.ID || e.Amendments[0].Reason == "" {
|
||
|
|
t.Errorf("amendment does not record who and why: %+v", e.Amendments[0])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestFinalizeMovesHypothesisToEvaluating(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
hs, ec, _ := newStores(t)
|
||
|
|
withHypothesis(t, hs)
|
||
|
|
|
||
|
|
if _, err := ec.Design(ctx, plannedExperiment("E-1"), operator); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, _, err := ec.Start(ctx, "E-1", 1, "R-1", operator); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, _, err := ec.Stop(ctx, "E-1", 2, "R-1", operator, "window elapsed"); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
|
||
|
|
e, err := ec.Finalize(ctx, "E-1", "R-2", operator, "candidate met its target",
|
||
|
|
[]contract.EvidenceRef{"metrics:E-1/window-1"})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if e.Result.State != contract.FluidExperimentResultStateCOMPLETED {
|
||
|
|
t.Errorf("state = %s", e.Result.State)
|
||
|
|
}
|
||
|
|
if e.Result.PreferredRevision == nil || *e.Result.PreferredRevision != "R-2" {
|
||
|
|
t.Error("preferred revision not recorded")
|
||
|
|
}
|
||
|
|
|
||
|
|
h, err := hs.Get(ctx, "H-1")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if h.State != contract.FluidHypothesisStateEVALUATING {
|
||
|
|
t.Errorf("hypothesis state = %s, want EVALUATING", h.State)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestFinalizeRefusesAnExperimentThatNeverRan(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
hs, ec, _ := newStores(t)
|
||
|
|
withHypothesis(t, hs)
|
||
|
|
|
||
|
|
if _, err := ec.Design(ctx, plannedExperiment("E-1"), operator); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, err := ec.Finalize(ctx, "E-1", "R-2", operator, "it would have worked", nil); err == nil {
|
||
|
|
t.Error("an experiment that never ran was finalized")
|
||
|
|
}
|
||
|
|
}
|