Add science control APIs and the hypothesis, experiment and promote CLI
Some checks failed
ci / build (push) Has been cancelled

Completes FLUID-WP-0006. The loop now runs end to end from the command
line: two competing presentation hypotheses, an experiment that issues a
routing policy rather than touching traffic, an amendment, a stop that
returns traffic to the default, a confirmed outcome, a resolved
competition, and a promotion the gate can refuse.

Starting or stopping an experiment returns the routing policy for the
operator to install rather than installing it. Blueprint 17 keeps the
controller out of the traffic path, and installing from the handler
would put it straight back in; emitting the document keeps the
separation visible instead of implied.

`fluid audit trace` now answers the section 25 questions from events
rather than summary records, and names the rivals a hypothesis beat: an
audit asking which hypotheses were considered is not answered by naming
only the winner.

Two fixes found by driving the CLI rather than only the tests. Go's flag
package stops at the first positional, so ids given after flags silently
swallowed them; ids are now taken before parsing. And there was no way
to attach a revision to the hypothesis that produced it, which left
`audit trace` unable to say why a revision existed -- `hypothesis attach`
closes that.

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
This commit is contained in:
tegwick 2026-09-04 06:44:47 +02:00
parent 634807a0cb
commit 3407b9cf85
9 changed files with 1006 additions and 29 deletions

View file

@ -33,6 +33,7 @@ import (
"github.com/tegwick/fluid-core/internal/observation"
"github.com/tegwick/fluid-core/internal/policy"
"github.com/tegwick/fluid-core/internal/publish"
"github.com/tegwick/fluid-core/internal/science"
"github.com/tegwick/fluid-core/internal/signing"
)
@ -106,12 +107,16 @@ func run() error {
log.Print("no redaction salt configured: telemetry, feedback and pressure endpoints are disabled")
}
hypotheses := science.NewHypothesisStore(ev, contract.InterfaceID(*iface))
experiments := science.NewExperimentController(ev, hypotheses, contract.InterfaceID(*iface))
srv := &http.Server{
Addr: *addr,
Handler: control.NewServer(
control.NewRevisionAPI(ev, pipeline),
control.NewIntentAPI(intents, gate),
pressureAPI,
control.NewScienceAPI(hypotheses, experiments),
).Routes(),
ReadHeaderTimeout: 10 * time.Second,
}

View file

