Add pressure classifier and pressure registry
FLUID-WP-0005 T05-T06. The classifier is deterministic: its output is what a hypothesis will later cite as its observation, and a first implementation that reached for a model could not be audited. Two mappings are deliberate. An authorization failure is not pressure -- the interface working as designed is not a defect, and classifying it as one would send the Daimon after a change that must not happen. Backend failures and timeouts are reported at any rate, while consumer-side errors must be a meaningful share of traffic first, since a handful says more about one confused client than about the interface. Pressure ids are derived from a finding fingerprint rather than allocated, which makes ingest idempotent: re-analysing a window updates one record instead of minting another. first_seen is never overwritten, because how long the interface has had a problem is the evidence that makes an old pressure worth prioritizing. A dismissed pressure is not silently reopened, or the operator's decision would mean nothing. 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
e779d1f8b9
commit
0ac35892a5
3 changed files with 1019 additions and 0 deletions
328
internal/observation/classify.go
Normal file
328
internal/observation/classify.go
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
package observation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/tegwick/fluid-core/internal/contract"
|
||||
)
|
||||
|
||||
// Finding is a classified observation, before it becomes a pressure record.
|
||||
//
|
||||
// A finding carries its evidence and its reasoning. FluidAPIStandards.md
|
||||
// section 13 is explicit that pressure is evidence and not truth, so a finding
|
||||
// that cannot show its working is not usable: the whole point of separating
|
||||
// observation from explanation (schema doc section 18) is that a later reader
|
||||
// can disagree with the interpretation while still trusting the observation.
|
||||
type Finding struct {
|
||||
Class contract.PressureClass `json:"class"`
|
||||
Summary string `json:"summary"`
|
||||
Cohorts []contract.CohortID `json:"cohorts"`
|
||||
Occurrences int `json:"occurrences"`
|
||||
Consumers int `json:"independent_consumers"`
|
||||
Severity float64 `json:"severity"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Evidence []contract.EvidenceRef `json:"evidence_refs"`
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
// Fingerprint identifies the same finding across runs, so the registry can
|
||||
// deduplicate rather than accumulating one record per analysis pass.
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
}
|
||||
|
||||
// ClassifierOptions tunes the deterministic thresholds.
|
||||
//
|
||||
// They are explicit configuration because Blueprint section 54 lists pressure
|
||||
// detection as an open question: which signals produce the best improvement
|
||||
// candidates is meant to be learned by operating the system, not fixed now.
|
||||
type ClassifierOptions struct {
|
||||
// RepeatThreshold is how many identical calls in one chain count as
|
||||
// compensating for a missing capability rather than ordinary paging.
|
||||
RepeatThreshold int
|
||||
// MinConsumers is the independent-consumer floor for any finding.
|
||||
MinConsumers int
|
||||
// ErrorRateThreshold is the share of a route's calls that must fail before
|
||||
// implementation failure is claimed.
|
||||
ErrorRateThreshold float64
|
||||
}
|
||||
|
||||
// DefaultClassifierOptions returns workable starting thresholds.
|
||||
func DefaultClassifierOptions() ClassifierOptions {
|
||||
return ClassifierOptions{
|
||||
RepeatThreshold: 3,
|
||||
MinConsumers: 2,
|
||||
ErrorRateThreshold: 0.20,
|
||||
}
|
||||
}
|
||||
|
||||
// Classifier maps evidence onto the ten standard pressure classes.
|
||||
//
|
||||
// It is deterministic on purpose. FluidAPIStandards.md section 13 allows
|
||||
// heuristics, statistics or agentic reasoning here, but a first implementation
|
||||
// that reaches for a model cannot be audited, and the classifier's output is
|
||||
// what a hypothesis will later cite as its observation.
|
||||
type Classifier struct {
|
||||
opts ClassifierOptions
|
||||
analyzer *TopologyAnalyzer
|
||||
}
|
||||
|
||||
// NewClassifier returns a classifier.
|
||||
func NewClassifier(opts ClassifierOptions, analyzer *TopologyAnalyzer) *Classifier {
|
||||
if analyzer == nil {
|
||||
analyzer = NewTopologyAnalyzer()
|
||||
}
|
||||
return &Classifier{opts: opts, analyzer: analyzer}
|
||||
}
|
||||
|
||||
// Classify examines telemetry and returns findings.
|
||||
func (c *Classifier) Classify(events []contract.FluidTelemetry) []Finding {
|
||||
var findings []Finding
|
||||
|
||||
findings = append(findings, c.fromPatterns(events)...)
|
||||
findings = append(findings, c.fromErrors(events)...)
|
||||
|
||||
// Highest severity first, with a stable tiebreak so output is diffable.
|
||||
sort.Slice(findings, func(i, j int) bool {
|
||||
if findings[i].Severity != findings[j].Severity {
|
||||
return findings[i].Severity > findings[j].Severity
|
||||
}
|
||||
return findings[i].Fingerprint < findings[j].Fingerprint
|
||||
})
|
||||
return findings
|
||||
}
|
||||
|
||||
// fromPatterns derives findings from interaction topology.
|
||||
func (c *Classifier) fromPatterns(events []contract.FluidTelemetry) []Finding {
|
||||
var out []Finding
|
||||
|
||||
for _, p := range c.analyzer.Patterns(events) {
|
||||
if p.Consumers < c.opts.MinConsumers {
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case p.MaxRepeats >= c.opts.RepeatThreshold:
|
||||
// Independent consumers calling one route repeatedly inside a
|
||||
// single task is the classic shape of an interface that makes them
|
||||
// assemble something it could have handed them.
|
||||
out = append(out, Finding{
|
||||
Class: contract.PressureClassSuccessfulButInefficientUsage,
|
||||
Summary: fmt.Sprintf(
|
||||
"%d independent consumers call %s up to %d times within one interaction",
|
||||
p.Consumers, p.RepeatedStep, p.MaxRepeats),
|
||||
Cohorts: p.Cohorts,
|
||||
Occurrences: p.Count,
|
||||
Consumers: p.Consumers,
|
||||
Severity: severityFrom(p.Consumers, p.Count, float64(p.MaxRepeats)/10),
|
||||
Confidence: confidenceFrom(p.Consumers, p.Count),
|
||||
Evidence: []contract.EvidenceRef{contract.EvidenceRef("topology:" + p.Signature)},
|
||||
FirstSeen: p.FirstSeen,
|
||||
LastSeen: p.LastSeen,
|
||||
Fingerprint: fingerprint("inefficient", p.RepeatedStep),
|
||||
})
|
||||
|
||||
case p.RecoveredError == contract.FluidTelemetryErrorClassValidation,
|
||||
p.RecoveredError == contract.FluidTelemetryErrorClassUnknownField,
|
||||
p.RecoveredError == contract.FluidTelemetryErrorClassUnsupportedParameter:
|
||||
// Consumers who fail, correct themselves, and succeed have
|
||||
// understood the interface eventually. That is a documentation and
|
||||
// discoverability problem, not a capability gap.
|
||||
out = append(out, Finding{
|
||||
Class: contract.PressureClassRecoverableMisunderstanding,
|
||||
Summary: fmt.Sprintf(
|
||||
"%d independent consumers recover from a %s error within the same interaction",
|
||||
p.Consumers, p.RecoveredError),
|
||||
Cohorts: p.Cohorts,
|
||||
Occurrences: p.Count,
|
||||
Consumers: p.Consumers,
|
||||
Severity: severityFrom(p.Consumers, p.Count, 0.1),
|
||||
Confidence: confidenceFrom(p.Consumers, p.Count),
|
||||
Evidence: []contract.EvidenceRef{contract.EvidenceRef("topology:" + p.Signature)},
|
||||
FirstSeen: p.FirstSeen,
|
||||
LastSeen: p.LastSeen,
|
||||
Fingerprint: fingerprint("recoverable", string(p.RecoveredError)),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// routeStats accumulates per-route outcomes.
|
||||
type routeStats struct {
|
||||
total int
|
||||
errors map[contract.FluidTelemetryErrorClass]int
|
||||
consumers map[string]struct{}
|
||||
cohorts map[contract.CohortID]struct{}
|
||||
first time.Time
|
||||
last time.Time
|
||||
}
|
||||
|
||||
// fromErrors derives findings from error classes on individual routes.
|
||||
func (c *Classifier) fromErrors(events []contract.FluidTelemetry) []Finding {
|
||||
byRoute := map[string]*routeStats{}
|
||||
|
||||
for _, ev := range events {
|
||||
route := "(unknown)"
|
||||
if ev.Request != nil && ev.Request.Route != "" {
|
||||
route = ev.Request.Method + " " + ev.Request.Route
|
||||
}
|
||||
|
||||
s, ok := byRoute[route]
|
||||
if !ok {
|
||||
s = &routeStats{
|
||||
errors: map[contract.FluidTelemetryErrorClass]int{},
|
||||
consumers: map[string]struct{}{},
|
||||
cohorts: map[contract.CohortID]struct{}{},
|
||||
first: ev.OccurredAt,
|
||||
last: ev.OccurredAt,
|
||||
}
|
||||
byRoute[route] = s
|
||||
}
|
||||
|
||||
s.total++
|
||||
if ev.ConsumerRef != "" {
|
||||
s.consumers[ev.ConsumerRef] = struct{}{}
|
||||
}
|
||||
if ev.Cohort != nil {
|
||||
s.cohorts[*ev.Cohort] = struct{}{}
|
||||
}
|
||||
if ev.OccurredAt.Before(s.first) {
|
||||
s.first = ev.OccurredAt
|
||||
}
|
||||
if ev.OccurredAt.After(s.last) {
|
||||
s.last = ev.OccurredAt
|
||||
}
|
||||
if ev.Error != nil {
|
||||
s.errors[ev.Error.Class]++
|
||||
}
|
||||
}
|
||||
|
||||
routes := make([]string, 0, len(byRoute))
|
||||
for r := range byRoute {
|
||||
routes = append(routes, r)
|
||||
}
|
||||
sort.Strings(routes)
|
||||
|
||||
var out []Finding
|
||||
for _, route := range routes {
|
||||
s := byRoute[route]
|
||||
consumers := len(s.consumers)
|
||||
if consumers < c.opts.MinConsumers {
|
||||
continue
|
||||
}
|
||||
|
||||
classes := make([]contract.FluidTelemetryErrorClass, 0, len(s.errors))
|
||||
for cl := range s.errors {
|
||||
classes = append(classes, cl)
|
||||
}
|
||||
sort.Slice(classes, func(i, j int) bool { return classes[i] < classes[j] })
|
||||
|
||||
for _, class := range classes {
|
||||
count := s.errors[class]
|
||||
rate := float64(count) / float64(s.total)
|
||||
|
||||
pressure, ok := pressureForError(class)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Backend failures and timeouts are reported on any recurrence;
|
||||
// consumer-side errors need to be a meaningful share of traffic
|
||||
// before they say something about the interface rather than about
|
||||
// one confused client.
|
||||
if !alwaysReport(class) && rate < c.opts.ErrorRateThreshold {
|
||||
continue
|
||||
}
|
||||
|
||||
cohorts := make([]contract.CohortID, 0, len(s.cohorts))
|
||||
for co := range s.cohorts {
|
||||
cohorts = append(cohorts, co)
|
||||
}
|
||||
sort.Slice(cohorts, func(i, j int) bool { return cohorts[i] < cohorts[j] })
|
||||
|
||||
out = append(out, Finding{
|
||||
Class: pressure,
|
||||
Summary: fmt.Sprintf("%s returns %s for %.0f%% of calls across %d consumers",
|
||||
route, class, rate*100, consumers),
|
||||
Cohorts: cohorts,
|
||||
Occurrences: count,
|
||||
Consumers: consumers,
|
||||
Severity: severityFrom(consumers, count, rate),
|
||||
Confidence: confidenceFrom(consumers, count),
|
||||
Evidence: []contract.EvidenceRef{
|
||||
contract.EvidenceRef("route:" + route),
|
||||
contract.EvidenceRef("error:" + string(class)),
|
||||
},
|
||||
FirstSeen: s.first,
|
||||
LastSeen: s.last,
|
||||
Fingerprint: fingerprint(string(pressure), route+"/"+string(class)),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pressureForError maps a telemetry error class onto a pressure class.
|
||||
//
|
||||
// Not every error is pressure. An authorization failure usually means the
|
||||
// interface worked exactly as designed, so it is left unmapped rather than
|
||||
// dressed up as a defect.
|
||||
func pressureForError(class contract.FluidTelemetryErrorClass) (contract.PressureClass, bool) {
|
||||
switch class {
|
||||
case contract.FluidTelemetryErrorClassUnknownPath:
|
||||
return contract.PressureClassMissingInterfaceCapability, true
|
||||
case contract.FluidTelemetryErrorClassUnknownField,
|
||||
contract.FluidTelemetryErrorClassUnsupportedParameter:
|
||||
return contract.PressureClassRepeatedExpectationMismatch, true
|
||||
case contract.FluidTelemetryErrorClassValidation:
|
||||
return contract.PressureClassPoorDiscoverability, true
|
||||
case contract.FluidTelemetryErrorClassBackendFailure,
|
||||
contract.FluidTelemetryErrorClassTimeout:
|
||||
return contract.PressureClassImplementationFailure, true
|
||||
case contract.FluidTelemetryErrorClassPolicyRejection:
|
||||
return contract.PressureClassProhibitedDemand, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// alwaysReport marks classes worth surfacing at any rate.
|
||||
func alwaysReport(class contract.FluidTelemetryErrorClass) bool {
|
||||
switch class {
|
||||
case contract.FluidTelemetryErrorClassBackendFailure,
|
||||
contract.FluidTelemetryErrorClassTimeout:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// severityFrom combines breadth, volume and intensity into a bounded score.
|
||||
//
|
||||
// The weighting favours breadth: a problem five consumers hit occasionally
|
||||
// says more about the interface than one consumer hitting it constantly.
|
||||
func severityFrom(consumers, occurrences int, intensity float64) float64 {
|
||||
breadth := saturate(float64(consumers) / 10)
|
||||
volume := saturate(float64(occurrences) / 100)
|
||||
return round2(saturate(0.5*breadth + 0.2*volume + 0.3*saturate(intensity)))
|
||||
}
|
||||
|
||||
// confidenceFrom expresses how much the evidence supports any conclusion.
|
||||
func confidenceFrom(consumers, occurrences int) float64 {
|
||||
return round2(saturate(0.6*saturate(float64(consumers)/5) + 0.4*saturate(float64(occurrences)/50)))
|
||||
}
|
||||
|
||||
func saturate(v float64) float64 {
|
||||
switch {
|
||||
case v < 0:
|
||||
return 0
|
||||
case v > 1:
|
||||
return 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func round2(v float64) float64 { return float64(int(v*100+0.5)) / 100 }
|
||||
|
||||
// fingerprint identifies a finding stably across analysis runs.
|
||||
func fingerprint(kind, subject string) string {
|
||||
return kind + ":" + subject
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue