fluid-core/cmd/fluid/science.go
tegwick 3407b9cf85
Some checks failed
ci / build (push) Has been cancelled
Add science control APIs and the hypothesis, experiment and promote CLI
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
2026-09-04 06:44:47 +02:00

518 lines
15 KiB
Go

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