403 lines
14 KiB
Go
403 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
|
||
|
|
}
|