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