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
|
||||
}
|
||||
365
internal/observation/pressure_test.go
Normal file
365
internal/observation/pressure_test.go
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
package observation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/tegwick/fluid-core/internal/contract"
|
||||
"github.com/tegwick/fluid-core/internal/evidence"
|
||||
)
|
||||
|
||||
func newRegistry(t *testing.T) (*PressureRegistry, *evidence.SQLStore) {
|
||||
t.Helper()
|
||||
store, err := evidence.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "e.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
return NewPressureRegistry(store, "hall-publishing"), store
|
||||
}
|
||||
|
||||
// inefficientTraffic reproduces the Blueprint section 33 shape: several
|
||||
// consumers listing a collection repeatedly to find one record.
|
||||
func inefficientTraffic() []contract.FluidTelemetry {
|
||||
var events []contract.FluidTelemetry
|
||||
for _, consumer := range []string{"c-1", "c-2", "c-3", "c-4"} {
|
||||
for chain := 0; chain < 3; chain++ {
|
||||
start := time.Duration(chain) * time.Hour
|
||||
for i := 0; i < 4; i++ {
|
||||
events = append(events,
|
||||
req(consumer, start+time.Duration(i)*time.Second, "GET", "/customers/{id}/invoices", 200))
|
||||
}
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func TestClassifierFindsInefficientUsage(t *testing.T) {
|
||||
c := NewClassifier(DefaultClassifierOptions(), NewTopologyAnalyzer())
|
||||
|
||||
findings := c.Classify(inefficientTraffic())
|
||||
if len(findings) == 0 {
|
||||
t.Fatal("no findings from clearly inefficient traffic")
|
||||
}
|
||||
|
||||
var found *Finding
|
||||
for i := range findings {
|
||||
if findings[i].Class == contract.PressureClassSuccessfulButInefficientUsage {
|
||||
found = &findings[i]
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("inefficient usage not classified; got %v", classesOf(findings))
|
||||
}
|
||||
if found.Consumers != 4 {
|
||||
t.Errorf("consumers = %d, want 4", found.Consumers)
|
||||
}
|
||||
if len(found.Evidence) == 0 {
|
||||
t.Error("a finding with no evidence references is not auditable")
|
||||
}
|
||||
if found.Confidence <= 0 || found.Confidence > 1 {
|
||||
t.Errorf("confidence out of range: %v", found.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifierMapsErrorsToPressureClasses(t *testing.T) {
|
||||
c := NewClassifier(DefaultClassifierOptions(), NewTopologyAnalyzer())
|
||||
|
||||
var events []contract.FluidTelemetry
|
||||
for i, consumer := range []string{"c-1", "c-2", "c-3"} {
|
||||
for n := 0; n < 5; n++ {
|
||||
ev := errEv(consumer, time.Duration(i*10+n)*time.Minute, "/v1/latest",
|
||||
contract.FluidTelemetryErrorClassUnknownPath)
|
||||
events = append(events, ev)
|
||||
}
|
||||
}
|
||||
|
||||
findings := c.Classify(events)
|
||||
if !hasClass(findings, contract.PressureClassMissingInterfaceCapability) {
|
||||
t.Errorf("repeated unknown-path errors did not yield missing capability; got %v", classesOf(findings))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthorizationFailureIsNotPressure: the interface working as designed is
|
||||
// not a defect, and dressing it up as one would send the Daimon chasing a
|
||||
// change that must not happen.
|
||||
func TestAuthorizationFailureIsNotPressure(t *testing.T) {
|
||||
c := NewClassifier(DefaultClassifierOptions(), NewTopologyAnalyzer())
|
||||
|
||||
var events []contract.FluidTelemetry
|
||||
for i, consumer := range []string{"c-1", "c-2", "c-3"} {
|
||||
for n := 0; n < 10; n++ {
|
||||
events = append(events, errEv(consumer, time.Duration(i*10+n)*time.Minute, "/v1/admin",
|
||||
contract.FluidTelemetryErrorClassAuthorization))
|
||||
}
|
||||
}
|
||||
|
||||
for _, f := range c.Classify(events) {
|
||||
if len(f.Evidence) > 0 && f.Class == contract.PressureClassMissingInterfaceCapability {
|
||||
t.Errorf("authorization failures were classified as pressure: %+v", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifierNeedsIndependentConsumers(t *testing.T) {
|
||||
c := NewClassifier(DefaultClassifierOptions(), NewTopologyAnalyzer())
|
||||
|
||||
var events []contract.FluidTelemetry
|
||||
for n := 0; n < 50; n++ {
|
||||
events = append(events, errEv("only-one", time.Duration(n)*time.Minute, "/v1/x",
|
||||
contract.FluidTelemetryErrorClassUnknownPath))
|
||||
}
|
||||
|
||||
if findings := c.Classify(events); len(findings) != 0 {
|
||||
t.Errorf("one consumer's behaviour became interface pressure: %v", classesOf(findings))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordIsIdempotent is what stops re-analysing a window from minting a new
|
||||
// pressure record on every pass.
|
||||
func TestRecordIsIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
reg, _ := newRegistry(t)
|
||||
c := NewClassifier(DefaultClassifierOptions(), NewTopologyAnalyzer())
|
||||
|
||||
findings := c.Classify(inefficientTraffic())
|
||||
if len(findings) == 0 {
|
||||
t.Fatal("no findings")
|
||||
}
|
||||
|
||||
first, err := reg.RecordAll(ctx, findings)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := reg.RecordAll(ctx, findings)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(first) != len(second) {
|
||||
t.Fatalf("record counts differ between runs: %d then %d", len(first), len(second))
|
||||
}
|
||||
for i := range first {
|
||||
if first[i].ID != second[i].ID {
|
||||
t.Errorf("re-recording minted a new id: %s then %s", first[i].ID, second[i].ID)
|
||||
}
|
||||
}
|
||||
|
||||
all, err := reg.List(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(all) != len(first) {
|
||||
t.Errorf("registry holds %d records after two identical runs, want %d", len(all), len(first))
|
||||
}
|
||||
}
|
||||
|
||||
// TestFirstSeenIsNotOverwritten: how long the interface has had a problem is
|
||||
// evidence, and it is what makes an old pressure worth prioritizing.
|
||||
func TestFirstSeenIsNotOverwritten(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
reg, _ := newRegistry(t)
|
||||
|
||||
old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
f := Finding{
|
||||
Class: contract.PressureClassPoorDiscoverability,
|
||||
Summary: "consumers repeatedly send an unsupported filter",
|
||||
Consumers: 3, Occurrences: 30,
|
||||
Severity: 0.4, Confidence: 0.6,
|
||||
Evidence: []contract.EvidenceRef{"route:GET /entries"},
|
||||
FirstSeen: old,
|
||||
LastSeen: old.Add(time.Hour),
|
||||
Fingerprint: "poor:GET /entries",
|
||||
}
|
||||
|
||||
if _, err := reg.Record(ctx, f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
later := f
|
||||
later.FirstSeen = old.AddDate(0, 6, 0)
|
||||
later.LastSeen = old.AddDate(0, 6, 1)
|
||||
got, err := reg.Record(ctx, later)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !got.FirstSeen.Equal(old) {
|
||||
t.Errorf("first_seen was overwritten: %s, want %s", got.FirstSeen, old)
|
||||
}
|
||||
if !got.LastSeen.Equal(later.LastSeen) {
|
||||
t.Errorf("last_seen was not extended: %s", got.LastSeen)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDismissedPressureStaysDismissed: an operator's decision that something is
|
||||
// not worth acting on must survive the next analysis run.
|
||||
func TestDismissedPressureStaysDismissed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
reg, _ := newRegistry(t)
|
||||
|
||||
f := Finding{
|
||||
Class: contract.PressureClassOutOfScopeDemand,
|
||||
Summary: "consumers ask for a capability outside the interface boundary",
|
||||
Consumers: 5, Occurrences: 40,
|
||||
Severity: 0.5, Confidence: 0.7,
|
||||
Evidence: []contract.EvidenceRef{"route:GET /billing"},
|
||||
FirstSeen: base,
|
||||
LastSeen: base.Add(time.Hour),
|
||||
Fingerprint: "out-of-scope:GET /billing",
|
||||
}
|
||||
|
||||
p, err := reg.Record(ctx, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
actor := contract.Actor{Type: contract.ActorTypeHuman, ID: "worsch"}
|
||||
if err := reg.SetStatus(ctx, p.ID, contract.FluidPressureStatusDISMISSED, actor,
|
||||
"billing belongs to the payments interface"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := reg.Record(ctx, f); !errors.Is(err, ErrDismissed) {
|
||||
t.Errorf("a dismissed pressure was silently reopened: %v", err)
|
||||
}
|
||||
|
||||
// A batch run must skip it rather than fail.
|
||||
if _, err := reg.RecordAll(ctx, []Finding{f}); err != nil {
|
||||
t.Errorf("RecordAll failed on a dismissed pressure: %v", err)
|
||||
}
|
||||
|
||||
got, err := reg.Get(ctx, p.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != contract.FluidPressureStatusDISMISSED {
|
||||
t.Errorf("status = %s, want DISMISSED", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusChangeRequiresAReason(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
reg, _ := newRegistry(t)
|
||||
|
||||
p, err := reg.Record(ctx, Finding{
|
||||
Class: contract.PressureClassPoorDiscoverability, Summary: "s",
|
||||
Consumers: 2, Occurrences: 5, Evidence: []contract.EvidenceRef{"route:x"},
|
||||
FirstSeen: base, LastSeen: base, Fingerprint: "fp",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
actor := contract.Actor{Type: contract.ActorTypeHuman, ID: "worsch"}
|
||||
if err := reg.SetStatus(ctx, p.ID, contract.FluidPressureStatusDISMISSED, actor, ""); err == nil {
|
||||
t.Error("a dismissal with no reason was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkHypothesisMovesToAnalyzing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
reg, store := newRegistry(t)
|
||||
|
||||
p, err := reg.Record(ctx, Finding{
|
||||
Class: contract.PressureClassSuccessfulButInefficientUsage, Summary: "s",
|
||||
Consumers: 3, Occurrences: 12, Evidence: []contract.EvidenceRef{"route:x"},
|
||||
FirstSeen: base, LastSeen: base, Fingerprint: "fp",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := reg.LinkHypothesis(ctx, p.ID, "H-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Linking must reject a mis-prefixed id rather than store nonsense.
|
||||
if err := reg.LinkHypothesis(ctx, p.ID, "R-1"); err == nil {
|
||||
t.Error("a revision id was accepted as a hypothesis link")
|
||||
}
|
||||
|
||||
got, err := reg.Get(ctx, p.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != contract.FluidPressureStatusANALYZING {
|
||||
t.Errorf("status = %s, want ANALYZING", got.Status)
|
||||
}
|
||||
if len(got.LinkedHypotheses) != 1 {
|
||||
t.Errorf("linked hypotheses = %v", got.LinkedHypotheses)
|
||||
}
|
||||
|
||||
// Linking twice must not duplicate.
|
||||
if err := reg.LinkHypothesis(ctx, p.ID, "H-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ = reg.Get(ctx, p.ID)
|
||||
if len(got.LinkedHypotheses) != 1 {
|
||||
t.Errorf("duplicate link recorded: %v", got.LinkedHypotheses)
|
||||
}
|
||||
|
||||
events, err := store.Events(ctx, evidence.EventFilter{EntityID: string(p.ID)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) < 2 {
|
||||
t.Errorf("lifecycle transitions left too few events: %d", len(events))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOrdersBySeverity(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
reg, _ := newRegistry(t)
|
||||
|
||||
for i, sev := range []float64{0.2, 0.9, 0.5} {
|
||||
_, err := reg.Record(ctx, Finding{
|
||||
Class: contract.PressureClassPoorDiscoverability,
|
||||
Summary: fmt.Sprintf("finding %d", i),
|
||||
Consumers: 3, Occurrences: 10,
|
||||
Severity: sev, Confidence: 0.5,
|
||||
Evidence: []contract.EvidenceRef{contract.EvidenceRef(fmt.Sprintf("route:%d", i))},
|
||||
FirstSeen: base,
|
||||
LastSeen: base,
|
||||
Fingerprint: fmt.Sprintf("fp-%d", i),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := reg.List(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("got %d records", len(got))
|
||||
}
|
||||
for i := 1; i < len(got); i++ {
|
||||
if unit(got[i-1].Severity) < unit(got[i].Severity) {
|
||||
t.Error("records are not ordered by severity")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func classesOf(fs []Finding) []contract.PressureClass {
|
||||
out := make([]contract.PressureClass, len(fs))
|
||||
for i, f := range fs {
|
||||
out[i] = f.Class
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasClass(fs []Finding, want contract.PressureClass) bool {
|
||||
for _, f := range fs {
|
||||
if f.Class == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
326
internal/observation/registry.go
Normal file
326
internal/observation/registry.go
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
package observation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/tegwick/fluid-core/internal/contract"
|
||||
"github.com/tegwick/fluid-core/internal/evidence"
|
||||
)
|
||||
|
||||
// PressureRegistry is the durable inventory of material interface pressure.
|
||||
//
|
||||
// ArchitectureBlueprint.md section 9 requires deduplication, aggregation,
|
||||
// cohort segmentation, frequency tracking, severity, confidence, linked
|
||||
// hypotheses and disposition — and says plainly that pressure may remain
|
||||
// unresolved on purpose. Not every observed mismatch deserves adaptation.
|
||||
type PressureRegistry struct {
|
||||
store evidence.Store
|
||||
iface contract.InterfaceID
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewPressureRegistry returns a registry backed by the evidence store.
|
||||
func NewPressureRegistry(store evidence.Store, iface contract.InterfaceID) *PressureRegistry {
|
||||
return &PressureRegistry{store: store, iface: iface, now: time.Now}
|
||||
}
|
||||
|
||||
// ErrDismissed reports an attempt to reopen a deliberately closed pressure.
|
||||
var ErrDismissed = errors.New("pressure was dismissed and will not be reopened automatically")
|
||||
|
||||
// pressureID derives a stable identifier from a finding's fingerprint.
|
||||
//
|
||||
// Deriving rather than allocating is what makes ingest idempotent: re-running
|
||||
// analysis over the same window updates one record instead of minting a new one
|
||||
// on every pass.
|
||||
func pressureID(iface contract.InterfaceID, fingerprint string) contract.PressureID {
|
||||
sum := sha256.Sum256([]byte(string(iface) + "\x00" + fingerprint))
|
||||
return contract.PressureID("P-" + hex.EncodeToString(sum[:6]))
|
||||
}
|
||||
|
||||
// Record folds a finding into the registry, creating or updating one pressure.
|
||||
//
|
||||
// A finding that matches an existing record extends its window and refreshes
|
||||
// its counts rather than replacing it: first_seen is evidence about how long
|
||||
// the interface has had this problem, and overwriting it would erase the age
|
||||
// that makes a pressure worth prioritizing.
|
||||
func (r *PressureRegistry) Record(ctx context.Context, f Finding) (contract.FluidPressure, error) {
|
||||
id := pressureID(r.iface, f.Fingerprint)
|
||||
|
||||
existing, err := r.Get(ctx, id)
|
||||
switch {
|
||||
case err == nil:
|
||||
if existing.Status == contract.FluidPressureStatusDISMISSED {
|
||||
// An operator decided this is not worth acting on. Silently
|
||||
// resurrecting it would make the dismissal meaningless.
|
||||
return existing, fmt.Errorf("%w: %s", ErrDismissed, id)
|
||||
}
|
||||
return r.update(ctx, existing, f)
|
||||
case errors.Is(err, evidence.ErrNotFound):
|
||||
return r.create(ctx, id, f)
|
||||
default:
|
||||
return contract.FluidPressure{}, err
|
||||
}
|
||||
}
|
||||
|
||||
func (r *PressureRegistry) create(ctx context.Context, id contract.PressureID, f Finding) (contract.FluidPressure, error) {
|
||||
observations := int64(f.Occurrences)
|
||||
consumers := int64(f.Consumers)
|
||||
severity := contract.UnitInterval(f.Severity)
|
||||
confidence := contract.UnitInterval(f.Confidence)
|
||||
|
||||
p := contract.FluidPressure{
|
||||
SchemaVersion: "0.1",
|
||||
ID: id,
|
||||
InterfaceID: r.iface,
|
||||
Class: f.Class,
|
||||
FirstSeen: f.FirstSeen,
|
||||
LastSeen: f.LastSeen,
|
||||
AffectedCohorts: f.Cohorts,
|
||||
Frequency: &contract.FluidPressureFrequency{
|
||||
Observations: &observations,
|
||||
IndependentConsumers: &consumers,
|
||||
},
|
||||
Severity: &severity,
|
||||
Confidence: &confidence,
|
||||
Summary: f.Summary,
|
||||
EvidenceRefs: f.Evidence,
|
||||
Status: contract.FluidPressureStatusOPEN,
|
||||
}
|
||||
|
||||
if err := r.put(ctx, p); err != nil {
|
||||
return contract.FluidPressure{}, err
|
||||
}
|
||||
if err := r.event(ctx, p, "PRESSURE_OPENED", f.Summary); err != nil {
|
||||
return contract.FluidPressure{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (r *PressureRegistry) update(ctx context.Context, p contract.FluidPressure, f Finding) (contract.FluidPressure, error) {
|
||||
if f.FirstSeen.Before(p.FirstSeen) {
|
||||
p.FirstSeen = f.FirstSeen
|
||||
}
|
||||
if f.LastSeen.After(p.LastSeen) {
|
||||
p.LastSeen = f.LastSeen
|
||||
}
|
||||
|
||||
observations := int64(f.Occurrences)
|
||||
consumers := int64(f.Consumers)
|
||||
p.Frequency = &contract.FluidPressureFrequency{
|
||||
Observations: &observations,
|
||||
IndependentConsumers: &consumers,
|
||||
}
|
||||
|
||||
severity := contract.UnitInterval(f.Severity)
|
||||
confidence := contract.UnitInterval(f.Confidence)
|
||||
p.Severity = &severity
|
||||
p.Confidence = &confidence
|
||||
p.Summary = f.Summary
|
||||
p.AffectedCohorts = mergeCohorts(p.AffectedCohorts, f.Cohorts)
|
||||
p.EvidenceRefs = mergeRefs(p.EvidenceRefs, f.Evidence)
|
||||
|
||||
// Fresh evidence for something previously explained means it is back.
|
||||
if p.Status == contract.FluidPressureStatusADDRESSED {
|
||||
p.Status = contract.FluidPressureStatusOPEN
|
||||
if err := r.put(ctx, p); err != nil {
|
||||
return contract.FluidPressure{}, err
|
||||
}
|
||||
return p, r.event(ctx, p, "PRESSURE_REOPENED",
|
||||
"new evidence observed after the pressure was marked addressed")
|
||||
}
|
||||
|
||||
if err := r.put(ctx, p); err != nil {
|
||||
return contract.FluidPressure{}, err
|
||||
}
|
||||
return p, r.event(ctx, p, "PRESSURE_OBSERVED", f.Summary)
|
||||
}
|
||||
|
||||
// RecordAll folds a batch of findings into the registry.
|
||||
//
|
||||
// Dismissed pressures are skipped rather than treated as errors: hitting one
|
||||
// is the expected outcome of re-analysing a window an operator has already
|
||||
// triaged.
|
||||
func (r *PressureRegistry) RecordAll(ctx context.Context, findings []Finding) ([]contract.FluidPressure, error) {
|
||||
var out []contract.FluidPressure
|
||||
for _, f := range findings {
|
||||
p, err := r.Record(ctx, f)
|
||||
if errors.Is(err, ErrDismissed) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Get returns one pressure record.
|
||||
func (r *PressureRegistry) Get(ctx context.Context, id contract.PressureID) (contract.FluidPressure, error) {
|
||||
body, err := r.store.Record(ctx, contract.KindPressure, string(id))
|
||||
if err != nil {
|
||||
return contract.FluidPressure{}, err
|
||||
}
|
||||
var doc contract.PressureDocument
|
||||
if err := json.Unmarshal(body, &doc); err != nil {
|
||||
return contract.FluidPressure{}, fmt.Errorf("decode pressure %s: %w", id, err)
|
||||
}
|
||||
return doc.FluidPressure, nil
|
||||
}
|
||||
|
||||
// List returns pressures, optionally filtered by status.
|
||||
func (r *PressureRegistry) List(ctx context.Context, status contract.FluidPressureStatus) ([]contract.FluidPressure, error) {
|
||||
records, err := r.store.Records(ctx, contract.KindPressure)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]contract.FluidPressure, 0, len(records))
|
||||
for _, body := range records {
|
||||
var doc contract.PressureDocument
|
||||
if err := json.Unmarshal(body, &doc); err != nil {
|
||||
continue
|
||||
}
|
||||
if status != "" && doc.FluidPressure.Status != status {
|
||||
continue
|
||||
}
|
||||
out = append(out, doc.FluidPressure)
|
||||
}
|
||||
|
||||
// Most severe first: the registry is a work queue as much as an inventory.
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
si, sj := unit(out[i].Severity), unit(out[j].Severity)
|
||||
if si != sj {
|
||||
return si > sj
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetStatus moves a pressure through its lifecycle.
|
||||
//
|
||||
// Transitions are recorded as events rather than only as a field, so a
|
||||
// dismissal can be traced to whoever made it and why.
|
||||
func (r *PressureRegistry) SetStatus(ctx context.Context, id contract.PressureID, status contract.FluidPressureStatus, actor contract.Actor, reason string) error {
|
||||
if !status.Valid() {
|
||||
return fmt.Errorf("unknown pressure status %q", status)
|
||||
}
|
||||
if reason == "" {
|
||||
// A status change with no reason is not auditable, and dismissals
|
||||
// without a reason are how a registry quietly loses its evidence.
|
||||
return errors.New("a status change requires a reason")
|
||||
}
|
||||
|
||||
p, err := r.Get(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
previous := p.Status
|
||||
p.Status = status
|
||||
|
||||
if err := r.put(ctx, p); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.eventBy(ctx, p, "PRESSURE_STATUS_CHANGED", actor,
|
||||
fmt.Sprintf("%s -> %s: %s", previous, status, reason))
|
||||
}
|
||||
|
||||
// LinkHypothesis records that a hypothesis addresses this pressure.
|
||||
func (r *PressureRegistry) LinkHypothesis(ctx context.Context, id contract.PressureID, h contract.HypothesisID) error {
|
||||
if err := contract.RequireKind(string(h), contract.KindHypothesis); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p, err := r.Get(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, existing := range p.LinkedHypotheses {
|
||||
if existing == h {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
p.LinkedHypotheses = append(p.LinkedHypotheses, h)
|
||||
if p.Status == contract.FluidPressureStatusOPEN {
|
||||
p.Status = contract.FluidPressureStatusANALYZING
|
||||
}
|
||||
|
||||
if err := r.put(ctx, p); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.event(ctx, p, "PRESSURE_HYPOTHESIS_LINKED", fmt.Sprintf("linked %s", h))
|
||||
}
|
||||
|
||||
func (r *PressureRegistry) put(ctx context.Context, p contract.FluidPressure) error {
|
||||
body, err := json.Marshal(contract.PressureDocument{FluidPressure: p})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.store.PutRecord(ctx, contract.KindPressure, string(p.ID), body)
|
||||
}
|
||||
|
||||
func (r *PressureRegistry) event(ctx context.Context, p contract.FluidPressure, kind, reason string) error {
|
||||
return r.eventBy(ctx, p, kind,
|
||||
contract.Actor{Type: contract.ActorTypeSystem, ID: "fluid-pressure-engine"}, reason)
|
||||
}
|
||||
|
||||
func (r *PressureRegistry) eventBy(ctx context.Context, p contract.FluidPressure, kind string, actor contract.Actor, reason string) error {
|
||||
return r.store.AppendEvent(ctx, contract.FluidEvent{
|
||||
SchemaVersion: "0.1",
|
||||
ID: contract.EventID(fmt.Sprintf("EV-%s-%d", p.ID, r.now().UnixNano())),
|
||||
OccurredAt: r.now().UTC(),
|
||||
EntityType: contract.FluidEventEntityTypePressure,
|
||||
EntityID: string(p.ID),
|
||||
EventType: kind,
|
||||
Actor: actor,
|
||||
Reason: reason,
|
||||
EvidenceRefs: p.EvidenceRefs,
|
||||
})
|
||||
}
|
||||
|
||||
func unit(v *contract.UnitInterval) float64 {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return float64(*v)
|
||||
}
|
||||
|
||||
func mergeCohorts(a, b []contract.CohortID) []contract.CohortID {
|
||||
seen := map[contract.CohortID]struct{}{}
|
||||
var out []contract.CohortID
|
||||
for _, list := range [][]contract.CohortID{a, b} {
|
||||
for _, c := range list {
|
||||
if _, ok := seen[c]; ok {
|
||||
continue
|
||||
}
|
||||
seen[c] = struct{}{}
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeRefs(a, b []contract.EvidenceRef) []contract.EvidenceRef {
|
||||
seen := map[contract.EvidenceRef]struct{}{}
|
||||
var out []contract.EvidenceRef
|
||||
for _, list := range [][]contract.EvidenceRef{a, b} {
|
||||
for _, s := range list {
|
||||
if _, ok := seen[s]; ok {
|
||||
continue
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||||
return out
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue