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] + "…" }