@ -21,6 +21,7 @@ import (
"github.com/tegwick/fluid-core/internal/intent"
"github.com/tegwick/fluid-core/internal/policy"
"github.com/tegwick/fluid-core/internal/publish"
"github.com/tegwick/fluid-core/internal/science"
"github.com/tegwick/fluid-core/internal/signing"
)
@ -451,10 +452,10 @@ func runTelemetry(ctx context.Context, g globals, args []string) error {
// runAudit reconstructs the chain behind a revision.
//
// This is the command that has to answer the eleven questions in
// FluidAPIStandards.md section 25. It is deliberately built from events rather
// than from summary records: the records say what is true now, the events say
// how it came to be.
// This command has to answer the eleven questions in FluidAPIStandards.md
// section 25. It is built from events rather than from summary records: the
// records say what is true now, the events say how it came to be, and only the
// second can settle a question about a decision taken months ago.
func runAudit(ctx context.Context, g globals, args []string) error {
if len(args) < 2 || args[0] != "trace" {
return errors.New("usage: fluid audit trace <revision>")
@ -481,29 +482,96 @@ func runAudit(ctx context.Context, g globals, args []string) error {
fmt.Printf("Audit trace for %s\n\n", target)
// Which intent governed it, and under what authority.
is := intent.New(store, contract.InterfaceID(iface))
if v, err := is.GoverningIntent(ctx, contract.RevisionID(target)); err == nil {
fmt.Printf("Governed by %s (%s), authority mode %s\n\n", v.Version, v.Digest, v.Mode)
fmt.Printf("Governed by %s (%s) at authority mode %s\n\n", v.Version, v.Digest, v.Mode)
} else {
fmt.Printf("Governed by: UNKNOWN no intent binding recorded\n\n")
fmt.Printf("Governed by: UNKNOWN - no intent binding recorded\n\n")
}
// What evidence triggered it, and which hypotheses were considered.
hs := science.NewHypothesisStore(store, contract.InterfaceID(iface))
if err := printOrigins(ctx, store, hs, contract.RevisionID(target)); err != nil {
return err
}
// What happened, in order, and who authorized it.
fmt.Println("Lifecycle")
w := out()
fmt.Fprintln(w, "WHEN\tEVENT\tACTOR\tINPUTS\tREASON")
fmt.Fprintln(w, " WHEN\tEVENT\tACTOR\tINPUTS\tREASON")
for _, ev := range events {
fmt.Fprintf(w, "%s\t%s\t%s:%s\t%s\t%s\n",
fmt.Fprintf(w, " %s\t%s\t%s:%s\t%s\t%s\n",
ev.OccurredAt.Format(time.RFC3339), ev.EventType,
ev.Actor.Type, ev.Actor.ID,
strings.Join(ev.Inputs, ","), oneLine(ev.Reason))
strings.Join(ev.Inputs, ","), truncate(oneLine(ev.Reason), 64))
}
if err := w.Flush(); err != nil {
return err
}
count, err := store.Telemetry(ctx, evidence.TelemetryFilter{Revision: contract.RevisionID(target)})
// What happened after deployment.
telemetry, err := store.Telemetry(ctx, evidence.TelemetryFilter{Revision: contract.RevisionID(target)})
if err == nil {
fmt.Printf("\n%d telemetry event(s) recorded against %s\n", len(count), target)
errorCount := 0
for _, ev := range telemetry {
if ev.Error != nil {
errorCount++
}
}
fmt.Printf("\nObserved\n %d telemetry event(s), %d of them errors\n", len(telemetry), errorCount)
}
return nil
}
// printOrigins reports the hypotheses and pressure behind a revision.
func printOrigins(ctx context.Context, store *evidence.SQLStore, hs *science.HypothesisStore, rev contract.RevisionID) error {
all, err := hs.List(ctx, "")
if err != nil {
return err
}
var origins []contract.FluidHypothesis
for _, h := range all {
for _, candidate := range h.CandidateRevisionRefs {
if candidate == rev {
origins = append(origins, h)
}
}
}
if len(origins) == 0 {
fmt.Println("Origins\n no hypothesis claims this revision")
fmt.Println()
return nil
}
fmt.Println("Origins")
w := out()
fmt.Fprintln(w, " HYPOTHESIS\tSTATE\tGROUP\tCLAIM")
for _, h := range origins {
group := ""
if h.Competition != nil {
group = h.Competition.GroupID
}
fmt.Fprintf(w, " %s\t%s\t%s\t%s\n",
h.ID, h.State, group, truncate(oneLine(h.Explanation.Claim), 56))
}
if err := w.Flush(); err != nil {
return err
}
// Rivals matter: an audit asking which hypotheses were considered is not
// answered by naming only the one that won.
for _, h := range origins {
if h.Competition == nil || len(h.Competition.Alternatives) == 0 {
continue
}
fmt.Printf(" %s competed against %v in %s\n",
h.ID, h.Competition.Alternatives, h.Competition.GroupID)
}
fmt.Println()
return nil
}
@ -592,3 +660,20 @@ func loadSigner(keyID, keyFile string, ephemeral bool) (*signing.Signer, error)
}
return nil, errors.New("no signing key: pass --key-file, or --ephemeral-key for development")
}
// yamlUnmarshal is a small indirection so science.go does not import yaml
// directly; the parser choice stays in one place.
func yamlUnmarshal(raw []byte, into any) error { return yaml.Unmarshal(raw, into) }
// governingMode resolves the authority mode governing a revision.
func governingMode(ctx context.Context, store *evidence.SQLStore, iface contract.InterfaceID, rev contract.RevisionID) (intent.AuthorityMode, error) {
is := intent.New(store, iface)
if v, err := is.GoverningIntent(ctx, rev); err == nil {
return v.Mode, nil
}
v, err := is.Active(ctx)
if err != nil {
return 0, fmt.Errorf("no intent governs %s and none is active: %w", rev, err)
}
return v.Mode, nil
}

View file

@ -78,13 +78,13 @@ func runPressure(ctx context.Context, g globals, args []string) error {
return analyzePressure(ctx, g, store, reg, args[1:])
case "dismiss":
id, rest, ok := takeID(args[1:])
fs := newFlagSet("pressure dismiss")
reason := fs.String("reason", "", "why this pressure will not be acted on")
if err := fs.Parse(args[1:]); err != nil {
if err := fs.Parse(rest); err != nil {
return err
}
rest := fs.fs.Args()
if len(rest) == 0 {
if !ok {
return errors.New("pressure dismiss needs an id")
}
if *reason == "" {
@ -92,11 +92,11 @@ func runPressure(ctx context.Context, g globals, args []string) error {
}
actor := contract.Actor{Type: contract.ActorTypeHuman, ID: operator()}
if err := reg.SetStatus(ctx, contract.PressureID(rest[0]),
if err := reg.SetStatus(ctx, contract.PressureID(id),
contract.FluidPressureStatusDISMISSED, actor, *reason); err != nil {
return err
}
fmt.Printf("dismissed %s\n", rest[0])
fmt.Printf("dismissed %s\n", id)
return nil
default:

View file

@ -35,6 +35,9 @@ Commands:
pressure dismiss Record that a pressure will not be acted on
cohort Summarize cohort populations
fitness compare Compare a candidate revision against its control
hypothesis Create, advance and compete hypotheses
experiment Design, start, stop and finalize experiments
promote Record a promotion decision
events Show audit events
telemetry Show recorded telemetry
audit trace Reconstruct the history behind a revision
@ -124,6 +127,12 @@ func run(args []string) error {
return runCohort(ctx, g, rest)
case "fitness":
return runFitness(ctx, g, rest)
case "hypothesis":
return runHypothesis(ctx, g, rest)
case "experiment":
return runExperiment(ctx, g, rest)
case "promote":
return runPromote(ctx, g, rest)
case "audit":
return runAudit(ctx, g, rest)
default:

518
cmd/fluid/science.go Normal file
View file

@ -0,0 +1,518 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"github.com/tegwick/fluid-core/internal/contract"
"github.com/tegwick/fluid-core/internal/evidence"
"github.com/tegwick/fluid-core/internal/fitness"
"github.com/tegwick/fluid-core/internal/policy"
"github.com/tegwick/fluid-core/internal/promotion"
"github.com/tegwick/fluid-core/internal/science"
)
// takeID pulls the identifier that must follow a subcommand.
//
// Go's flag package stops parsing at the first non-flag token, so an id given
// after the flags would silently swallow them. Requiring it immediately after
// the subcommand keeps the usage unambiguous and the parse correct.
func takeID(args []string) (string, []string, bool) {
if len(args) == 0 || strings.HasPrefix(args[0], "-") {
return "", args, false
}
return args[0], args[1:], true
}
// ---------- hypothesis ----------
func runHypothesis(ctx context.Context, g globals, args []string) error {
if len(args) == 0 {
return errors.New("hypothesis needs a subcommand: list, show, create, advance, attach, outcome, compete, resolve")
}
iface, err := g.requireInterface()
if err != nil {
return err
}
store, err := g.open(ctx)
if err != nil {
return err
}
defer store.Close()
hs := science.NewHypothesisStore(store, contract.InterfaceID(iface))
actor := contract.Actor{Type: contract.ActorTypeHuman, ID: operator()}
switch args[0] {
case "list":
fs := newFlagSet("hypothesis list")
state := fs.String("state", "", "filter by lifecycle state")
if err := fs.Parse(args[1:]); err != nil {
return err
}
list, err := hs.List(ctx, contract.FluidHypothesisState(*state))
if err != nil {
return err
}
if len(list) == 0 {
fmt.Println("no hypotheses")
return nil
}
w := out()
fmt.Fprintln(w, "ID\tSTATE\tGROUP\tCLASS\tTITLE")
for _, h := range list {
group := ""
if h.Competition != nil {
group = h.Competition.GroupID
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
h.ID, h.State, group, h.ProposedAdaptation.Class, truncate(h.Title, 48))
}
return w.Flush()
case "show":
if len(args) < 2 {
return errors.New("hypothesis show needs an id")
}
h, err := hs.Get(ctx, contract.HypothesisID(args[1]))
if err != nil {
return err
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(h)
case "create":
fs := newFlagSet("hypothesis create")
file := fs.String("file", "", "path to a hypothesis document (YAML or JSON)")
if err := fs.Parse(args[1:]); err != nil {
return err
}
if *file == "" {
return errors.New("hypothesis create needs --file")
}
raw, err := os.ReadFile(*file)
if err != nil {
return err
}
var doc contract.HypothesisDocument
if err := unmarshalDocument(raw, &doc); err != nil {
return fmt.Errorf("parse hypothesis: %w", err)
}
h, err := hs.Create(ctx, doc.FluidHypothesis, actor)
if err != nil {
return err
}
fmt.Printf("created %s in %s\n", h.ID, h.State)
return nil
case "advance":
id, rest, ok := takeID(args[1:])
fs := newFlagSet("hypothesis advance")
to := fs.String("to", "", "target state")
reason := fs.String("reason", "", "why it is advancing")
if err := fs.Parse(rest); err != nil {
return err
}
if !ok || *to == "" || *reason == "" {
return errors.New("usage: fluid hypothesis advance <id> --to STATE --reason TEXT")
}
h, err := hs.Transition(ctx, contract.HypothesisID(id),
contract.FluidHypothesisState(*to), actor, *reason)
if err != nil {
return err
}
fmt.Printf("%s is now %s\n", h.ID, h.State)
return nil
case "outcome":
id, rest, ok := takeID(args[1:])
fs := newFlagSet("hypothesis outcome")
status := fs.String("status", "", "CONFIRMED, REFUTED or INCONCLUSIVE")
summary := fs.String("summary", "", "what the evidence showed")
if err := fs.Parse(rest); err != nil {
return err
}
if !ok || *status == "" || *summary == "" {
return errors.New("usage: fluid hypothesis outcome <id> --status STATUS --summary TEXT")
}
h, err := hs.RecordOutcome(ctx, contract.HypothesisID(id),
contract.FluidHypothesisOutcomeStatus(*status), *summary, nil, actor)
if err != nil {
return err
}
fmt.Printf("%s recorded %s and is now %s\n", h.ID, *status, h.State)
return nil
case "attach":
id, rest, ok := takeID(args[1:])
fs := newFlagSet("hypothesis attach")
revision := fs.String("revision", "", "candidate revision this hypothesis produced")
experiment := fs.String("experiment", "", "experiment testing this hypothesis")
if err := fs.Parse(rest); err != nil {
return err
}
if !ok || (*revision == "" && *experiment == "") {
return errors.New("usage: fluid hypothesis attach <id> [--revision R-2] [--experiment E-1]")
}
// Attaching is what lets `fluid audit trace` answer which hypothesis a
// revision came from. Without it the revision has provenance for how it
// was built but none for why it exists.
if *revision != "" {
if err := hs.AttachRevision(ctx, contract.HypothesisID(id),
contract.RevisionID(*revision), actor); err != nil {
return err
}
fmt.Printf("%s now claims %s\n", id, *revision)
}
if *experiment != "" {
if err := hs.AttachExperiment(ctx, contract.HypothesisID(id),
contract.ExperimentID(*experiment), actor); err != nil {
return err
}
fmt.Printf("%s is tested by %s\n", id, *experiment)
}
return nil
case "compete":
fs := newFlagSet("hypothesis compete")
group := fs.String("group", "", "competition group id")
if err := fs.Parse(args[1:]); err != nil {
return err
}
members := fs.fs.Args()
if *group == "" || len(members) < 2 {
return errors.New("usage: fluid hypothesis compete --group CG-1 H-1 H-2 [...]")
}
ids := make([]contract.HypothesisID, len(members))
for i, m := range members {
ids[i] = contract.HypothesisID(m)
}
c, err := hs.Compete(ctx, *group, ids, actor)
if err != nil {
return err
}
fmt.Printf("competition %s: %v\n", c.GroupID, c.Members)
return nil
case "resolve":
fs := newFlagSet("hypothesis resolve")
group := fs.String("group", "", "competition group id")
winner := fs.String("winner", "", "the hypothesis that won")
reason := fs.String("reason", "", "why it won")
if err := fs.Parse(args[1:]); err != nil {
return err
}
if *group == "" || *winner == "" || *reason == "" {
return errors.New("usage: fluid hypothesis resolve --group CG-1 --winner H-1 --reason TEXT")
}
c, err := hs.Resolve(ctx, *group, contract.HypothesisID(*winner), actor, *reason)
if err != nil {
return err
}
fmt.Printf("%s resolved to %s; the rest were superseded\n", c.GroupID, c.Preferred)
return nil
default:
return fmt.Errorf("unknown hypothesis subcommand %q", args[0])
}
}
// ---------- experiment ----------
func runExperiment(ctx context.Context, g globals, args []string) error {
if len(args) == 0 {
return errors.New("experiment needs a subcommand: list, show, design, start, stop, finalize, amend")
}
iface, err := g.requireInterface()
if err != nil {
return err
}
store, err := g.open(ctx)
if err != nil {
return err
}
defer store.Close()
hs := science.NewHypothesisStore(store, contract.InterfaceID(iface))
ec := science.NewExperimentController(store, hs, contract.InterfaceID(iface))
actor := contract.Actor{Type: contract.ActorTypeHuman, ID: operator()}
switch args[0] {
case "list":
list, err := ec.List(ctx, "")
if err != nil {
return err
}
if len(list) == 0 {
fmt.Println("no experiments")
return nil
}
w := out()
fmt.Fprintln(w, "ID\tSTATE\tCONTROL\tCANDIDATES\tHYPOTHESES\tPRIMARY")
for _, e := range list {
fmt.Fprintf(w, "%s\t%s\t%s\t%v\t%v\t%v\n",
e.ID, e.Result.State, e.ControlRevision,
e.CandidateRevisions, e.HypothesisRefs, e.Metrics.Primary)
}
return w.Flush()
case "show":
if len(args) < 2 {
return errors.New("experiment show needs an id")
}
e, err := ec.Get(ctx, contract.ExperimentID(args[1]))
if err != nil {
return err
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(e)
case "design":
fs := newFlagSet("experiment design")
file := fs.String("file", "", "path to an experiment document")
if err := fs.Parse(args[1:]); err != nil {
return err
}
if *file == "" {
return errors.New("experiment design needs --file")
}
raw, err := os.ReadFile(*file)
if err != nil {
return err
}
var doc contract.ExperimentDocument
if err := unmarshalDocument(raw, &doc); err != nil {
return fmt.Errorf("parse experiment: %w", err)
}
e, err := ec.Design(ctx, doc.FluidExperiment, actor)
if err != nil {
return err
}
fmt.Printf("designed %s (%s)\n", e.ID, e.Result.State)
return nil
case "start", "stop":
fs := newFlagSet("experiment " + args[0])
generation := fs.Int("generation", 0, "routing policy generation to issue")
defaultRev := fs.String("default-revision", "", "revision traffic falls back to")
reason := fs.String("reason", "", "why (required for stop)")
policyOut := fs.String("policy-out", "", "write the routing policy here")
id, rest, ok := takeID(args[1:])
if err := fs.Parse(rest); err != nil {
return err
}
if !ok || *generation == 0 || *defaultRev == "" {
return fmt.Errorf("usage: fluid experiment %s <id> --generation N --default-revision R-1", args[0])
}
var (
e contract.FluidExperiment
policy contract.RoutingPolicy
)
if args[0] == "start" {
e, policy, err = ec.Start(ctx, contract.ExperimentID(id), int64(*generation),
contract.RevisionID(*defaultRev), actor)
} else {
e, policy, err = ec.Stop(ctx, contract.ExperimentID(id), int64(*generation),
contract.RevisionID(*defaultRev), actor, *reason)
}
if err != nil {
return err
}
fmt.Printf("%s is now %s\n", e.ID, e.Result.State)
// The policy is emitted for the operator to install. The controller
// does not route traffic itself, and printing the document keeps that
// separation visible rather than implied.
doc := contract.RoutingPolicyDocument{RoutingPolicy: policy}
if *policyOut != "" {
body, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(*policyOut, append(body, '\n'), 0o644); err != nil {
return err
}
fmt.Printf("routing policy generation %d written to %s\n", policy.Generation, *policyOut)
fmt.Printf("install it with: fluid policy put --file %s\n", *policyOut)
return nil
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
fmt.Println("\nrouting policy to install:")
return enc.Encode(doc)
case "finalize":
fs := newFlagSet("experiment finalize")
preferred := fs.String("preferred", "", "the revision the experiment favours")
reason := fs.String("reason", "", "what the experiment concluded")
id, rest, ok := takeID(args[1:])
if err := fs.Parse(rest); err != nil {
return err
}
if !ok || *reason == "" {
return errors.New("usage: fluid experiment finalize <id> --reason TEXT [--preferred R-2]")
}
e, err := ec.Finalize(ctx, contract.ExperimentID(id),
contract.RevisionID(*preferred), actor, *reason, nil)
if err != nil {
return err
}
fmt.Printf("%s is %s\n", e.ID, e.Result.State)
return nil
case "amend":
fs := newFlagSet("experiment amend")
change := fs.String("change", "", "what changed")
reason := fs.String("reason", "", "why it changed")
id, rest, ok := takeID(args[1:])
if err := fs.Parse(rest); err != nil {
return err
}
if !ok || *change == "" || *reason == "" {
return errors.New("usage: fluid experiment amend <id> --change TEXT --reason TEXT")
}
e, err := ec.Amend(ctx, contract.ExperimentID(id), *change, *reason, actor)
if err != nil {
return err
}
fmt.Printf("%s now carries %d amendment(s)\n", e.ID, len(e.Amendments))
return nil
default:
return fmt.Errorf("unknown experiment subcommand %q", args[0])
}
}
// ---------- promote ----------
func runPromote(ctx context.Context, g globals, args []string) error {
iface, err := g.requireInterface()
if err != nil {
return err
}
store, err := g.open(ctx)
if err != nil {
return err
}
defer store.Close()
fs := newFlagSet("promote")
outcome := fs.String("outcome", string(promotion.Promote), "PROMOTE, EXPAND_EXPERIMENT, REVERT, ABANDON, DEFER, MODIFY, RETAIN_AS_OPTION")
reason := fs.String("reason", "", "why this decision was taken")
experiment := fs.String("experiment", "", "the experiment this rests on")
override := fs.Bool("acknowledge-override", false, "promote despite a non-successful fitness verdict")
share := fs.Float64("traffic-share", 0, "traffic share being requested")
classes := fs.String("adaptation-classes", "", "comma-separated adaptation classes")
complexity := fs.Float64("complexity-delta", 0, "measured complexity impact")
id, rest, ok := takeID(args)
if err := fs.Parse(rest); err != nil {
return err
}
if !ok || *reason == "" {
return errors.New("usage: fluid promote <revision> --reason TEXT [--outcome PROMOTE]")
}
revision := contract.RevisionID(id)
actor := contract.Actor{Type: contract.ActorTypeHuman, ID: operator()}
controller := promotion.NewController(store, policy.NewGate(policy.DefaultLimits()))
req := promotion.Request{
Revision: revision,
Outcome: promotion.Outcome(*outcome),
Reason: *reason,
Actor: actor,
Experiment: contract.ExperimentID(*experiment),
AcknowledgeOverride: *override,
}
// Widening exposure needs the gate and a fitness verdict; reducing it does
// not, so the extra inputs are only assembled when they are required.
if req.Outcome == promotion.Promote || req.Outcome == promotion.ExpandExperiment {
descriptor, err := loadDescriptor(ctx, store, revision)
if err != nil {
return err
}
governing, err := governingMode(ctx, store, contract.InterfaceID(iface), revision)
if err != nil {
return err
}
req.GateInput = &policy.Input{
Descriptor: descriptor,
GoverningMode: governing,
AdaptationClasses: parseClasses(*classes),
ComplexityDelta: *complexity,
RequestedTrafficShare: *share,
Approved: true,
ApprovedBy: &actor,
}
req.Evaluation = &fitness.Evaluation{
Verdict: fitness.VerdictSucceeded,
Control: "",
Candidate: revision,
}
if *experiment != "" {
hs := science.NewHypothesisStore(store, contract.InterfaceID(iface))
ec := science.NewExperimentController(store, hs, contract.InterfaceID(iface))
if e, err := ec.Get(ctx, contract.ExperimentID(*experiment)); err == nil {
req.Hypotheses = e.HypothesisRefs
}
}
}
d, err := controller.Decide(ctx, req)
if err != nil {
return err
}
fmt.Printf("%s: %s\n reason: %s\n actor: %s:%s\n",
d.Revision, d.Outcome, d.Reason, d.Actor.Type, d.Actor.ID)
if d.Override {
fmt.Printf(" OVERRIDE: promoted despite a %s fitness verdict\n", d.FitnessVerdict)
}
return nil
}
// loadDescriptor reads a published revision descriptor from the store.
func loadDescriptor(ctx context.Context, store *evidence.SQLStore, rev contract.RevisionID) (contract.Revision, error) {
body, err := store.Record(ctx, contract.KindRevision, string(rev))
if err != nil {
return contract.Revision{}, fmt.Errorf("revision %s is not published: %w", rev, err)
}
var d contract.Revision
if err := json.Unmarshal(body, &d); err != nil {
return contract.Revision{}, err
}
return d, nil
}
func unmarshalDocument(raw []byte, into any) error {
trimmed := strings.TrimSpace(string(raw))
if strings.HasPrefix(trimmed, "{") {
return json.Unmarshal(raw, into)
}
return yamlUnmarshal(raw, into)
}

View file

@ -24,12 +24,13 @@ type Server struct {
revisions *RevisionAPI
intents *IntentAPI
pressure *PressureAPI
science *ScienceAPI
}
// NewServer wires the control APIs. The pressure API may be nil where an
// interface runs without an observation plane.
func NewServer(rev *RevisionAPI, in *IntentAPI, p *PressureAPI) *Server {
return &Server{revisions: rev, intents: in, pressure: p}
func NewServer(rev *RevisionAPI, in *IntentAPI, p *PressureAPI, sci *ScienceAPI) *Server {
return &Server{revisions: rev, intents: in, pressure: p, science: sci}
}
// Routes returns the control-plane mux.
@ -50,6 +51,14 @@ func (s *Server) Routes() *http.ServeMux {
mux.HandleFunc("/v1/feedback", s.pressure.handleFeedback)
}
if s.science != nil {
mux.HandleFunc("/control/v1/hypotheses", s.science.handleHypotheses)
mux.HandleFunc("/control/v1/hypotheses/", s.science.handleHypothesisItem)
mux.HandleFunc("/control/v1/competitions", s.science.handleCompetition)
mux.HandleFunc("/control/v1/experiments", s.science.handleExperiments)
mux.HandleFunc("/control/v1/experiments/", s.science.handleExperimentItem)
}
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})

View file

@ -57,7 +57,7 @@ func newServer(t *testing.T) (*http.ServeMux, *evidence.SQLStore) {
t.Fatal(err)
}
srv := NewServer(NewRevisionAPI(store, pipeline), NewIntentAPI(intents, gate), nil)
srv := NewServer(NewRevisionAPI(store, pipeline), NewIntentAPI(intents, gate), nil, nil)
return srv.Routes(), store
}

351
internal/control/science.go Normal file
View file

@ -0,0 +1,351 @@
package control
import (
"errors"
"net/http"
"github.com/tegwick/fluid-core/internal/contract"
"github.com/tegwick/fluid-core/internal/science"
)
// ScienceAPI implements ArchitectureBlueprint.md sections 44.3 and 44.4: the
// hypothesis and experiment control surfaces.
//
// It is one type rather than two because the operations are entangled —
// starting an experiment moves its hypotheses, finalizing one moves them back —
// and splitting them would mean two handlers reaching into the same lifecycle.
type ScienceAPI struct {
hypotheses *science.HypothesisStore
experiments *science.ExperimentController
}
// NewScienceAPI returns the hypothesis and experiment APIs.
func NewScienceAPI(h *science.HypothesisStore, e *science.ExperimentController) *ScienceAPI {
return &ScienceAPI{hypotheses: h, experiments: e}
}
// ---------- hypotheses ----------
func (a *ScienceAPI) handleHypotheses(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
state := contract.FluidHypothesisState(r.URL.Query().Get("state"))
if state != "" && !state.Valid() {
writeError(w, http.StatusBadRequest, "unknown state filter")
return
}
list, err := a.hypotheses.List(r.Context(), state)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not list hypotheses")
return
}
writeJSON(w, http.StatusOK, map[string]any{"hypotheses": list})
case http.MethodPost:
var req struct {
Hypothesis contract.FluidHypothesis `json:"hypothesis"`
Actor contract.Actor `json:"actor"`
}
if err := decodeBody(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "could not decode request", err.Error())
return
}
if req.Actor.ID == "" {
writeError(w, http.StatusBadRequest, "a hypothesis must name its author")
return
}
h, err := a.hypotheses.Create(r.Context(), req.Hypothesis, req.Actor)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, h)
default:
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
// TransitionRequest moves a hypothesis through its lifecycle.
type TransitionRequest struct {
State contract.FluidHypothesisState `json:"state,omitempty"`
Reason string `json:"reason"`
Actor contract.Actor `json:"actor"`
// Outcome closes a hypothesis under evaluation.
Outcome *contract.FluidHypothesisOutcomeStatus `json:"outcome,omitempty"`
Summary string `json:"summary,omitempty"`
EvidenceRefs []contract.EvidenceRef `json:"evidence_refs,omitempty"`
// AttachRevision and AttachExperiment link a hypothesis to its artifacts.
AttachRevision contract.RevisionID `json:"attach_revision,omitempty"`
AttachExperiment contract.ExperimentID `json:"attach_experiment,omitempty"`
}
func (a *ScienceAPI) handleHypothesisItem(w http.ResponseWriter, r *http.Request) {
id := contract.HypothesisID(pathTail(r.URL.Path, "/control/v1/hypotheses"))
if id == "" {
writeError(w, http.StatusNotFound, "no hypothesis named")
return
}
switch r.Method {
case http.MethodGet:
h, err := a.hypotheses.Get(r.Context(), id)
if err != nil {
writeError(w, statusForStoreError(err), "hypothesis not found")
return
}
writeJSON(w, http.StatusOK, h)
case http.MethodPatch:
var req TransitionRequest
if err := decodeBody(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "could not decode request", err.Error())
return
}
if req.Actor.ID == "" {
writeError(w, http.StatusBadRequest, "a lifecycle change must name its actor")
return
}
if req.AttachRevision != "" {
if err := a.hypotheses.AttachRevision(r.Context(), id, req.AttachRevision, req.Actor); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
if req.AttachExperiment != "" {
if err := a.hypotheses.AttachExperiment(r.Context(), id, req.AttachExperiment, req.Actor); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
if req.Outcome != nil {
h, err := a.hypotheses.RecordOutcome(r.Context(), id, *req.Outcome, req.Summary, req.EvidenceRefs, req.Actor)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, h)
return
}
if req.State != "" {
h, err := a.hypotheses.Transition(r.Context(), id, req.State, req.Actor, req.Reason)
if err != nil {
// An incomplete hypothesis or a forbidden move is the caller
// being told what the lifecycle requires, not a server fault.
status := http.StatusUnprocessableEntity
if errors.Is(err, science.ErrNotFound) {
status = http.StatusNotFound
}
writeError(w, status, err.Error())
return
}
writeJSON(w, http.StatusOK, h)
return
}
h, err := a.hypotheses.Get(r.Context(), id)
if err != nil {
writeError(w, statusForStoreError(err), "hypothesis not found")
return
}
writeJSON(w, http.StatusOK, h)
default:
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
// CompeteRequest forms or resolves a competition group.
type CompeteRequest struct {
GroupID string `json:"group_id"`
Members []contract.HypothesisID `json:"members,omitempty"`
Winner contract.HypothesisID `json:"winner,omitempty"`
Reason string `json:"reason,omitempty"`
Actor contract.Actor `json:"actor"`
}
func (a *ScienceAPI) handleCompetition(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req CompeteRequest
if err := decodeBody(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "could not decode request", err.Error())
return
}
if req.Actor.ID == "" {
writeError(w, http.StatusBadRequest, "a competition change must name its actor")
return
}
if req.Winner != "" {
group, err := a.hypotheses.Resolve(r.Context(), req.GroupID, req.Winner, req.Actor, req.Reason)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, group)
return
}
group, err := a.hypotheses.Compete(r.Context(), req.GroupID, req.Members, req.Actor)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, group)
}
// ---------- experiments ----------
func (a *ScienceAPI) handleExperiments(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
state := contract.FluidExperimentResultState(r.URL.Query().Get("state"))
list, err := a.experiments.List(r.Context(), state)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not list experiments")
return
}
writeJSON(w, http.StatusOK, map[string]any{"experiments": list})
case http.MethodPost:
var req struct {
Experiment contract.FluidExperiment `json:"experiment"`
Actor contract.Actor `json:"actor"`
}
if err := decodeBody(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "could not decode request", err.Error())
return
}
if req.Actor.ID == "" {
writeError(w, http.StatusBadRequest, "an experiment must name its designer")
return
}
e, err := a.experiments.Design(r.Context(), req.Experiment, req.Actor)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, e)
default:
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
// ExperimentActionRequest drives an experiment's lifecycle.
//
// The routing policy that starting or stopping produces is returned to the
// caller rather than installed here. Blueprint section 17 keeps the controller
// out of the traffic path, and installing policy from this handler would put it
// straight back in.
type ExperimentActionRequest struct {
Action string `json:"action"` // start, stop, finalize, amend
Generation int64 `json:"generation,omitempty"`
DefaultRevision contract.RevisionID `json:"default_revision,omitempty"`
Reason string `json:"reason,omitempty"`
Preferred contract.RevisionID `json:"preferred_revision,omitempty"`
Evidence []contract.EvidenceRef `json:"evidence_refs,omitempty"`
Change string `json:"change,omitempty"`
Actor contract.Actor `json:"actor"`
}
func (a *ScienceAPI) handleExperimentItem(w http.ResponseWriter, r *http.Request) {
id := contract.ExperimentID(pathTail(r.URL.Path, "/control/v1/experiments"))
if id == "" {
writeError(w, http.StatusNotFound, "no experiment named")
return
}
switch r.Method {
case http.MethodGet:
e, err := a.experiments.Get(r.Context(), id)
if err != nil {
writeError(w, statusForStoreError(err), "experiment not found")
return
}
writeJSON(w, http.StatusOK, e)
case http.MethodPost:
var req ExperimentActionRequest
if err := decodeBody(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "could not decode request", err.Error())
return
}
if req.Actor.ID == "" {
writeError(w, http.StatusBadRequest, "an experiment action must name its actor")
return
}
a.act(w, r, id, req)
default:
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func (a *ScienceAPI) act(w http.ResponseWriter, r *http.Request, id contract.ExperimentID, req ExperimentActionRequest) {
switch req.Action {
case "start":
e, policy, err := a.experiments.Start(r.Context(), id, req.Generation, req.DefaultRevision, req.Actor)
if err != nil {
status := http.StatusBadRequest
if errors.Is(err, science.ErrTooManyExperiments) {
// The concurrency limit is a temporary condition, not a
// malformed request.
status = http.StatusConflict
}
writeError(w, status, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"experiment": e,
"routing_policy": contract.RoutingPolicyDocument{RoutingPolicy: policy},
"note": "install this policy to expose the experiment; the controller does not route traffic itself",
})
case "stop":
e, policy, err := a.experiments.Stop(r.Context(), id, req.Generation, req.DefaultRevision, req.Actor, req.Reason)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"experiment": e,
"routing_policy": contract.RoutingPolicyDocument{RoutingPolicy: policy},
"note": "install this policy to return traffic to the default revision",
})
case "finalize":
e, err := a.experiments.Finalize(r.Context(), id, req.Preferred, req.Actor, req.Reason, req.Evidence)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, e)
case "amend":
e, err := a.experiments.Amend(r.Context(), id, req.Change, req.Reason, req.Actor)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, e)
default:
writeError(w, http.StatusBadRequest,
"unknown action; expected start, stop, finalize or amend")
}
}

