fluid-core/internal/science/hypothesis.go
tegwick 634807a0cb Add hypothesis lifecycle, competition groups, experiments and promotion
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
2026-09-04 06:39:33 +02:00

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
}