Add science control APIs and the hypothesis, experiment and promote CLI
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:
tegwick 2026-09-04 06:44:47 +02:00
parent 634807a0cb
commit 3407b9cf85
9 changed files with 1006 additions and 29 deletions

View file

@ -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,
}

View file

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

View file

@ -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:

View file

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