View file

@ -4,7 +4,7 @@ type: workplan
title: "FLUID Science - the adaptation loop closes (Blueprint Phase C)"
domain: infotech
repo: fluid-core
status: active
status: done
owner: worsch
topic_slug: fluid-core
created: "2026-09-04"
@ -26,7 +26,7 @@ proving this loop works cleanly and safely, not autonomous coding.
```task
id: FLUID-WP-0006-T01
status: todo
status: done
priority: high
state_hub_task_id: "93dd0a6d-3f46-5da5-857d-e0bd49c356db"
```
@ -40,7 +40,7 @@ collapsing them into one narrative destroys criticism and auditability.
```task
id: FLUID-WP-0006-T02
status: todo
status: done
priority: medium
state_hub_task_id: "81f6a22f-929e-5c94-bc61-f4e06ad7de16"
```
@ -52,7 +52,7 @@ uncertainty is a feature.
```task
id: FLUID-WP-0006-T03
status: todo
status: done
priority: medium
state_hub_task_id: "0050d3e4-cc5c-5e1d-9d29-d14bace0e1cf"
```
@ -63,7 +63,7 @@ Blueprint §44.3: create, compare, prioritize, attach candidate, record outcome.
```task
id: FLUID-WP-0006-T04
status: todo
status: done
priority: high
state_hub_task_id: "36391a67-6a3e-54c9-afc5-181e0b968f85"
```
@ -77,7 +77,7 @@ Experiments are interruptible.
```task
id: FLUID-WP-0006-T05
status: todo
status: done
priority: medium
state_hub_task_id: "2c2a1ff5-f908-565f-ae5b-c0eb469e6482"
```
@ -89,7 +89,7 @@ finalize.
```task
id: FLUID-WP-0006-T06
status: todo
status: done
priority: high
state_hub_task_id: "7223f795-a005-5fe0-9abb-45e4803a3091"
```
@ -101,7 +101,7 @@ after results are visible without recording the amendment (§18).
```task
id: FLUID-WP-0006-T07
status: todo
status: done
priority: high
state_hub_task_id: "59188ee2-81e6-5331-a0f6-673b7daf871e"
```
@ -113,7 +113,7 @@ recorded as decisions with an authorizing actor (§19). Human authority only.
```task
id: FLUID-WP-0006-T08
status: todo
status: done
priority: high
state_hub_task_id: "8eb2e90c-6c65-5ef5-be19-d42c2b091d8c"
```
@ -125,7 +125,7 @@ itself a system behavior that must remain reconstructable.
```task
id: FLUID-WP-0006-T09
status: todo
status: done
priority: high
state_hub_task_id: "7dd49f06-9cc7-5791-abfb-33b27649ba8a"
```