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
228 lines
6.9 KiB
Go
228 lines
6.9 KiB
Go
package promotion
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
_ "modernc.org/sqlite"
|
|
|
|
"github.com/tegwick/fluid-core/internal/contract"
|
|
"github.com/tegwick/fluid-core/internal/evidence"
|
|
"github.com/tegwick/fluid-core/internal/fitness"
|
|
"github.com/tegwick/fluid-core/internal/intent"
|
|
"github.com/tegwick/fluid-core/internal/policy"
|
|
)
|
|
|
|
var human = contract.Actor{Type: contract.ActorTypeHuman, ID: "worsch"}
|
|
|
|
func newController(t *testing.T) (*Controller, *evidence.SQLStore) {
|
|
t.Helper()
|
|
store, err := evidence.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "e.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = store.Close() })
|
|
return NewController(store, policy.NewGate(policy.DefaultLimits())), store
|
|
}
|
|
|
|
func passingGateInput() *policy.Input {
|
|
pc := contract.RevisionPolicyPolicyCheckPassed
|
|
return &policy.Input{
|
|
Descriptor: contract.Revision{
|
|
ID: "R-2",
|
|
Policy: contract.RevisionPolicy{
|
|
Compatibility: contract.RevisionPolicyCompatibilityAdditive,
|
|
SecurityCheck: contract.RevisionPolicySecurityCheckPassed,
|
|
PolicyCheck: &pc,
|
|
},
|
|
},
|
|
GoverningMode: intent.ModeAdvisory,
|
|
AdaptationClasses: []contract.AdaptationClass{contract.AdaptationClassPresentation},
|
|
ComplexityDelta: 0.2,
|
|
RequestedTrafficShare: 0.1,
|
|
Approved: true,
|
|
ApprovedBy: &human,
|
|
}
|
|
}
|
|
|
|
func succeeded() *fitness.Evaluation {
|
|
return &fitness.Evaluation{Verdict: fitness.VerdictSucceeded, Candidate: "R-2", Control: "R-1"}
|
|
}
|
|
|
|
func TestPromoteOnGoodEvidence(t *testing.T) {
|
|
ctx := context.Background()
|
|
c, store := newController(t)
|
|
|
|
d, err := c.Decide(ctx, Request{
|
|
Revision: "R-2", Outcome: Promote,
|
|
Reason: "requests per task fell from 2.7 to 1.15 with no guardrail breach",
|
|
Actor: human, Experiment: "E-1", Hypotheses: []contract.HypothesisID{"H-1"},
|
|
Evaluation: succeeded(), GateInput: passingGateInput(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("a well-evidenced promotion was refused: %v", err)
|
|
}
|
|
if !d.GateAllowed || d.Override {
|
|
t.Errorf("decision = %+v", d)
|
|
}
|
|
|
|
// The decision must be traceable back to the evidence it rests on.
|
|
if d.Experiment != "E-1" || len(d.Hypotheses) != 1 {
|
|
t.Error("decision does not cite its evidence")
|
|
}
|
|
events, err := store.Events(ctx, evidence.EventFilter{EntityID: "R-2"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(events) != 1 || events[0].EventType != "PROMOTION_DECIDED_PROMOTE" {
|
|
t.Errorf("promotion left %d events: %+v", len(events), events)
|
|
}
|
|
}
|
|
|
|
// TestGateRefusalCannotBeOverridden: a human may act against the evidence, but
|
|
// never against the deterministic gate.
|
|
func TestGateRefusalCannotBeOverridden(t *testing.T) {
|
|
ctx := context.Background()
|
|
c, store := newController(t)
|
|
|
|
gate := passingGateInput()
|
|
gate.Descriptor.Policy.Compatibility = contract.RevisionPolicyCompatibilityBreaking
|
|
|
|
_, err := c.Decide(ctx, Request{
|
|
Revision: "R-2", Outcome: Promote, Reason: "I am confident",
|
|
Actor: human, Evaluation: succeeded(), GateInput: gate,
|
|
AcknowledgeOverride: true, // must not help
|
|
})
|
|
if !errors.Is(err, ErrGateRefused) {
|
|
t.Fatalf("the gate was overridden: %v", err)
|
|
}
|
|
|
|
// The blocked attempt is itself worth auditing.
|
|
events, qerr := store.Events(ctx, evidence.EventFilter{EntityID: "R-2"})
|
|
if qerr != nil {
|
|
t.Fatal(qerr)
|
|
}
|
|
if len(events) != 1 || events[0].EventType != "PROMOTION_REFUSED_PROMOTE" {
|
|
t.Errorf("a refused promotion left no audit trail: %+v", events)
|
|
}
|
|
}
|
|
|
|
// TestPromotingAgainstEvidenceRequiresAcknowledgement keeps a judgement call
|
|
// visible as one instead of letting it read like a normal promotion.
|
|
func TestPromotingAgainstEvidenceRequiresAcknowledgement(t *testing.T) {
|
|
ctx := context.Background()
|
|
c, _ := newController(t)
|
|
|
|
inconclusive := &fitness.Evaluation{
|
|
Verdict: fitness.VerdictInconclusive,
|
|
Reasons: []string{"only 11 samples per arm"},
|
|
}
|
|
|
|
_, err := c.Decide(ctx, Request{
|
|
Revision: "R-2", Outcome: Promote, Reason: "we need this before the launch",
|
|
Actor: human, Evaluation: inconclusive, GateInput: passingGateInput(),
|
|
})
|
|
if err == nil {
|
|
t.Fatal("an inconclusive candidate was promoted without acknowledgement")
|
|
}
|
|
|
|
d, err := c.Decide(ctx, Request{
|
|
Revision: "R-2", Outcome: Promote, Reason: "we need this before the launch",
|
|
Actor: human, Evaluation: inconclusive, GateInput: passingGateInput(),
|
|
AcknowledgeOverride: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("an acknowledged override was refused: %v", err)
|
|
}
|
|
if !d.Override {
|
|
t.Error("the override was not recorded on the decision")
|
|
}
|
|
if d.FitnessVerdict != fitness.VerdictInconclusive {
|
|
t.Error("the verdict being overridden was not recorded")
|
|
}
|
|
}
|
|
|
|
// TestReducingExposureNeedsNoGate: requiring permission to stop would point a
|
|
// safety property the wrong way.
|
|
func TestReducingExposureNeedsNoGate(t *testing.T) {
|
|
ctx := context.Background()
|
|
c, _ := newController(t)
|
|
|
|
for _, outcome := range []Outcome{Revert, Abandon, Defer, RetainAsOption, Modify} {
|
|
if _, err := c.Decide(ctx, Request{
|
|
Revision: "R-2", Outcome: outcome,
|
|
Reason: "rolling back after a latency regression",
|
|
Actor: human,
|
|
// No gate input and no evaluation: stopping must always be possible.
|
|
}); err != nil {
|
|
t.Errorf("%s was refused: %v", outcome, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPromotionNeedsEvidenceAndAttribution(t *testing.T) {
|
|
ctx := context.Background()
|
|
c, _ := newController(t)
|
|
|
|
if _, err := c.Decide(ctx, Request{
|
|
Revision: "R-2", Outcome: Promote, Reason: "because",
|
|
Actor: human, GateInput: passingGateInput(),
|
|
}); !errors.Is(err, ErrNoEvidence) {
|
|
t.Errorf("promotion with no fitness verdict returned %v", err)
|
|
}
|
|
|
|
if _, err := c.Decide(ctx, Request{
|
|
Revision: "R-2", Outcome: Promote, Reason: "because",
|
|
Evaluation: succeeded(), GateInput: passingGateInput(),
|
|
}); err == nil {
|
|
t.Error("an unattributed decision was accepted")
|
|
}
|
|
|
|
if _, err := c.Decide(ctx, Request{
|
|
Revision: "R-2", Outcome: Promote, Actor: human,
|
|
Evaluation: succeeded(), GateInput: passingGateInput(),
|
|
}); err == nil {
|
|
t.Error("a decision with no reason was accepted")
|
|
}
|
|
}
|
|
|
|
func TestDecisionHistoryIsOrdered(t *testing.T) {
|
|
ctx := context.Background()
|
|
c, _ := newController(t)
|
|
|
|
for _, outcome := range []Outcome{ExpandExperiment, Revert} {
|
|
req := Request{
|
|
Revision: "R-2", Outcome: outcome, Reason: "step", Actor: human,
|
|
}
|
|
if wideningExposure(outcome) {
|
|
req.Evaluation = succeeded()
|
|
req.GateInput = passingGateInput()
|
|
}
|
|
if _, err := c.Decide(ctx, req); err != nil {
|
|
t.Fatalf("%s: %v", outcome, err)
|
|
}
|
|
}
|
|
|
|
history, err := c.ForRevision(ctx, "R-2")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(history) != 2 {
|
|
t.Fatalf("history has %d entries, want 2", len(history))
|
|
}
|
|
if history[0].DecidedAt.After(history[1].DecidedAt) {
|
|
t.Error("decision history is not in chronological order")
|
|
}
|
|
}
|
|
|
|
func TestUnknownOutcomeRefused(t *testing.T) {
|
|
ctx := context.Background()
|
|
c, _ := newController(t)
|
|
if _, err := c.Decide(ctx, Request{
|
|
Revision: "R-2", Outcome: Outcome("SHIP_IT"), Reason: "r", Actor: human,
|
|
}); err == nil {
|
|
t.Error("an undefined outcome was accepted")
|
|
}
|
|
}
|