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
290 lines
8.9 KiB
Go
290 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")
|
|
}
|
|
}
|