fluid-core/internal/observation/topology_test.go
tegwick e779d1f8b9 Add redaction filter, cohort engine and topology analyzer
FLUID-WP-0005 T02-T04. Redaction is deterministic configuration rather
than a heuristic: the evidence store is append-only, so anything it
accepts cannot be taken back out. Consumer identities are pseudonymized
with HMAC rather than a bare hash, since a plain digest of a short
identifier is reversible by enumeration.

Applied rules are recorded on each event, so later analysis knows what
it cannot see instead of mistaking an absence of evidence for evidence
of absence. Redacted query parameters keep their key: which parameters a
consumer sent is itself interface evidence.

The topology analyzer requires several independent consumers before
calling a shape a pattern. One client looping is a client bug; several
converging on the same workaround is interface pressure. It also
distinguishes a recovered error from a hard failure, because those two
deserve different hypotheses.

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:08:05 +02:00

223 lines
6.8 KiB
Go

package observation
import (
"fmt"
"testing"
"time"
"github.com/tegwick/fluid-core/internal/contract"
)
var base = time.Date(2026, 9, 4, 10, 0, 0, 0, time.UTC)
func req(consumer string, offset time.Duration, method, route string, status int64) contract.FluidTelemetry {
cohort := contract.CohortID("coding-agents")
s := status
return contract.FluidTelemetry{
ID: fmt.Sprintf("tl-%s-%d", consumer, offset),
OccurredAt: base.Add(offset),
Kind: contract.FluidTelemetryKindRequest,
ConsumerRef: consumer,
Cohort: &cohort,
Request: &contract.FluidTelemetryRequest{Route: route, Method: method, Status: &s},
}
}
func errEv(consumer string, offset time.Duration, route string, class contract.FluidTelemetryErrorClass) contract.FluidTelemetry {
ev := req(consumer, offset, "GET", route, 400)
ev.Kind = contract.FluidTelemetryKindError
ev.Error = &contract.FluidTelemetryError{Class: class}
return ev
}
// TestDetectsInefficientUsagePattern reproduces the Blueprint section 33
// worked example: consumers listing everything to find one record.
func TestDetectsInefficientUsagePattern(t *testing.T) {
a := NewTopologyAnalyzer()
var events []contract.FluidTelemetry
for _, consumer := range []string{"c-1", "c-2", "c-3"} {
for chain := 0; chain < 2; chain++ {
start := time.Duration(chain) * time.Hour
events = append(events,
req(consumer, start, "GET", "/customers/{id}/invoices", 200),
req(consumer, start+time.Second, "GET", "/customers/{id}/invoices", 200),
req(consumer, start+2*time.Second, "GET", "/customers/{id}/invoices", 200),
)
}
}
patterns := a.Patterns(events)
if len(patterns) == 0 {
t.Fatal("no pattern detected in a clearly repeated shape")
}
p := patterns[0]
if p.Consumers != 3 {
t.Errorf("independent consumers = %d, want 3", p.Consumers)
}
if p.Count != 6 {
t.Errorf("occurrences = %d, want 6", p.Count)
}
// The repeated route is the signal that the consumer is compensating.
if p.MaxRepeats != 3 {
t.Errorf("max repeats = %d, want 3", p.MaxRepeats)
}
if p.RepeatedStep != "GET /customers/{id}/invoices" {
t.Errorf("repeated step = %q", p.RepeatedStep)
}
}
// TestOneConsumerRepeatingIsNotAPattern: a single client looping is a client
// bug; several independent consumers converging is interface pressure.
func TestOneConsumerRepeatingIsNotAPattern(t *testing.T) {
a := NewTopologyAnalyzer()
var events []contract.FluidTelemetry
for chain := 0; chain < 10; chain++ {
start := time.Duration(chain) * time.Hour
events = append(events,
req("c-1", start, "GET", "/entries", 200),
req("c-1", start+time.Second, "GET", "/entries", 200),
)
}
if patterns := a.Patterns(events); len(patterns) != 0 {
t.Errorf("a single consumer's loop was reported as a pattern: %+v", patterns)
}
}
func TestChainGapSplitsInteractions(t *testing.T) {
a := NewTopologyAnalyzer()
a.ChainGap = 10 * time.Second
events := []contract.FluidTelemetry{
req("c-1", 0, "GET", "/a", 200),
req("c-1", 2*time.Second, "GET", "/b", 200),
// Well past the gap: a new interaction, not a continuation.
req("c-1", time.Minute, "GET", "/c", 200),
}
interactions := a.Interactions(events)
if len(interactions) != 2 {
t.Fatalf("got %d interactions, want 2", len(interactions))
}
if len(interactions[0].Steps) != 2 || len(interactions[1].Steps) != 1 {
t.Errorf("steps split wrongly: %d and %d",
len(interactions[0].Steps), len(interactions[1].Steps))
}
}
func TestExplicitChainIDOverridesTiming(t *testing.T) {
a := NewTopologyAnalyzer()
a.ChainGap = time.Second
withChain := func(ev contract.FluidTelemetry, id string) contract.FluidTelemetry {
ev.Sequence = &contract.FluidTelemetrySequence{ChainID: id}
return ev
}
// Two calls an hour apart, but the consumer says they are one task.
events := []contract.FluidTelemetry{
withChain(req("c-1", 0, "GET", "/a", 200), "chain-1"),
withChain(req("c-1", time.Hour, "GET", "/b", 200), "chain-1"),
}
interactions := a.Interactions(events)
if len(interactions) != 1 {
t.Fatalf("an explicit chain id was split by timing: got %d interactions", len(interactions))
}
}
// TestRecoveredErrorIsDistinguished: invalid request, then a corrected retry,
// is a recoverable misunderstanding rather than a hard failure, and the two
// deserve different hypotheses.
func TestRecoveredErrorIsDistinguished(t *testing.T) {
a := NewTopologyAnalyzer()
var events []contract.FluidTelemetry
for _, consumer := range []string{"c-1", "c-2", "c-3"} {
for chain := 0; chain < 2; chain++ {
start := time.Duration(chain) * time.Hour
events = append(events,
errEv(consumer, start, "/entries", contract.FluidTelemetryErrorClassValidation),
req(consumer, start+time.Second, "GET", "/entries", 200),
)
}
}
patterns := a.Patterns(events)
if len(patterns) == 0 {
t.Fatal("no pattern detected")
}
if patterns[0].RecoveredError != contract.FluidTelemetryErrorClassValidation {
t.Errorf("recovered error = %q, want validation", patterns[0].RecoveredError)
}
}
func TestPatternsAreDeterministicallyOrdered(t *testing.T) {
a := NewTopologyAnalyzer()
var events []contract.FluidTelemetry
for _, consumer := range []string{"c-1", "c-2", "c-3"} {
for chain := 0; chain < 5; chain++ {
start := time.Duration(chain) * time.Hour
events = append(events, req(consumer, start, "GET", "/frequent", 200))
}
for chain := 0; chain < 2; chain++ {
start := time.Duration(chain+10) * time.Hour
events = append(events, req(consumer, start, "GET", "/rare", 200))
}
}
first := a.Patterns(events)
if len(first) < 2 {
t.Fatalf("expected two patterns, got %d", len(first))
}
// Biggest signal first, so an analyst reads the important thing first.
if first[0].Count < first[1].Count {
t.Error("patterns are not ordered by frequency")
}
for i := 0; i < 20; i++ {
again := a.Patterns(events)
for j := range first {
if again[j].Signature != first[j].Signature {
t.Fatal("pattern ordering varied between runs")
}
}
}
}
func TestCohortPopulationsRespectMinimumSize(t *testing.T) {
policy := testPolicy()
e := NewCohortEngine("unclassified", policy)
var events []contract.FluidTelemetry
// One cohort with a single consumer, one with plenty.
small := contract.CohortID("lone-partner")
for i := 0; i < 20; i++ {
ev := req("only-one", time.Duration(i)*time.Second, "GET", "/a", 200)
ev.Cohort = &small
events = append(events, ev)
}
big := contract.CohortID("agents")
for i := 0; i < 20; i++ {
ev := req(fmt.Sprintf("c-%d", i), time.Duration(i)*time.Second, "GET", "/a", 200)
ev.Cohort = &big
events = append(events, ev)
}
pops := e.Populations(events)
byCohort := map[contract.CohortID]Population{}
for _, p := range pops {
byCohort[p.Cohort] = p
}
if !byCohort[small].Suppressed {
t.Error("a cohort of one consumer was reportable")
}
if byCohort[big].Suppressed {
t.Error("a cohort of twenty consumers was suppressed")
}
}