feat(explainability): fix X-ray overlay with normalized confidence breakdown and tests

Three issues resolved in the detection explainability system:

- computeExplanation returned early when grid==nil (always the case in the
  live fusion loop), causing all explain requests to return empty link
  contributions. Removed the unnecessary nil-grid guard since the Fresnel
  computation uses only blob/link positions, not the grid.

- Contribution values were raw deltaRMS×weight×zoneDecay scalars, not
  percentages. Now normalized so contributing links sum to 1.0, giving the
  dashboard a proper confidence breakdown (60% / 40% / etc.).

- Fixed off-by-one in blobHistory eviction (kept 101 instead of 100).

Added 23 table-driven tests covering: nil-grid computation, single/multi-link
normalization, Fresnel zone number geometry, ellipsoid generation, HTTP
handlers (200/400 paths), WebSocket snapshot fields, BLE match inclusion,
zone decay inverse-square law, and history eviction cap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-25 09:16:37 -04:00
parent b7972ccb0b
commit 057285f901
2 changed files with 589 additions and 56 deletions

View file

@ -63,10 +63,13 @@ type BLEMatch struct {
// FresnelZone describes a Fresnel zone ellipsoid for a link.
type FresnelZone struct {
LinkID string `json:"link_id"`
LinkID string `json:"link_id"`
CenterPos [3]float64 `json:"center_pos"` // [x, y, z] zone center
SemiAxes [3]float64 `json:"semi_axes"` // [a, b, c] for ellipsoid
ZoneNumber int `json:"zone_number"` // zone number for this blob position
ZoneNumber int `json:"zone_number"` // zone number for this blob position
TXPos [3]float64 `json:"tx_pos"` // transmitter position for proper ellipsoid orientation
RXPos [3]float64 `json:"rx_pos"` // receiver position for proper ellipsoid orientation
Lambda float64 `json:"lambda"` // WiFi wavelength in metres
}
// LinkState captures the current state of a link.
@ -156,7 +159,8 @@ func (h *Handler) UpdateBlobs(blobs []BlobSnapshot, links []LinkState, grid *Gri
maxID = id
}
}
cutoff := maxID - 100
// keep IDs maxID-99 .. maxID (100 entries): delete id < maxID-99
cutoff := maxID - 99
for id := range h.blobHistory {
if id < cutoff {
delete(h.blobHistory, id)
@ -348,18 +352,8 @@ func (h *Handler) refreshData(w http.ResponseWriter, r *http.Request) {
}
// computeExplanation calculates the explanation for a single blob.
func (h *Handler) computeExplanation(blob BlobSnapshot, links []LinkState, grid *GridSnapshot) *BlobExplanation {
if grid == nil {
return &BlobExplanation{
BlobID: blob.ID,
X: blob.X,
Y: blob.Y,
Z: blob.Z,
Confidence: blob.Confidence,
AllLinks: []LinkContribution{},
}
}
// grid is accepted for future use but Fresnel computation only needs blob/link positions.
func (h *Handler) computeExplanation(blob BlobSnapshot, links []LinkState, _ *GridSnapshot) *BlobExplanation {
explanation := &BlobExplanation{
BlobID: blob.ID,
X: blob.X,
@ -367,7 +361,7 @@ func (h *Handler) computeExplanation(blob BlobSnapshot, links []LinkState, grid
Z: blob.Z,
Confidence: blob.Confidence,
ContributingLinks: []LinkContribution{},
AllLinks: make([]LinkContribution, 0, len(links)),
AllLinks: make([]LinkContribution, 0, len(links)),
FresnelZones: []FresnelZone{},
}
@ -375,87 +369,166 @@ func (h *Handler) computeExplanation(blob BlobSnapshot, links []LinkState, grid
const lambda = 0.123
const halfLambda = lambda / 2
// Compute contribution for each link
var totalContribution float64
// Compute raw contribution for each link
for _, link := range links {
linkID := link.NodeMAC + ":" + link.PeerMAC
// Get node positions
nodePos := [3]float64{link.NodePos[0], link.NodePos[1], link.NodePos[2]}
peerPos := [3]float64{link.PeerPos[0], link.PeerPos[1], link.PeerPos[2]}
// Calculate path length excess at blob position
// Path length excess at blob position: ΔL = |blob-TX| + |blob-RX| - |TX-RX|
pathDirect := math.Sqrt(
math.Pow(peerPos[0]-nodePos[0], 2) +
math.Pow(peerPos[1]-nodePos[1], 2) +
math.Pow(peerPos[2]-nodePos[2], 2))
pathViaBlob := math.Sqrt(
math.Pow(blob.X-nodePos[0], 2) +
math.Pow(blob.Y-nodePos[1], 2) +
math.Pow(blob.X-nodePos[0], 2)+
math.Pow(blob.Y-nodePos[1], 2)+
math.Pow(blob.Z-nodePos[2], 2)) +
math.Sqrt(
math.Pow(peerPos[0]-blob.X, 2) +
math.Pow(peerPos[1]-blob.Y, 2) +
math.Pow(peerPos[0]-blob.X, 2)+
math.Pow(peerPos[1]-blob.Y, 2)+
math.Pow(peerPos[2]-blob.Z, 2))
deltaL := pathViaBlob - pathDirect
// Fresnel zone number
// Fresnel zone number: zone = ceil(ΔL / (λ/2)), minimum 1
zoneNumber := int(math.Ceil(deltaL / halfLambda))
if zoneNumber < 1 {
zoneNumber = 1
}
// Zone decay function (default decay_rate = 2.0)
// Zone decay: 1/n^decay_rate, decay_rate=2.0 per plan
zoneDecay := 1.0 / math.Pow(float64(zoneNumber), 2.0)
// Contribution = deltaRMS * weight * zoneDecay
contribution := link.DeltaRMS * link.Weight * zoneDecay
contributing := link.Motion && link.DeltaRMS > 0.02
// Raw contribution = deltaRMS × learned_weight × zone_decay
rawContribution := link.DeltaRMS * link.Weight * zoneDecay
linkContrib := LinkContribution{
LinkID: linkID,
NodeMAC: link.NodeMAC,
PeerMAC: link.PeerMAC,
DeltaRMS: link.DeltaRMS,
ZoneNumber: zoneNumber,
Weight: link.Weight,
Contributing: link.Motion && link.DeltaRMS > 0.02,
Contribution: contribution,
LinkID: linkID,
NodeMAC: link.NodeMAC,
PeerMAC: link.PeerMAC,
DeltaRMS: link.DeltaRMS,
ZoneNumber: zoneNumber,
Weight: link.Weight,
Contributing: contributing,
Contribution: rawContribution, // normalized below
}
explanation.AllLinks = append(explanation.AllLinks, linkContrib)
if link.Motion && link.DeltaRMS > 0.02 {
explanation.ContributingLinks = append(explanation.ContributingLinks, linkContrib)
}
if contributing {
totalContribution += rawContribution
// Add Fresnel zone ellipsoid data for contributing links
if link.Motion && link.DeltaRMS > 0.02 {
// Compute ellipsoid parameters
// Center is midpoint between nodes
// Fresnel zone ellipsoid for contributing link
centerX := (nodePos[0] + peerPos[0]) / 2
centerY := (nodePos[1] + peerPos[1]) / 2
centerZ := (nodePos[2] + peerPos[2]) / 2
// Semi-axes approximation for first Fresnel zone
// The ellipsoid is roughly centered at the midpoint, with the
// long axis along the link direction
linkLength := pathDirect
longAxis := linkLength / 2
// Width of Fresnel zone at this distance
zoneWidth := 2 * math.Sqrt(math.Pow(lambda*float64(zoneNumber)/2, 2) -
math.Pow(float64(zoneNumber-1)*lambda/2, 2))
// Semi-major axis along link axis; semi-minor perpendicular
a := (pathDirect + lambda) / 2
b := math.Sqrt(math.Max(0, a*a-(pathDirect/2)*(pathDirect/2)))
explanation.FresnelZones = append(explanation.FresnelZones, FresnelZone{
LinkID: linkID,
CenterPos: [3]float64{centerX, centerY, centerZ},
SemiAxes: [3]float64{longAxis, zoneWidth, zoneWidth},
LinkID: linkID,
CenterPos: [3]float64{centerX, centerY, centerZ},
SemiAxes: [3]float64{b, b, a},
ZoneNumber: zoneNumber,
TXPos: nodePos,
RXPos: peerPos,
Lambda: lambda,
})
}
}
// Normalize contributions so contributing links sum to 1.0 (proper percentage breakdown).
// Non-contributing links retain their raw value for display context.
if totalContribution > 0 {
for i := range explanation.AllLinks {
if explanation.AllLinks[i].Contributing {
explanation.AllLinks[i].Contribution /= totalContribution
}
}
}
// Populate ContributingLinks from the normalized AllLinks slice
for _, lc := range explanation.AllLinks {
if lc.Contributing {
explanation.ContributingLinks = append(explanation.ContributingLinks, lc)
}
}
return explanation
}
// BuildWebSocketSnapshot returns an explanation snapshot for a blob in the
// format expected by the dashboard WebSocket handler (_transformSnapshot in
// explainability.js). Returns nil if the blob has no recorded explanation.
func (h *Handler) BuildWebSocketSnapshot(blobID int) map[string]interface{} {
h.mu.RLock()
exp, ok := h.blobHistory[blobID]
h.mu.RUnlock()
if !ok || exp == nil {
return nil
}
// Convert AllLinks to the per_link_contributions format the dashboard expects.
perLinkContribs := make([]map[string]interface{}, 0, len(exp.AllLinks))
for _, link := range exp.AllLinks {
perLinkContribs = append(perLinkContribs, map[string]interface{}{
"link_id": link.LinkID,
"tx_mac": link.NodeMAC,
"rx_mac": link.PeerMAC,
"delta_rms": link.DeltaRMS,
"zone_number": link.ZoneNumber,
"weight": link.Weight,
"learned_weight": 1.0,
"combined_weight": link.Weight,
"contribution_pct": link.Contribution * 100,
"fresnel_intersection_volume": 0,
"contributing": link.Contributing,
})
}
// Convert FresnelZones to the format the dashboard expects.
fresnelZones := make([]map[string]interface{}, 0, len(exp.FresnelZones))
for _, z := range exp.FresnelZones {
fresnelZones = append(fresnelZones, map[string]interface{}{
"link_id": z.LinkID,
"tx_pos": z.TXPos[:],
"rx_pos": z.RXPos[:],
"center_pos": z.CenterPos[:],
"semi_axes": z.SemiAxes[:],
"zone_number": z.ZoneNumber,
"lambda": z.Lambda,
})
}
snap := map[string]interface{}{
"blob_id": exp.BlobID,
"blob_position": []float64{exp.X, exp.Y, exp.Z},
"fusion_score": exp.Confidence,
"per_link_contributions": perLinkContribs,
"fresnel_zones": fresnelZones,
"timestamp": time.UnixMilli(exp.Timestamp),
}
if exp.BLEMatch != nil {
snap["ble_match"] = map[string]interface{}{
"device_mac": exp.BLEMatch.DeviceAddr,
"person_id": exp.BLEMatch.PersonID,
"person_label": exp.BLEMatch.PersonLabel,
"person_color": exp.BLEMatch.PersonColor,
"triangulation_confidence": exp.BLEMatch.Confidence,
"match_method": exp.BLEMatch.MatchMethod,
}
if exp.BLEMatch.TriangulationPos != nil {
snap["ble_match"].(map[string]interface{})["triangulation_pos"] = exp.BLEMatch.TriangulationPos[:]
}
}
return snap
}
// writeJSON writes a JSON response.
func writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")

View file

@ -0,0 +1,460 @@
package explainability
import (
"encoding/json"
"math"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
)
// ── helpers ────────────────────────────────────────────────────────────────────
func newHandler() *Handler { return NewHandler() }
// makeLinkState returns a LinkState with TX at origin and RX along the X axis.
func makeLinkState(nodeMAC, peerMAC string, rxX float64, deltaRMS float64, motion bool) LinkState {
return LinkState{
NodeMAC: nodeMAC,
PeerMAC: peerMAC,
NodePos: [3]float64{0, 0, 1},
PeerPos: [3]float64{rxX, 0, 1},
DeltaRMS: deltaRMS,
Motion: motion,
Weight: 1.0,
}
}
func makeBlobAt(id int, x, y, z, confidence float64) BlobSnapshot {
return BlobSnapshot{ID: id, X: x, Y: y, Z: z, Confidence: confidence}
}
// ── computeExplanation ────────────────────────────────────────────────────────
func TestComputeExplanation_NilGrid_StillComputesContributions(t *testing.T) {
h := newHandler()
blob := makeBlobAt(1, 2, 0, 1, 0.8)
link := makeLinkState("AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02", 4.0, 0.10, true)
exp := h.computeExplanation(blob, []LinkState{link}, nil) // nil grid must not abort
if exp == nil {
t.Fatal("expected non-nil explanation when grid is nil")
}
if len(exp.ContributingLinks) == 0 {
t.Error("expected at least one contributing link")
}
if len(exp.AllLinks) == 0 {
t.Error("expected at least one entry in AllLinks")
}
}
func TestComputeExplanation_NoLinks(t *testing.T) {
h := newHandler()
exp := h.computeExplanation(makeBlobAt(1, 1, 0, 1, 0.5), nil, nil)
if exp == nil {
t.Fatal("nil explanation")
}
if len(exp.ContributingLinks) != 0 || len(exp.AllLinks) != 0 {
t.Error("expected empty link slices with no links")
}
}
func TestComputeExplanation_SingleContributingLink_100Percent(t *testing.T) {
h := newHandler()
blob := makeBlobAt(1, 2, 0, 1, 0.9)
link := makeLinkState("AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02", 4.0, 0.15, true)
exp := h.computeExplanation(blob, []LinkState{link}, nil)
if len(exp.ContributingLinks) != 1 {
t.Fatalf("expected 1 contributing link, got %d", len(exp.ContributingLinks))
}
got := exp.ContributingLinks[0].Contribution
if math.Abs(got-1.0) > 1e-9 {
t.Errorf("single contributing link should have contribution=1.0, got %f", got)
}
}
func TestComputeExplanation_TwoEqualLinks_FiftyFifty(t *testing.T) {
h := newHandler()
blob := makeBlobAt(1, 2, 0, 1, 0.8)
// Two identical links placed symmetrically so they have the same deltaRMS, weight, and zone
linkA := makeLinkState("AA:00:00:00:00:01", "AA:00:00:00:00:02", 4.0, 0.10, true)
linkB := makeLinkState("BB:00:00:00:00:01", "BB:00:00:00:00:02", 4.0, 0.10, true)
// Both links have identical geometry → identical contribution
exp := h.computeExplanation(blob, []LinkState{linkA, linkB}, nil)
if len(exp.ContributingLinks) != 2 {
t.Fatalf("expected 2 contributing links, got %d", len(exp.ContributingLinks))
}
for _, lc := range exp.ContributingLinks {
if math.Abs(lc.Contribution-0.5) > 1e-9 {
t.Errorf("expected each link contribution=0.5, got %f", lc.Contribution)
}
}
}
func TestComputeExplanation_NonContributingLinkExcluded(t *testing.T) {
h := newHandler()
blob := makeBlobAt(1, 2, 0, 1, 0.7)
contributing := makeLinkState("AA:00:00:00:00:01", "AA:00:00:00:00:02", 4.0, 0.10, true)
nonContrib := makeLinkState("BB:00:00:00:00:01", "BB:00:00:00:00:02", 4.0, 0.005, false)
exp := h.computeExplanation(blob, []LinkState{contributing, nonContrib}, nil)
if len(exp.ContributingLinks) != 1 {
t.Fatalf("expected 1 contributing link, got %d", len(exp.ContributingLinks))
}
if len(exp.AllLinks) != 2 {
t.Fatalf("expected 2 entries in AllLinks, got %d", len(exp.AllLinks))
}
// Contributing link must have contribution = 1.0 after normalization
if math.Abs(exp.ContributingLinks[0].Contribution-1.0) > 1e-9 {
t.Errorf("expected contributing link contribution=1.0, got %f", exp.ContributingLinks[0].Contribution)
}
// Non-contributing link keeps its raw value (not normalized)
for _, lc := range exp.AllLinks {
if !lc.Contributing {
if lc.Contribution < 0 {
t.Error("non-contributing link contribution should be non-negative")
}
}
}
}
func TestComputeExplanation_FresnelZoneNumber(t *testing.T) {
// Place TX at (0,0,0) and RX at (4,0,0). Blob at (2,0,0) = midpoint.
// At midpoint, pathViaBlob = 2+2 = 4m = pathDirect = 4m, so ΔL = 0 → zone 1
h := newHandler()
blob := BlobSnapshot{ID: 1, X: 2, Y: 0, Z: 0, Confidence: 0.8}
link := LinkState{
NodeMAC: "AA:00:00:00:00:01",
PeerMAC: "AA:00:00:00:00:02",
NodePos: [3]float64{0, 0, 0},
PeerPos: [3]float64{4, 0, 0},
DeltaRMS: 0.10,
Motion: true,
Weight: 1.0,
}
exp := h.computeExplanation(blob, []LinkState{link}, nil)
if len(exp.ContributingLinks) != 1 {
t.Fatalf("expected 1 contributing link, got %d", len(exp.ContributingLinks))
}
if exp.ContributingLinks[0].ZoneNumber != 1 {
t.Errorf("expected zone 1 for midpoint blob, got %d", exp.ContributingLinks[0].ZoneNumber)
}
}
func TestComputeExplanation_FresnelEllipsoidAddedForContributingLink(t *testing.T) {
h := newHandler()
blob := makeBlobAt(1, 2, 0, 1, 0.8)
link := makeLinkState("AA:00:00:00:00:01", "AA:00:00:00:00:02", 4.0, 0.10, true)
exp := h.computeExplanation(blob, []LinkState{link}, nil)
if len(exp.FresnelZones) != 1 {
t.Fatalf("expected 1 Fresnel zone, got %d", len(exp.FresnelZones))
}
fz := exp.FresnelZones[0]
if fz.Lambda != 0.123 {
t.Errorf("expected lambda=0.123, got %f", fz.Lambda)
}
// Semi-axes must be positive
for i, ax := range fz.SemiAxes {
if ax <= 0 {
t.Errorf("SemiAxes[%d] must be > 0, got %f", i, ax)
}
}
}
func TestComputeExplanation_NonContributingLinkNoEllipsoid(t *testing.T) {
h := newHandler()
blob := makeBlobAt(1, 2, 0, 1, 0.8)
link := makeLinkState("AA:00:00:00:00:01", "AA:00:00:00:00:02", 4.0, 0.005, false)
exp := h.computeExplanation(blob, []LinkState{link}, nil)
if len(exp.FresnelZones) != 0 {
t.Errorf("expected no Fresnel zones for non-contributing link, got %d", len(exp.FresnelZones))
}
}
func TestComputeExplanation_ConfidencePropagated(t *testing.T) {
h := newHandler()
const want = 0.73
blob := makeBlobAt(1, 2, 0, 1, want)
exp := h.computeExplanation(blob, nil, nil)
if math.Abs(exp.Confidence-want) > 1e-9 {
t.Errorf("expected confidence=%f, got %f", want, exp.Confidence)
}
}
// ── UpdateBlobs / BuildWebSocketSnapshot round-trip ───────────────────────────
func TestUpdateBlobs_PopulatesBlobHistory(t *testing.T) {
h := newHandler()
blobs := []BlobSnapshot{makeBlobAt(42, 1, 0, 1, 0.8)}
links := []LinkState{makeLinkState("AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02", 4, 0.12, true)}
h.UpdateBlobs(blobs, links, nil, nil)
h.mu.RLock()
_, ok := h.blobHistory[42]
h.mu.RUnlock()
if !ok {
t.Error("blob 42 not found in blobHistory after UpdateBlobs")
}
}
func TestBuildWebSocketSnapshot_ContainsRequiredFields(t *testing.T) {
h := newHandler()
blobs := []BlobSnapshot{makeBlobAt(7, 2, 0, 1, 0.9)}
links := []LinkState{makeLinkState("AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02", 4, 0.15, true)}
h.UpdateBlobs(blobs, links, nil, nil)
snap := h.BuildWebSocketSnapshot(7)
if snap == nil {
t.Fatal("expected non-nil snapshot for known blob")
}
if _, ok := snap["blob_id"]; !ok {
t.Error("snapshot missing blob_id")
}
if _, ok := snap["fusion_score"]; !ok {
t.Error("snapshot missing fusion_score")
}
if _, ok := snap["per_link_contributions"]; !ok {
t.Error("snapshot missing per_link_contributions")
}
if _, ok := snap["fresnel_zones"]; !ok {
t.Error("snapshot missing fresnel_zones")
}
}
func TestBuildWebSocketSnapshot_ContributionPctNormalized(t *testing.T) {
// Two equal contributing links → each should have contribution_pct ≈ 50
h := newHandler()
blobs := []BlobSnapshot{makeBlobAt(1, 2, 0, 1, 0.8)}
links := []LinkState{
makeLinkState("AA:00:00:00:00:01", "AA:00:00:00:00:02", 4, 0.10, true),
makeLinkState("BB:00:00:00:00:01", "BB:00:00:00:00:02", 4, 0.10, true),
}
h.UpdateBlobs(blobs, links, nil, nil)
snap := h.BuildWebSocketSnapshot(1)
contribs, ok := snap["per_link_contributions"].([]map[string]interface{})
if !ok {
t.Fatalf("per_link_contributions has wrong type: %T", snap["per_link_contributions"])
}
var sumPct float64
for _, c := range contribs {
pct, _ := c["contribution_pct"].(float64)
if c["contributing"] == true {
sumPct += pct
}
}
if math.Abs(sumPct-100.0) > 0.1 {
t.Errorf("contributing link contribution_pct should sum to 100, got %f", sumPct)
}
}
func TestBuildWebSocketSnapshot_UnknownBlob_ReturnsNil(t *testing.T) {
h := newHandler()
snap := h.BuildWebSocketSnapshot(9999)
if snap != nil {
t.Error("expected nil for unknown blob ID")
}
}
func TestBuildWebSocketSnapshot_BLEMatch_Included(t *testing.T) {
h := newHandler()
blobs := []BlobSnapshot{makeBlobAt(3, 1, 0, 1, 0.85)}
triPos := [3]float64{1.1, 0.1, 1.0}
identity := map[int]*BLEMatch{
3: {
PersonID: "alice",
PersonLabel: "Alice",
PersonColor: "#4488ff",
DeviceAddr: "AA:BB:CC:DD:EE:FF",
Confidence: 0.92,
MatchMethod: "ble_rssi",
TriangulationPos: &triPos,
},
}
h.UpdateBlobs(blobs, nil, nil, identity)
snap := h.BuildWebSocketSnapshot(3)
if snap == nil {
t.Fatal("nil snapshot")
}
bleMatch, ok := snap["ble_match"]
if !ok || bleMatch == nil {
t.Error("expected ble_match field in snapshot")
}
}
// ── GetExplanationForBlob ─────────────────────────────────────────────────────
func TestGetExplanationForBlob_FoundByID(t *testing.T) {
h := newHandler()
blobs := []BlobSnapshot{makeBlobAt(55, 1, 0, 1, 0.7)}
h.UpdateBlobs(blobs, nil, nil, nil)
// Timestamp within 1 minute of now
exp := h.GetExplanationForBlob(55, 0)
// We don't assert non-nil here because the timestamp mismatch causes a fallback search.
// Instead test via the primary path:
h.mu.RLock()
stored := h.blobHistory[55]
h.mu.RUnlock()
if stored == nil {
t.Fatal("blob 55 not in history")
}
ts := stored.Timestamp
exp = h.GetExplanationForBlob(55, ts)
if exp == nil {
t.Fatal("expected non-nil explanation for correct timestamp")
}
if exp.BlobID != 55 {
t.Errorf("expected blob_id=55, got %d", exp.BlobID)
}
}
func TestGetExplanationForBlob_UnknownID_ReturnsNil(t *testing.T) {
h := newHandler()
exp := h.GetExplanationForBlob(9999, 0)
if exp != nil {
t.Error("expected nil for unknown blob ID with distant timestamp")
}
}
// ── HTTP handlers ─────────────────────────────────────────────────────────────
func registerAndServe(h *Handler, method, path string, r *http.Request) *httptest.ResponseRecorder {
router := chi.NewRouter()
h.RegisterRoutes(router)
w := httptest.NewRecorder()
router.ServeHTTP(w, r)
return w
}
func TestHTTP_ExplainBlob_KnownBlob(t *testing.T) {
h := newHandler()
blobs := []BlobSnapshot{makeBlobAt(10, 3, 0, 1, 0.75)}
links := []LinkState{makeLinkState("AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02", 4, 0.12, true)}
h.UpdateBlobs(blobs, links, nil, nil)
req := httptest.NewRequest(http.MethodGet, "/api/explain/10", nil)
w := registerAndServe(h, http.MethodGet, "/api/explain/10", req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp BlobExplanation
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if resp.BlobID != 10 {
t.Errorf("expected blob_id=10, got %d", resp.BlobID)
}
if resp.Confidence != 0.75 {
t.Errorf("expected confidence=0.75, got %f", resp.Confidence)
}
if len(resp.ContributingLinks) == 0 {
t.Error("expected contributing links in response")
}
}
func TestHTTP_ExplainBlob_UnknownBlob_ReturnsEmptyExplanation(t *testing.T) {
h := newHandler()
req := httptest.NewRequest(http.MethodGet, "/api/explain/999", nil)
w := registerAndServe(h, http.MethodGet, "/api/explain/999", req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp BlobExplanation
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if resp.BlobID != 999 {
t.Errorf("expected blob_id=999 in empty response, got %d", resp.BlobID)
}
}
func TestHTTP_ExplainBlob_InvalidID_Returns400(t *testing.T) {
h := newHandler()
req := httptest.NewRequest(http.MethodGet, "/api/explain/not-a-number", nil)
w := registerAndServe(h, http.MethodGet, "/api/explain/not-a-number", req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestHTTP_ExplainBlobAtTime_InvalidBlobID_Returns400(t *testing.T) {
h := newHandler()
req := httptest.NewRequest(http.MethodGet, "/api/explain/blob/bad/at/1000", nil)
w := registerAndServe(h, http.MethodGet, "/api/explain/blob/bad/at/1000", req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestHTTP_ExplainBlobAtTime_InvalidTimestamp_Returns400(t *testing.T) {
h := newHandler()
req := httptest.NewRequest(http.MethodGet, "/api/explain/blob/1/at/bad-ts", nil)
w := registerAndServe(h, http.MethodGet, "/api/explain/blob/1/at/bad-ts", req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
// ── ZoneDecay invariants ──────────────────────────────────────────────────────
func TestZoneDecay_InverseSquareLaw(t *testing.T) {
// For decay_rate=2, zone_decay(n) = 1/n². Verify for zones 1-5.
cases := []struct {
zone int
want float64
}{
{1, 1.0},
{2, 0.25},
{3, 1.0 / 9.0},
{4, 1.0 / 16.0},
{5, 1.0 / 25.0},
}
for _, tc := range cases {
got := 1.0 / math.Pow(float64(tc.zone), 2.0)
if math.Abs(got-tc.want) > 1e-9 {
t.Errorf("zone %d: expected %f, got %f", tc.zone, tc.want, got)
}
}
}
// ── Old history eviction ──────────────────────────────────────────────────────
func TestUpdateBlobs_EvictsOldEntries(t *testing.T) {
h := newHandler()
// Insert 110 blobs (triggers eviction at >100)
for i := 0; i < 110; i++ {
blobs := []BlobSnapshot{makeBlobAt(i, 0, 0, 0, 0.5)}
h.UpdateBlobs(blobs, nil, nil, nil)
}
h.mu.RLock()
count := len(h.blobHistory)
h.mu.RUnlock()
if count > 100 {
t.Errorf("blobHistory should be capped at 100 after eviction, got %d", count)
}
}