fluid-core/internal/observation/pressure_test.go
tegwick 0ac35892a5 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
2026-09-04 03:11:49 +02:00

365 lines
10 KiB
Go

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
}