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
385 lines
11 KiB
Go
385 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"time"
|
|
|
|
"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/observation"
|
|
)
|
|
|
|
// ---------- pressure ----------
|
|
|
|
func runPressure(ctx context.Context, g globals, args []string) error {
|
|
if len(args) == 0 {
|
|
return errors.New("pressure needs a subcommand: list, show, analyze, dismiss")
|
|
}
|
|
iface, err := g.requireInterface()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
store, err := g.open(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer store.Close()
|
|
|
|
reg := observation.NewPressureRegistry(store, contract.InterfaceID(iface))
|
|
|
|
switch args[0] {
|
|
case "list":
|
|
fs := newFlagSet("pressure list")
|
|
status := fs.String("status", "", "filter by status, such as OPEN or DISMISSED")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return err
|
|
}
|
|
|
|
list, err := reg.List(ctx, contract.FluidPressureStatus(*status))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(list) == 0 {
|
|
fmt.Println("no pressure recorded")
|
|
return nil
|
|
}
|
|
|
|
w := out()
|
|
fmt.Fprintln(w, "ID\tCLASS\tSEV\tCONF\tCONSUMERS\tSTATUS\tSUMMARY")
|
|
for _, p := range list {
|
|
consumers := int64(0)
|
|
if p.Frequency != nil && p.Frequency.IndependentConsumers != nil {
|
|
consumers = *p.Frequency.IndependentConsumers
|
|
}
|
|
fmt.Fprintf(w, "%s\t%s\t%.2f\t%.2f\t%d\t%s\t%s\n",
|
|
p.ID, p.Class, unitOf(p.Severity), unitOf(p.Confidence),
|
|
consumers, p.Status, truncate(oneLine(p.Summary), 60))
|
|
}
|
|
return w.Flush()
|
|
|
|
case "show":
|
|
if len(args) < 2 {
|
|
return errors.New("pressure show needs an id")
|
|
}
|
|
p, err := reg.Get(ctx, contract.PressureID(args[1]))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(p)
|
|
|
|
case "analyze":
|
|
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(rest); err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return errors.New("pressure dismiss needs an id")
|
|
}
|
|
if *reason == "" {
|
|
return errors.New("dismissal requires --reason; an unexplained dismissal is not auditable")
|
|
}
|
|
|
|
actor := contract.Actor{Type: contract.ActorTypeHuman, ID: operator()}
|
|
if err := reg.SetStatus(ctx, contract.PressureID(id),
|
|
contract.FluidPressureStatusDISMISSED, actor, *reason); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("dismissed %s\n", id)
|
|
return nil
|
|
|
|
default:
|
|
return fmt.Errorf("unknown pressure subcommand %q", args[0])
|
|
}
|
|
}
|
|
|
|
// analyzePressure runs the classifier over recorded telemetry.
|
|
//
|
|
// Analysis is an explicit command rather than something that happens on ingest.
|
|
// Blueprint section 30 wants adaptive work to be budgeted and deferrable, and a
|
|
// classifier that ran on every event would be neither.
|
|
func analyzePressure(ctx context.Context, g globals, store *evidence.SQLStore, reg *observation.PressureRegistry, args []string) error {
|
|
fs := newFlagSet("pressure analyze")
|
|
since := fs.String("since", "168h", "how far back to analyze, as a Go duration")
|
|
dryRun := fs.Bool("dry-run", false, "report findings without recording them")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
|
|
window, err := time.ParseDuration(*since)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid --since: %w", err)
|
|
}
|
|
|
|
events, err := store.Telemetry(ctx, evidence.TelemetryFilter{
|
|
InterfaceID: contract.InterfaceID(g.iface),
|
|
Since: time.Now().Add(-window),
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(events) == 0 {
|
|
fmt.Println("no telemetry in the window; nothing to analyze")
|
|
return nil
|
|
}
|
|
|
|
classifier := observation.NewClassifier(
|
|
observation.DefaultClassifierOptions(), observation.NewTopologyAnalyzer())
|
|
findings := classifier.Classify(events)
|
|
|
|
if len(findings) == 0 {
|
|
fmt.Printf("analyzed %d events; no material pressure found\n", len(events))
|
|
return nil
|
|
}
|
|
|
|
w := out()
|
|
fmt.Fprintln(w, "CLASS\tSEV\tCONF\tCONSUMERS\tSUMMARY")
|
|
for _, f := range findings {
|
|
fmt.Fprintf(w, "%s\t%.2f\t%.2f\t%d\t%s\n",
|
|
f.Class, f.Severity, f.Confidence, f.Consumers, truncate(oneLine(f.Summary), 70))
|
|
}
|
|
if err := w.Flush(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if *dryRun {
|
|
fmt.Printf("\n%d finding(s) from %d events; not recorded (--dry-run)\n", len(findings), len(events))
|
|
return nil
|
|
}
|
|
|
|
recorded, err := reg.RecordAll(ctx, findings)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("\nrecorded %d pressure record(s) from %d events\n", len(recorded), len(events))
|
|
return nil
|
|
}
|
|
|
|
// ---------- cohorts ----------
|
|
|
|
func runCohort(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("cohort list")
|
|
since := fs.String("since", "168h", "how far back to summarize")
|
|
saltFile := fs.String("salt-file", os.Getenv("FLUID_REDACTION_SALT"), "pseudonymization salt file")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
|
|
window, err := time.ParseDuration(*since)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid --since: %w", err)
|
|
}
|
|
|
|
policy, err := loadPolicy(*saltFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
events, err := store.Telemetry(ctx, evidence.TelemetryFilter{
|
|
InterfaceID: contract.InterfaceID(iface),
|
|
Since: time.Now().Add(-window),
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
pops := observation.NewCohortEngine("unclassified", policy).Populations(events)
|
|
if len(pops) == 0 {
|
|
fmt.Println("no cohort activity in the window")
|
|
return nil
|
|
}
|
|
|
|
w := out()
|
|
fmt.Fprintln(w, "COHORT\tCONSUMERS\tEVENTS")
|
|
for _, p := range pops {
|
|
consumers := fmt.Sprint(p.Consumers)
|
|
if p.Suppressed {
|
|
// Reporting the exact count of a tiny cohort identifies individuals.
|
|
consumers = fmt.Sprintf("<%d (suppressed)", policy.CohortMinimumSize)
|
|
}
|
|
fmt.Fprintf(w, "%s\t%s\t%d\n", p.Cohort, consumers, p.Events)
|
|
}
|
|
return w.Flush()
|
|
}
|
|
|
|
// ---------- fitness ----------
|
|
|
|
func runFitness(ctx context.Context, g globals, args []string) error {
|
|
if len(args) == 0 || args[0] != "compare" {
|
|
return errors.New("usage: fluid fitness compare --control R-1 --candidate R-2")
|
|
}
|
|
|
|
iface, err := g.requireInterface()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
store, err := g.open(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer store.Close()
|
|
|
|
fs := newFlagSet("fitness compare")
|
|
control := fs.String("control", "", "control revision")
|
|
candidate := fs.String("candidate", "", "candidate revision")
|
|
since := fs.String("since", "168h", "measurement window")
|
|
target := fs.Float64("target-requests-per-task", 0, "primary target for requests per completed task")
|
|
latencyGuard := fs.Float64("guard-p95-latency-ms", 0, "p95 latency guardrail")
|
|
errorGuard := fs.Float64("guard-error-rate", 0, "error rate guardrail")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return err
|
|
}
|
|
if *control == "" || *candidate == "" {
|
|
return errors.New("fitness compare needs --control and --candidate")
|
|
}
|
|
|
|
window, err := time.ParseDuration(*since)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid --since: %w", err)
|
|
}
|
|
start := time.Now().Add(-window)
|
|
|
|
events, err := store.Telemetry(ctx, evidence.TelemetryFilter{
|
|
InterfaceID: contract.InterfaceID(iface),
|
|
Since: start,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
measureWindow := fitness.Window{Start: start}
|
|
observations := fitness.NewMeasurer().Measure(events, measureWindow)
|
|
|
|
// Specs are declared here rather than inferred from the data, so that the
|
|
// criteria a comparison was judged against are visible in the command that
|
|
// ran it.
|
|
var specs []fitness.MetricSpec
|
|
if *target > 0 {
|
|
specs = append(specs, fitness.MetricSpec{
|
|
Name: fitness.MetricRequestsPerTask, Role: fitness.RolePrimary,
|
|
Direction: fitness.Lower, Target: target,
|
|
})
|
|
}
|
|
if *latencyGuard > 0 {
|
|
specs = append(specs, fitness.MetricSpec{
|
|
Name: fitness.MetricP95LatencyMS, Role: fitness.RoleGuardrail,
|
|
Direction: fitness.Lower, Threshold: latencyGuard,
|
|
})
|
|
}
|
|
if *errorGuard > 0 {
|
|
specs = append(specs, fitness.MetricSpec{
|
|
Name: fitness.MetricErrorRate, Role: fitness.RoleGuardrail,
|
|
Direction: fitness.Lower, Threshold: errorGuard,
|
|
})
|
|
}
|
|
if len(specs) == 0 {
|
|
return errors.New("no criteria given: pass at least --target-requests-per-task")
|
|
}
|
|
|
|
eval := fitness.NewEvaluator().Evaluate(
|
|
contract.RevisionID(*control), contract.RevisionID(*candidate),
|
|
measureWindow, specs, observations)
|
|
|
|
fmt.Printf("Fitness: %s vs %s\nWindow: since %s\nVerdict: %s\n\n",
|
|
eval.Control, eval.Candidate, start.Format(time.RFC3339), eval.Verdict)
|
|
|
|
w := out()
|
|
fmt.Fprintln(w, "METRIC\tROLE\tBASELINE\tCURRENT\tDELTA\tOUTCOME")
|
|
for _, m := range eval.Metrics {
|
|
outcome := ""
|
|
switch {
|
|
case m.Underpowered:
|
|
outcome = "underpowered"
|
|
case m.TargetMet != nil && *m.TargetMet:
|
|
outcome = "target met"
|
|
case m.TargetMet != nil:
|
|
outcome = "target missed"
|
|
case m.GuardrailBreached != nil && *m.GuardrailBreached:
|
|
outcome = "BREACHED"
|
|
case m.GuardrailBreached != nil:
|
|
outcome = "within guardrail"
|
|
}
|
|
fmt.Fprintf(w, "%s\t%s\t%.4g\t%.4g\t%+.4g\t%s\n",
|
|
m.Name, m.Role, m.Baseline, m.Current, m.Delta, outcome)
|
|
}
|
|
if err := w.Flush(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(eval.Reasons) > 0 {
|
|
fmt.Println()
|
|
for _, r := range eval.Reasons {
|
|
fmt.Printf(" %s\n", r)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---------- helpers ----------
|
|
|
|
// loadPolicy builds a redaction policy from a salt file.
|
|
//
|
|
// It refuses to invent a salt. A generated one would change on every
|
|
// invocation, so the same consumer would look like a new consumer each time and
|
|
// every cohort count would be wrong.
|
|
func loadPolicy(saltFile string) (observation.RedactionPolicy, error) {
|
|
if saltFile == "" {
|
|
return observation.RedactionPolicy{}, errors.New(
|
|
"no redaction salt: pass --salt-file or set FLUID_REDACTION_SALT")
|
|
}
|
|
salt, err := os.ReadFile(saltFile)
|
|
if err != nil {
|
|
return observation.RedactionPolicy{}, fmt.Errorf("read salt: %w", err)
|
|
}
|
|
policy := observation.DefaultRedactionPolicy([]byte(trimSpaceStr(string(salt))))
|
|
if err := policy.Validate(); err != nil {
|
|
return observation.RedactionPolicy{}, err
|
|
}
|
|
return policy, nil
|
|
}
|
|
|
|
func trimSpaceStr(s string) string {
|
|
start, end := 0, len(s)
|
|
for start < end && (s[start] == ' ' || s[start] == '\n' || s[start] == '\t' || s[start] == '\r') {
|
|
start++
|
|
}
|
|
for end > start && (s[end-1] == ' ' || s[end-1] == '\n' || s[end-1] == '\t' || s[end-1] == '\r') {
|
|
end--
|
|
}
|
|
return s[start:end]
|
|
}
|
|
|
|
func unitOf(v *contract.UnitInterval) float64 {
|
|
if v == nil {
|
|
return 0
|
|
}
|
|
return float64(*v)
|
|
}
|
|
|
|
func truncate(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n-1] + "…"
|
|
}
|