229 lines
6.9 KiB
Go
229 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")
|
||
|
|
}
|
||
|
|
}
|