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
518 lines
15 KiB
Go
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)
|
|
}
|