diff --git a/cmd/fluid-control/main.go b/cmd/fluid-control/main.go index a90fed7..f76dc3d 100644 --- a/cmd/fluid-control/main.go +++ b/cmd/fluid-control/main.go @@ -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, } diff --git a/cmd/fluid/commands.go b/cmd/fluid/commands.go index 41b64b5..793577f 100644 --- a/cmd/fluid/commands.go +++ b/cmd/fluid/commands.go @@ -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 ") @@ -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 +} diff --git a/cmd/fluid/insight.go b/cmd/fluid/insight.go index a57d2b8..be2fed5 100644 --- a/cmd/fluid/insight.go +++ b/cmd/fluid/insight.go @@ -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: diff --git a/cmd/fluid/main.go b/cmd/fluid/main.go index 6da0158..d1e1038 100644 --- a/cmd/fluid/main.go +++ b/cmd/fluid/main.go @@ -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: diff --git a/cmd/fluid/science.go b/cmd/fluid/science.go new file mode 100644 index 0000000..7b17eae --- /dev/null +++ b/cmd/fluid/science.go @@ -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 --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) +} diff --git a/internal/control/api.go b/internal/control/api.go index 8b4234b..9c9ed8a 100644 --- a/internal/control/api.go +++ b/internal/control/api.go @@ -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"}) }) diff --git a/internal/control/api_test.go b/internal/control/api_test.go index 3073c99..fab245e 100644 --- a/internal/control/api_test.go +++ b/internal/control/api_test.go @@ -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 } diff --git a/internal/control/science.go b/internal/control/science.go new file mode 100644 index 0000000..21aca1f --- /dev/null +++ b/internal/control/science.go @@ -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") + } +} diff --git a/workplans/FLUID-WP-0006-fluid-science.md b/workplans/FLUID-WP-0006-fluid-science.md index 6848802..5a1ca14 100644 --- a/workplans/FLUID-WP-0006-fluid-science.md +++ b/workplans/FLUID-WP-0006-fluid-science.md @@ -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" ```