Add science control APIs and the hypothesis, experiment and promote CLI
Some checks failed
ci / build (push) Has been cancelled
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:
parent
634807a0cb
commit
3407b9cf85
9 changed files with 1006 additions and 29 deletions
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
518
cmd/fluid/science.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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"})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
351
internal/control/science.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
```
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue