273 lines
8.7 KiB
Go
273 lines
8.7 KiB
Go
|
|
package science
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"path/filepath"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
_ "modernc.org/sqlite"
|
||
|
|
|
||
|
|
"github.com/tegwick/fluid-core/internal/contract"
|
||
|
|
"github.com/tegwick/fluid-core/internal/evidence"
|
||
|
|
)
|
||
|
|
|
||
|
|
const iface contract.InterfaceID = "hall-publishing"
|
||
|
|
|
||
|
|
var operator = contract.Actor{Type: contract.ActorTypeHuman, ID: "worsch"}
|
||
|
|
|
||
|
|
func ptr[T any](v T) *T { return &v }
|
||
|
|
|
||
|
|
func newStores(t *testing.T) (*HypothesisStore, *ExperimentController, *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() })
|
||
|
|
|
||
|
|
hs := NewHypothesisStore(store, iface)
|
||
|
|
return hs, NewExperimentController(store, hs, iface), store
|
||
|
|
}
|
||
|
|
|
||
|
|
// complete returns a hypothesis with everything READY requires.
|
||
|
|
func complete(id contract.HypothesisID, title string) contract.FluidHypothesis {
|
||
|
|
return contract.FluidHypothesis{
|
||
|
|
ID: id,
|
||
|
|
Title: title,
|
||
|
|
Observation: contract.FluidHypothesisObservation{
|
||
|
|
Summary: "consumers list the collection repeatedly to find one entry",
|
||
|
|
EvidenceRefs: []contract.EvidenceRef{"topology:GET /entries"},
|
||
|
|
},
|
||
|
|
Pressure: contract.FluidHypothesisPressure{
|
||
|
|
Classes: []contract.PressureClass{contract.PressureClassSuccessfulButInefficientUsage},
|
||
|
|
},
|
||
|
|
Explanation: contract.FluidHypothesisExplanation{
|
||
|
|
Claim: "latest entry is a first-class consumer concept the interface does not expose",
|
||
|
|
},
|
||
|
|
ProposedAdaptation: contract.FluidHypothesisProposedAdaptation{
|
||
|
|
Class: contract.AdaptationClassContract,
|
||
|
|
Summary: "add an explicit latest-entry capability",
|
||
|
|
},
|
||
|
|
ExpectedOutcomes: []contract.ExpectedOutcome{
|
||
|
|
{Metric: "requests_per_completed_task", Target: 1.2, Direction: contract.ExpectedOutcomeDirectionLower},
|
||
|
|
},
|
||
|
|
SuccessCriteria: contract.FluidHypothesisSuccessCriteria{
|
||
|
|
Expression: "requests_per_completed_task <= 1.2 AND no guardrail violation",
|
||
|
|
},
|
||
|
|
Complexity: contract.FluidHypothesisComplexity{
|
||
|
|
ExpectedDelta: contract.ComplexityDelta{OperationCount: ptr(1.0)},
|
||
|
|
},
|
||
|
|
Risk: contract.FluidHypothesisRisk{Level: contract.FluidHypothesisRiskLevelLOW},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestDraftMayBeIncompleteButReadyMayNot(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
hs, _, _ := newStores(t)
|
||
|
|
|
||
|
|
// Drafting stays cheap: a half-formed idea is allowed to be half-formed.
|
||
|
|
sparse := contract.FluidHypothesis{ID: "H-1", Title: "maybe the list endpoint is wrong"}
|
||
|
|
if _, err := hs.Create(ctx, sparse, operator); err != nil {
|
||
|
|
t.Fatalf("an incomplete draft was refused: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
_, err := hs.Transition(ctx, "H-1", contract.FluidHypothesisStateREADY, operator, "let us look at this")
|
||
|
|
if !errors.Is(err, ErrIncomplete) {
|
||
|
|
t.Fatalf("an incomplete hypothesis reached READY: %v", err)
|
||
|
|
}
|
||
|
|
// The error must say what is missing, or it is not actionable.
|
||
|
|
for _, want := range []string{"evidence_refs", "expected_outcomes", "success_criteria"} {
|
||
|
|
if !strings.Contains(err.Error(), want) {
|
||
|
|
t.Errorf("error does not name missing %s: %v", want, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestCannotSkipToAccepted: a hypothesis jumping straight to ACCEPTED would
|
||
|
|
// claim evidence it never gathered.
|
||
|
|
func TestCannotSkipToAccepted(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
hs, _, _ := newStores(t)
|
||
|
|
|
||
|
|
if _, err := hs.Create(ctx, complete("H-1", "latest entry"), operator); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
|
||
|
|
_, err := hs.Transition(ctx, "H-1", contract.FluidHypothesisStateACCEPTED, operator, "looks right to me")
|
||
|
|
if !errors.Is(err, ErrInvalidTransition) {
|
||
|
|
t.Errorf("DRAFT jumped to ACCEPTED: %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestLifecycleHappyPath(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
hs, _, store := newStores(t)
|
||
|
|
|
||
|
|
if _, err := hs.Create(ctx, complete("H-1", "latest entry"), operator); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
for _, to := range []State{
|
||
|
|
contract.FluidHypothesisStateREADY,
|
||
|
|
contract.FluidHypothesisStatePRIORITIZED,
|
||
|
|
contract.FluidHypothesisStateDESIGNING,
|
||
|
|
contract.FluidHypothesisStateEXPERIMENTING,
|
||
|
|
contract.FluidHypothesisStateEVALUATING,
|
||
|
|
} {
|
||
|
|
if _, err := hs.Transition(ctx, "H-1", to, operator, "advancing"); err != nil {
|
||
|
|
t.Fatalf("transition to %s failed: %v", to, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
got, err := hs.RecordOutcome(ctx, "H-1", contract.FluidHypothesisOutcomeStatusCONFIRMED,
|
||
|
|
"requests per task fell from 2.7 to 1.15", []contract.EvidenceRef{"metrics:E-1"}, operator)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if got.State != contract.FluidHypothesisStateACCEPTED {
|
||
|
|
t.Errorf("state = %s, want ACCEPTED", got.State)
|
||
|
|
}
|
||
|
|
|
||
|
|
events, err := store.Events(ctx, evidence.EventFilter{EntityID: "H-1"})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if len(events) < 7 {
|
||
|
|
t.Errorf("lifecycle left %d events; every transition should be observable", len(events))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestOutcomeOnlyWhileEvaluating: recording a result while still running would
|
||
|
|
// assume what the experiment set out to find.
|
||
|
|
func TestOutcomeOnlyWhileEvaluating(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
hs, _, _ := newStores(t)
|
||
|
|
|
||
|
|
if _, err := hs.Create(ctx, complete("H-1", "latest entry"), operator); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, err := hs.RecordOutcome(ctx, "H-1", contract.FluidHypothesisOutcomeStatusCONFIRMED,
|
||
|
|
"it worked", nil, operator); err == nil {
|
||
|
|
t.Error("an outcome was recorded on a DRAFT hypothesis")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestInconclusiveDefersRatherThanRejects: inconclusive is not refuted, and
|
||
|
|
// burying a possibly-good idea is the wrong default.
|
||
|
|
func TestInconclusiveDefersRatherThanRejects(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
hs, _, _ := newStores(t)
|
||
|
|
|
||
|
|
if _, err := hs.Create(ctx, complete("H-1", "latest entry"), operator); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
advance(t, hs, "H-1", contract.FluidHypothesisStateEVALUATING)
|
||
|
|
|
||
|
|
got, err := hs.RecordOutcome(ctx, "H-1", contract.FluidHypothesisOutcomeStatusINCONCLUSIVE,
|
||
|
|
"only 12 samples per arm", nil, operator)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if got.State != contract.FluidHypothesisStateDEFERRED {
|
||
|
|
t.Errorf("state = %s, want DEFERRED", got.State)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestCompetitionIsSymmetric(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
hs, _, _ := newStores(t)
|
||
|
|
|
||
|
|
for _, id := range []contract.HypothesisID{"H-1", "H-2", "H-3"} {
|
||
|
|
if _, err := hs.Create(ctx, complete(id, string(id)), operator); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
group, err := hs.Compete(ctx, "CG-1", []contract.HypothesisID{"H-1", "H-2", "H-3"}, operator)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if len(group.Members) != 3 {
|
||
|
|
t.Fatalf("group has %d members", len(group.Members))
|
||
|
|
}
|
||
|
|
|
||
|
|
// Every member must know about every rival, or a reader looking at one
|
||
|
|
// would miss that alternatives exist.
|
||
|
|
for _, id := range group.Members {
|
||
|
|
h, err := hs.Get(ctx, id)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if h.Competition == nil || len(h.Competition.Alternatives) != 2 {
|
||
|
|
t.Errorf("%s does not list both rivals: %+v", id, h.Competition)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if _, err := hs.Compete(ctx, "CG-2", []contract.HypothesisID{"H-1"}, operator); err == nil {
|
||
|
|
t.Error("a competition group of one was accepted")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestResolveSupersedesRatherThanRejects: rejection says the explanation was
|
||
|
|
// wrong; superseded says a better one won.
|
||
|
|
func TestResolveSupersedesRatherThanRejects(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
hs, _, _ := newStores(t)
|
||
|
|
|
||
|
|
for _, id := range []contract.HypothesisID{"H-1", "H-2"} {
|
||
|
|
if _, err := hs.Create(ctx, complete(id, string(id)), operator); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if _, err := hs.Compete(ctx, "CG-1", []contract.HypothesisID{"H-1", "H-2"}, operator); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
|
||
|
|
advance(t, hs, "H-1", contract.FluidHypothesisStateEVALUATING)
|
||
|
|
if _, err := hs.RecordOutcome(ctx, "H-1", contract.FluidHypothesisOutcomeStatusCONFIRMED,
|
||
|
|
"teaser plus link won on read-through", nil, operator); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
|
||
|
|
group, err := hs.Resolve(ctx, "CG-1", "H-1", operator, "H-1 measured better on the primary metric")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if !group.Resolved || group.Preferred != "H-1" {
|
||
|
|
t.Errorf("group not resolved to H-1: %+v", group)
|
||
|
|
}
|
||
|
|
|
||
|
|
loser, err := hs.Get(ctx, "H-2")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if loser.State != contract.FluidHypothesisStateSUPERSEDED {
|
||
|
|
t.Errorf("loser state = %s, want SUPERSEDED", loser.State)
|
||
|
|
}
|
||
|
|
|
||
|
|
if _, err := hs.Resolve(ctx, "CG-1", "H-99", operator, "reason"); !errors.Is(err, ErrNotCompeting) {
|
||
|
|
t.Errorf("a non-member won a competition: %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func advance(t *testing.T, hs *HypothesisStore, id contract.HypothesisID, to State) {
|
||
|
|
t.Helper()
|
||
|
|
ctx := context.Background()
|
||
|
|
path := []State{
|
||
|
|
contract.FluidHypothesisStateREADY,
|
||
|
|
contract.FluidHypothesisStatePRIORITIZED,
|
||
|
|
contract.FluidHypothesisStateDESIGNING,
|
||
|
|
contract.FluidHypothesisStateEXPERIMENTING,
|
||
|
|
contract.FluidHypothesisStateEVALUATING,
|
||
|
|
}
|
||
|
|
for _, s := range path {
|
||
|
|
if _, err := hs.Transition(ctx, id, s, operator, "advancing"); err != nil {
|
||
|
|
t.Fatalf("advance to %s: %v", s, err)
|
||
|
|
}
|
||
|
|
if s == to {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|