// 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 }