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
402 lines
14 KiB
Go
402 lines
14 KiB
Go
// Package science implements the FLUID scientific adaptation loop: hypotheses,
|
|
// competition between them, bounded experiments, and the promotion decisions
|
|
// they inform.
|
|
//
|
|
// Nothing here is generative. ArchitectureBlueprint.md Phase C is deliberately
|
|
// human-driven: the point of this stage, per section 50, is to prove that the
|
|
// revision-experiment-fitness loop works cleanly and safely, not to automate
|
|
// the reasoning inside it.
|
|
//
|
|
// The schema document's section 18 sets the discipline this package keeps:
|
|
// what we observed, what we think explains it, what we changed, and what
|
|
// happened afterwards must not collapse into one narrative. Keeping them apart
|
|
// is what makes criticism, competing explanations and rollback possible.
|
|
package science
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/tegwick/fluid-core/internal/contract"
|
|
"github.com/tegwick/fluid-core/internal/evidence"
|
|
)
|
|
|
|
// HypothesisStore persists hypotheses and enforces their lifecycle.
|
|
type HypothesisStore struct {
|
|
store evidence.Store
|
|
iface contract.InterfaceID
|
|
now func() time.Time
|
|
}
|
|
|
|
// NewHypothesisStore returns a store backed by the evidence log.
|
|
func NewHypothesisStore(store evidence.Store, iface contract.InterfaceID) *HypothesisStore {
|
|
return &HypothesisStore{store: store, iface: iface, now: time.Now}
|
|
}
|
|
|
|
// State is a point in the hypothesis lifecycle.
|
|
type State = contract.FluidHypothesisState
|
|
|
|
// transitions is the permitted lifecycle graph.
|
|
//
|
|
// It is explicit rather than free-form 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.
|
|
var transitions = map[State][]State{
|
|
contract.FluidHypothesisStateDRAFT: {
|
|
contract.FluidHypothesisStateREADY,
|
|
contract.FluidHypothesisStateREJECTED,
|
|
contract.FluidHypothesisStateDEFERRED,
|
|
// A draft can lose a competition to a better-formed rival before it is
|
|
// ever completed. That is superseding, not rejection: nobody showed the
|
|
// idea was wrong, only that something else explained the pressure first.
|
|
contract.FluidHypothesisStateSUPERSEDED,
|
|
},
|
|
contract.FluidHypothesisStateREADY: {
|
|
contract.FluidHypothesisStatePRIORITIZED,
|
|
contract.FluidHypothesisStateDEFERRED,
|
|
contract.FluidHypothesisStateREJECTED,
|
|
contract.FluidHypothesisStateSUPERSEDED,
|
|
},
|
|
contract.FluidHypothesisStatePRIORITIZED: {
|
|
contract.FluidHypothesisStateDESIGNING,
|
|
contract.FluidHypothesisStateDEFERRED,
|
|
contract.FluidHypothesisStateREJECTED,
|
|
contract.FluidHypothesisStateSUPERSEDED,
|
|
},
|
|
contract.FluidHypothesisStateDESIGNING: {
|
|
contract.FluidHypothesisStateEXPERIMENTING,
|
|
contract.FluidHypothesisStateDEFERRED,
|
|
contract.FluidHypothesisStateREJECTED,
|
|
contract.FluidHypothesisStateSUPERSEDED,
|
|
},
|
|
contract.FluidHypothesisStateEXPERIMENTING: {
|
|
contract.FluidHypothesisStateEVALUATING,
|
|
// An experiment can be stopped without a verdict; that is a normal
|
|
// outcome, not a failure of the hypothesis.
|
|
contract.FluidHypothesisStateDEFERRED,
|
|
contract.FluidHypothesisStateREJECTED,
|
|
},
|
|
contract.FluidHypothesisStateEVALUATING: {
|
|
contract.FluidHypothesisStateACCEPTED,
|
|
contract.FluidHypothesisStateREJECTED,
|
|
contract.FluidHypothesisStateDEFERRED,
|
|
contract.FluidHypothesisStateSUPERSEDED,
|
|
},
|
|
// A deferred hypothesis can come back when circumstances change.
|
|
contract.FluidHypothesisStateDEFERRED: {
|
|
contract.FluidHypothesisStateREADY,
|
|
contract.FluidHypothesisStatePRIORITIZED,
|
|
contract.FluidHypothesisStateREJECTED,
|
|
contract.FluidHypothesisStateSUPERSEDED,
|
|
},
|
|
// Terminal. A rejected hypothesis that turns out to be right becomes a new
|
|
// hypothesis citing the old one, so the reversal is visible.
|
|
contract.FluidHypothesisStateACCEPTED: {contract.FluidHypothesisStateSUPERSEDED},
|
|
contract.FluidHypothesisStateREJECTED: {},
|
|
contract.FluidHypothesisStateSUPERSEDED: {},
|
|
}
|
|
|
|
var (
|
|
// ErrInvalidTransition reports a lifecycle move that is not permitted.
|
|
ErrInvalidTransition = errors.New("invalid hypothesis state transition")
|
|
// ErrIncomplete reports a hypothesis missing something its state requires.
|
|
ErrIncomplete = errors.New("hypothesis is missing required content")
|
|
// ErrNotFound reports an unknown hypothesis.
|
|
ErrNotFound = evidence.ErrNotFound
|
|
)
|
|
|
|
// CanTransition reports whether a lifecycle move is permitted.
|
|
func CanTransition(from, to State) bool {
|
|
if from == to {
|
|
return true
|
|
}
|
|
for _, allowed := range transitions[from] {
|
|
if allowed == to {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Create records a new hypothesis in DRAFT.
|
|
//
|
|
// A draft is allowed to be incomplete: the point of the state is to hold a
|
|
// half-formed idea. Completeness is checked when it moves to READY, which is
|
|
// the claim that it is worth someone's time.
|
|
func (s *HypothesisStore) Create(ctx context.Context, h contract.FluidHypothesis, actor contract.Actor) (contract.FluidHypothesis, error) {
|
|
if h.ID == "" {
|
|
return h, errors.New("hypothesis needs an id")
|
|
}
|
|
if err := contract.RequireKind(string(h.ID), contract.KindHypothesis); err != nil {
|
|
return h, err
|
|
}
|
|
if h.Title == "" {
|
|
return h, fmt.Errorf("%w: a hypothesis needs a title", ErrIncomplete)
|
|
}
|
|
|
|
if _, err := s.Get(ctx, h.ID); err == nil {
|
|
return h, fmt.Errorf("hypothesis %s already exists", h.ID)
|
|
} else if !errors.Is(err, ErrNotFound) {
|
|
return h, err
|
|
}
|
|
|
|
h.SchemaVersion = "0.1"
|
|
h.InterfaceID = s.iface
|
|
if h.State == "" {
|
|
h.State = contract.FluidHypothesisStateDRAFT
|
|
}
|
|
if h.CreatedAt == nil {
|
|
now := s.now().UTC()
|
|
h.CreatedAt = &now
|
|
}
|
|
if h.CreatedBy == nil {
|
|
h.CreatedBy = &actor
|
|
}
|
|
|
|
if err := s.put(ctx, h); err != nil {
|
|
return h, err
|
|
}
|
|
return h, s.event(ctx, h, "HYPOTHESIS_CREATED", actor, h.Title, nil)
|
|
}
|
|
|
|
// readyRequirements lists what a hypothesis must have before it leaves DRAFT.
|
|
//
|
|
// These mirror the schema document's section 4 mandatory fields. The check runs
|
|
// at READY rather than at creation so that drafting stays cheap, but nothing
|
|
// can be prioritized, experimented on or accepted without them.
|
|
func readyRequirements(h contract.FluidHypothesis) []string {
|
|
var missing []string
|
|
|
|
if h.Observation.Summary == "" {
|
|
missing = append(missing, "observation.summary: what was actually seen")
|
|
}
|
|
if len(h.Observation.EvidenceRefs) == 0 {
|
|
missing = append(missing, "observation.evidence_refs: a claim with no evidence is not falsifiable")
|
|
}
|
|
if len(h.Pressure.Classes) == 0 {
|
|
missing = append(missing, "pressure.classes: which kind of pressure this addresses")
|
|
}
|
|
if h.Explanation.Claim == "" {
|
|
missing = append(missing, "explanation.claim: what you think explains the observation")
|
|
}
|
|
if h.ProposedAdaptation.Summary == "" {
|
|
missing = append(missing, "proposed_adaptation.summary: what you propose to change")
|
|
}
|
|
if len(h.ExpectedOutcomes) == 0 {
|
|
missing = append(missing, "expected_outcomes: a prediction, or the hypothesis cannot be wrong")
|
|
}
|
|
if h.SuccessCriteria.Expression == "" {
|
|
missing = append(missing, "success_criteria.expression: how you will know")
|
|
}
|
|
// Complexity and risk are required structs rather than pointers, so an
|
|
// unset one shows up as a zero value: an empty delta and an empty level.
|
|
if h.Complexity.ExpectedDelta == (contract.ComplexityDelta{}) {
|
|
missing = append(missing, "complexity.expected_delta: complexity is a budget")
|
|
}
|
|
if h.Risk.Level == "" {
|
|
missing = append(missing, "risk.level")
|
|
}
|
|
return missing
|
|
}
|
|
|
|
// Transition moves a hypothesis to a new state.
|
|
func (s *HypothesisStore) Transition(ctx context.Context, id contract.HypothesisID, to State, actor contract.Actor, reason string) (contract.FluidHypothesis, error) {
|
|
h, err := s.Get(ctx, id)
|
|
if err != nil {
|
|
return h, err
|
|
}
|
|
|
|
if !to.Valid() {
|
|
return h, fmt.Errorf("unknown hypothesis state %q", to)
|
|
}
|
|
if !CanTransition(h.State, to) {
|
|
return h, fmt.Errorf("%w: %s cannot become %s", ErrInvalidTransition, h.State, to)
|
|
}
|
|
if reason == "" {
|
|
return h, errors.New("a lifecycle transition requires a reason")
|
|
}
|
|
|
|
// Everything past DRAFT is a claim that the hypothesis is worth acting on.
|
|
if to != contract.FluidHypothesisStateDRAFT && to != contract.FluidHypothesisStateREJECTED &&
|
|
to != contract.FluidHypothesisStateDEFERRED && to != contract.FluidHypothesisStateSUPERSEDED {
|
|
if missing := readyRequirements(h); len(missing) > 0 {
|
|
return h, fmt.Errorf("%w: %s cannot reach %s until it has:\n - %s",
|
|
ErrIncomplete, id, to, joinLines(missing))
|
|
}
|
|
}
|
|
|
|
previous := h.State
|
|
h.State = to
|
|
|
|
if err := s.put(ctx, h); err != nil {
|
|
return h, err
|
|
}
|
|
return h, s.event(ctx, h, "HYPOTHESIS_"+string(to), actor,
|
|
fmt.Sprintf("%s -> %s: %s", previous, to, reason), nil)
|
|
}
|
|
|
|
// RecordOutcome closes a hypothesis with its result.
|
|
//
|
|
// The outcome is written separately from the explanation it tests, and only in
|
|
// EVALUATING. A hypothesis that recorded its own result while still running
|
|
// would be assuming what it set out to find.
|
|
func (s *HypothesisStore) RecordOutcome(ctx context.Context, id contract.HypothesisID, status contract.FluidHypothesisOutcomeStatus, summary string, evidenceRefs []contract.EvidenceRef, actor contract.Actor) (contract.FluidHypothesis, error) {
|
|
h, err := s.Get(ctx, id)
|
|
if err != nil {
|
|
return h, err
|
|
}
|
|
if h.State != contract.FluidHypothesisStateEVALUATING {
|
|
return h, fmt.Errorf("an outcome can only be recorded while EVALUATING, not in %s", h.State)
|
|
}
|
|
if summary == "" {
|
|
return h, errors.New("an outcome needs a summary")
|
|
}
|
|
|
|
h.Outcome = &contract.FluidHypothesisOutcome{
|
|
Status: &status,
|
|
Summary: &summary,
|
|
EvidenceRefs: evidenceRefs,
|
|
}
|
|
|
|
next := contract.FluidHypothesisStateREJECTED
|
|
if status == contract.FluidHypothesisOutcomeStatusCONFIRMED {
|
|
next = contract.FluidHypothesisStateACCEPTED
|
|
}
|
|
if status == contract.FluidHypothesisOutcomeStatusINCONCLUSIVE {
|
|
// Inconclusive is not refuted. Deferring keeps it available for a
|
|
// better-powered experiment instead of burying a possibly-good idea.
|
|
next = contract.FluidHypothesisStateDEFERRED
|
|
}
|
|
h.State = next
|
|
|
|
if err := s.put(ctx, h); err != nil {
|
|
return h, err
|
|
}
|
|
return h, s.event(ctx, h, "HYPOTHESIS_OUTCOME_RECORDED", actor,
|
|
fmt.Sprintf("%s: %s", status, summary), evidenceRefs)
|
|
}
|
|
|
|
// Get returns one hypothesis.
|
|
func (s *HypothesisStore) Get(ctx context.Context, id contract.HypothesisID) (contract.FluidHypothesis, error) {
|
|
body, err := s.store.Record(ctx, contract.KindHypothesis, string(id))
|
|
if err != nil {
|
|
return contract.FluidHypothesis{}, err
|
|
}
|
|
var doc contract.HypothesisDocument
|
|
if err := json.Unmarshal(body, &doc); err != nil {
|
|
return contract.FluidHypothesis{}, fmt.Errorf("decode hypothesis %s: %w", id, err)
|
|
}
|
|
return doc.FluidHypothesis, nil
|
|
}
|
|
|
|
// List returns hypotheses, optionally filtered by state.
|
|
func (s *HypothesisStore) List(ctx context.Context, state State) ([]contract.FluidHypothesis, error) {
|
|
records, err := s.store.Records(ctx, contract.KindHypothesis)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := make([]contract.FluidHypothesis, 0, len(records))
|
|
for _, body := range records {
|
|
var doc contract.HypothesisDocument
|
|
if err := json.Unmarshal(body, &doc); err != nil {
|
|
continue
|
|
}
|
|
if state != "" && doc.FluidHypothesis.State != state {
|
|
continue
|
|
}
|
|
out = append(out, doc.FluidHypothesis)
|
|
}
|
|
|
|
// Highest priority first; the list doubles as a queue.
|
|
sort.Slice(out, func(i, j int) bool {
|
|
pi, pj := priorityOf(out[i]), priorityOf(out[j])
|
|
if pi != pj {
|
|
return pi > pj
|
|
}
|
|
return out[i].ID < out[j].ID
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
func priorityOf(h contract.FluidHypothesis) float64 {
|
|
if h.Priority == nil || h.Priority.Score == nil {
|
|
return 0
|
|
}
|
|
return *h.Priority.Score
|
|
}
|
|
|
|
// AttachRevision links a candidate revision to the hypothesis it came from.
|
|
func (s *HypothesisStore) AttachRevision(ctx context.Context, id contract.HypothesisID, rev contract.RevisionID, actor contract.Actor) error {
|
|
if err := contract.RequireKind(string(rev), contract.KindRevision); err != nil {
|
|
return err
|
|
}
|
|
h, err := s.Get(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, existing := range h.CandidateRevisionRefs {
|
|
if existing == rev {
|
|
return nil
|
|
}
|
|
}
|
|
h.CandidateRevisionRefs = append(h.CandidateRevisionRefs, rev)
|
|
|
|
if err := s.put(ctx, h); err != nil {
|
|
return err
|
|
}
|
|
return s.event(ctx, h, "HYPOTHESIS_REVISION_ATTACHED", actor, fmt.Sprintf("attached %s", rev), nil)
|
|
}
|
|
|
|
// AttachExperiment links an experiment to the hypothesis it tests.
|
|
func (s *HypothesisStore) AttachExperiment(ctx context.Context, id contract.HypothesisID, exp contract.ExperimentID, actor contract.Actor) error {
|
|
h, err := s.Get(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, existing := range h.ExperimentRefs {
|
|
if existing == exp {
|
|
return nil
|
|
}
|
|
}
|
|
h.ExperimentRefs = append(h.ExperimentRefs, exp)
|
|
|
|
if err := s.put(ctx, h); err != nil {
|
|
return err
|
|
}
|
|
return s.event(ctx, h, "HYPOTHESIS_EXPERIMENT_ATTACHED", actor, fmt.Sprintf("attached %s", exp), nil)
|
|
}
|
|
|
|
func (s *HypothesisStore) put(ctx context.Context, h contract.FluidHypothesis) error {
|
|
body, err := json.Marshal(contract.HypothesisDocument{FluidHypothesis: h})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.store.PutRecord(ctx, contract.KindHypothesis, string(h.ID), body)
|
|
}
|
|
|
|
func (s *HypothesisStore) event(ctx context.Context, h contract.FluidHypothesis, kind string, actor contract.Actor, reason string, refs []contract.EvidenceRef) error {
|
|
return s.store.AppendEvent(ctx, contract.FluidEvent{
|
|
SchemaVersion: "0.1",
|
|
ID: contract.EventID(fmt.Sprintf("EV-%s-%d", h.ID, s.now().UnixNano())),
|
|
OccurredAt: s.now().UTC(),
|
|
EntityType: contract.FluidEventEntityTypeHypothesis,
|
|
EntityID: string(h.ID),
|
|
EventType: kind,
|
|
Actor: actor,
|
|
Reason: reason,
|
|
EvidenceRefs: refs,
|
|
})
|
|
}
|
|
|
|
func joinLines(items []string) string {
|
|
out := items[0]
|
|
for _, item := range items[1:] {
|
|
out += "\n - " + item
|
|
}
|
|
return out
|
|
}
|