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
365 lines
10 KiB
Go
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
|
|
}
|