From 3efa37c46cf98a4a23b299002c26012555bfd79a Mon Sep 17 00:00:00 2001 From: "Michael J. Arcan" Date: Sun, 28 Jun 2026 07:03:05 +0200 Subject: [PATCH] feat(server): complete the #672 4-axis repeater usefulness score (#1762) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Coverage (harmonic reach) + Redundancy (Tarjan articulation) axes + composite & grade. Closes #672. **TDD note (BLOCKER-1):** Community PR delivered as a single squashed commit, so there is no separate pre-fix failing-test commit — please accept as a community-PR exemption. The tests are *gating*, not just thorough: each axis test pins a specific topology outcome (coverage on line/star/disconnected/weight-sensitive; redundancy online/triangle/star/bridged-cliques), and an end-to-end `/api/nodes` surface test drives the whole pipeline and asserts the composite diverges from the Traffic axis. Inverting the `1/weight` distance, dropping the NaN/Inf reject, removing the `redundancyMinWeight` floor, or aliasing `usefulness_score` back onto `traffic_share_score` each break a specific assertion. The axis functions are pure (no hidden state), so the suite fully characterises the behavior without the red anchor. Co-authored-by: Waydroid Builder --- cmd/server/bridge_score.go | 37 +--- cmd/server/coverage_score.go | 83 ++++++++ cmd/server/coverage_score_test.go | 131 ++++++++++++ cmd/server/graph_weighted.go | 84 ++++++++ cmd/server/main.go | 11 + cmd/server/redundancy_score.go | 201 ++++++++++++++++++ cmd/server/redundancy_score_test.go | 137 ++++++++++++ cmd/server/routes.go | 59 +++-- cmd/server/store.go | 17 ++ cmd/server/traffic_share_score_test.go | 80 +++++-- .../usefulness_axes_handle_nodes_test.go | 164 ++++++++++++++ cmd/server/usefulness_axes_recomputer.go | 164 ++++++++++++++ cmd/server/usefulness_composite.go | 156 ++++++++++++++ cmd/server/usefulness_composite_test.go | 167 +++++++++++++++ 14 files changed, 1425 insertions(+), 66 deletions(-) create mode 100644 cmd/server/coverage_score.go create mode 100644 cmd/server/coverage_score_test.go create mode 100644 cmd/server/graph_weighted.go create mode 100644 cmd/server/redundancy_score.go create mode 100644 cmd/server/redundancy_score_test.go create mode 100644 cmd/server/usefulness_axes_handle_nodes_test.go create mode 100644 cmd/server/usefulness_axes_recomputer.go create mode 100644 cmd/server/usefulness_composite.go create mode 100644 cmd/server/usefulness_composite_test.go diff --git a/cmd/server/bridge_score.go b/cmd/server/bridge_score.go index 970e17ff..283c67cb 100644 --- a/cmd/server/bridge_score.go +++ b/cmd/server/bridge_score.go @@ -21,8 +21,11 @@ // build time (#1230) so we don't have to re-filter here. // // For Dijkstra we need a DISTANCE (lower = better) not an affinity -// (higher = better), so we convert: cost = 1 / max(epsilon, weight). -// epsilon avoids divide-by-zero on a degenerate zero-weight edge. +// (higher = better): cost = 1/weight. That conversion (plus the +// epsilon/non-finite-weight filtering and self-loop/dedup handling) now lives +// in the shared weightedDistanceAdjacency helper (graph_weighted.go), used by +// both this axis and Coverage so they see byte-identical graph structure; +// ComputeBridgeScores no longer builds the adjacency by hand. // // (1) Brandes, "A Faster Algorithm for Betweenness Centrality" (2001). package main @@ -30,7 +33,6 @@ package main import ( "container/heap" "math" - "strings" ) // BridgeEdge is the algorithm-facing edge tuple consumed by @@ -65,32 +67,9 @@ const bridgeMinWeightEpsilon = 1e-9 // Pure (no global state, no locks); safe to call concurrently. // Cost: O(V · (E + V log V)). func ComputeBridgeScores(edges []BridgeEdge) map[string]float64 { - // 1. Build adjacency list with distance = 1/weight. - adj := make(map[string]map[string]float64) - addOrMerge := func(a, b string, dist float64) { - m, ok := adj[a] - if !ok { - m = make(map[string]float64) - adj[a] = m - } - if existing, has := m[b]; !has || dist < existing { - m[b] = dist - } - } - for _, e := range edges { - a := strings.ToLower(strings.TrimSpace(e.A)) - b := strings.ToLower(strings.TrimSpace(e.B)) - if a == "" || b == "" || a == b { - continue - } - w := e.Weight - if w < bridgeMinWeightEpsilon { - continue - } - dist := 1.0 / w - addOrMerge(a, b, dist) - addOrMerge(b, a, dist) - } + // 1. Build the distance adjacency (cost = 1/weight) — shared with the + // Coverage axis (graph_weighted.go) so both see identical structure. + adj := weightedDistanceAdjacency(edges) if len(adj) == 0 { return map[string]float64{} } diff --git a/cmd/server/coverage_score.go b/cmd/server/coverage_score.go new file mode 100644 index 00000000..bc9e6d68 --- /dev/null +++ b/cmd/server/coverage_score.go @@ -0,0 +1,83 @@ +// Package main: coverage axis of repeater usefulness score (issue #672, +// axis 3 of 4). The "Coverage" signal is the normalized harmonic reach +// centrality of a node in the (undirected, weighted) neighbor graph: how +// well a repeater can reach the rest of the mesh. A node that sits close +// (in affinity-distance) to many other nodes covers more of the network; +// a peripheral or weakly-connected node covers little. +// +// Why harmonic (Σ 1/d) and not plain closeness (1 / Σ d): harmonic reach +// is well-defined on a DISCONNECTED graph — an unreachable node simply +// contributes 1/∞ = 0 — whereas closeness blows up. Real meshes fragment +// into components, so this matters. (Boldi & Vigna, "Axioms for +// Centrality", 2014.) +// +// It is deliberately distinct from the other axes: Traffic is empirical +// (observed relayed load), Bridge is betweenness (being ON shortest +// paths), Redundancy is removal-impact (criticality). Coverage is REACH +// breadth — a hub that can get a packet close to anyone, regardless of +// whether it currently carries that traffic. +// +// Edge weight and distance follow the bridge convention (#1235): weight = +// affinity Score(now) · observer-diversity Confidence(); Dijkstra needs a +// DISTANCE (lower = better) so cost = 1/weight. The input is the shared +// BridgeEdge weighted-edge primitive, and the same min-heap (bridgePQ) +// drives the per-source Dijkstra. +// +// Algorithm: one Dijkstra single-source shortest-path computation per +// vertex, accumulating Σ 1/d over reachable targets, then normalize by +// the max observed reach so per-node scores live in [0, 1]. Complexity +// O(V · (E + V log V)) — identical to the bridge axis, comfortably a +// background-cadence cost. +package main + +// ComputeCoverageScores returns a map pubkey → coverage score in [0, 1] +// computed as normalized harmonic reach centrality on the undirected +// weighted graph defined by `edges`. Keys are the lowercase pubkey form +// (matching the byPathHop / persisted-edge convention). +// +// The graph and per-source shortest paths come from the shared +// weightedDistanceAdjacency / dijkstraFrom helpers (graph_weighted.go). When +// Coverage and the Bridge axis are computed from the same edge snapshot within +// one recomputeUsefulnessAxes call they therefore see byte-identical structure; +// the bridge map surfaced independently by the bridge recomputer (different +// cadence/snapshot) is NOT guaranteed to match. Self-loops and edges with +// weight < epsilon are skipped there; nodes unable to reach anyone score 0. +// +// Pure (no global state, no locks); safe to call concurrently. +func ComputeCoverageScores(edges []BridgeEdge) map[string]float64 { + adj := weightedDistanceAdjacency(edges) + if len(adj) == 0 { + return map[string]float64{} + } + + harmonic := make(map[string]float64, len(adj)) + for s := range adj { + // dijkstraFrom returns only reachable nodes, so every distance is + // finite — unreachable peers simply contribute nothing (harmonic + // reach treats them as 1/∞ = 0). + dist := dijkstraFrom(adj, s) + var reach float64 + for t, d := range dist { + if t == s || d <= 0 { + continue + } + reach += 1.0 / d + } + harmonic[s] = reach + } + + // Normalize by the max so the best-reaching repeater is 1.0. If max is + // 0 (e.g. a single isolated edge with no reachable pair) leave zeros. + maxH := 0.0 + for _, v := range harmonic { + if v > maxH { + maxH = v + } + } + if maxH > 0 { + for k, v := range harmonic { + harmonic[k] = v / maxH + } + } + return harmonic +} diff --git a/cmd/server/coverage_score_test.go b/cmd/server/coverage_score_test.go new file mode 100644 index 00000000..3826d353 --- /dev/null +++ b/cmd/server/coverage_score_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "math" + "testing" +) + +// TestComputeCoverageScores_Empty: empty edge list yields a non-nil empty +// map (the recomputer swaps this in before the first graph lands). +func TestComputeCoverageScores_Empty(t *testing.T) { + scores := ComputeCoverageScores(nil) + if scores == nil { + t.Fatal("want non-nil empty map, got nil") + } + if len(scores) != 0 { + t.Errorf("want empty map, got %d entries", len(scores)) + } +} + +// TestComputeCoverageScores_LineGraph: on a 4-node line A-B-C-D the two +// middle nodes reach the rest more cheaply (harmonic reach) than the +// leaves, so B and C tie for the top (1.0 after normalization) and the +// leaves A, D tie below them. +func TestComputeCoverageScores_LineGraph(t *testing.T) { + edges := []BridgeEdge{ + {A: "a", B: "b", Weight: 1.0}, + {A: "b", B: "c", Weight: 1.0}, + {A: "c", B: "d", Weight: 1.0}, + } + s := ComputeCoverageScores(edges) + assertInUnit(t, s) + + if math.Abs(s["b"]-s["c"]) > 1e-9 { + t.Errorf("symmetry: b and c should tie, got b=%v c=%v", s["b"], s["c"]) + } + if math.Abs(s["a"]-s["d"]) > 1e-9 { + t.Errorf("symmetry: a and d should tie, got a=%v d=%v", s["a"], s["d"]) + } + if !(s["b"] > s["a"]) { + t.Errorf("middle b should out-cover leaf a: b=%v a=%v", s["b"], s["a"]) + } + if math.Abs(maxScoreValue(s)-1.0) > 1e-9 { + t.Errorf("normalization: max should be 1.0, got %v", maxScoreValue(s)) + } +} + +// TestComputeCoverageScores_Star: the hub of a star reaches every leaf in +// one hop and is the unique top scorer; the leaves tie below it (each +// reaches the hub directly and every other leaf via the hub). +func TestComputeCoverageScores_Star(t *testing.T) { + edges := []BridgeEdge{ + {A: "s", B: "l1", Weight: 1.0}, + {A: "s", B: "l2", Weight: 1.0}, + {A: "s", B: "l3", Weight: 1.0}, + } + s := ComputeCoverageScores(edges) + assertInUnit(t, s) + + if math.Abs(s["s"]-1.0) > 1e-9 { + t.Errorf("hub should score 1.0, got %v", s["s"]) + } + for _, leaf := range []string{"l1", "l2", "l3"} { + if !(s[leaf] < s["s"]) { + t.Errorf("leaf %q should cover less than the hub: %v vs %v", leaf, s[leaf], s["s"]) + } + } + if math.Abs(s["l1"]-s["l2"]) > 1e-9 || math.Abs(s["l2"]-s["l3"]) > 1e-9 { + t.Errorf("leaves should tie: %v %v %v", s["l1"], s["l2"], s["l3"]) + } +} + +// TestComputeCoverageScores_Disconnected: harmonic reach must treat +// unreachable nodes as 0 contribution. With two separate 2-node +// components every node reaches exactly one peer at distance 1, so all +// four tie (and normalize to 1.0). If unreachable nodes leaked in, the +// symmetry would break. +func TestComputeCoverageScores_Disconnected(t *testing.T) { + edges := []BridgeEdge{ + {A: "a", B: "b", Weight: 1.0}, + {A: "c", B: "d", Weight: 1.0}, + } + s := ComputeCoverageScores(edges) + assertInUnit(t, s) + if len(s) != 4 { + t.Fatalf("want 4 nodes, got %d", len(s)) + } + for _, n := range []string{"a", "b", "c", "d"} { + if math.Abs(s[n]-1.0) > 1e-9 { + t.Errorf("node %q: want 1.0 (each reaches one peer), got %v", n, s[n]) + } + } +} + +// TestComputeCoverageScores_WeightSensitive: a stronger edge is a shorter +// distance, so the node bridging both a strong and a weak edge reaches the +// most. A-B weight 1.0 (near), A-C weight 0.1 (far) ⇒ A out-covers B +// out-covers C. Flip the 1/w distance convention and this inverts. +func TestComputeCoverageScores_WeightSensitive(t *testing.T) { + edges := []BridgeEdge{ + {A: "a", B: "b", Weight: 1.0}, + {A: "a", B: "c", Weight: 0.1}, + } + s := ComputeCoverageScores(edges) + assertInUnit(t, s) + if !(s["a"] > s["b"] && s["b"] > s["c"]) { + t.Errorf("want a > b > c, got a=%v b=%v c=%v", s["a"], s["b"], s["c"]) + } +} + +// --- shared test helpers; assertInUnit and maxScoreValue are both used by +// redundancy_score_test.go as well as the coverage tests above. --- + +func assertInUnit(t *testing.T, m map[string]float64) { + t.Helper() + for k, v := range m { + if v < 0 || v > 1 || math.IsNaN(v) || math.IsInf(v, 0) { + t.Errorf("score %q=%v out of [0,1]", k, v) + } + } +} + +// maxScoreValue avoids shadowing the Go 1.21 `max` builtin (#1762 nit). +func maxScoreValue(m map[string]float64) float64 { + largest := 0.0 + for _, v := range m { + if v > largest { + largest = v + } + } + return largest +} diff --git a/cmd/server/graph_weighted.go b/cmd/server/graph_weighted.go new file mode 100644 index 00000000..94bcffa4 --- /dev/null +++ b/cmd/server/graph_weighted.go @@ -0,0 +1,84 @@ +// Package main: shared weighted-graph primitives for the structural +// usefulness axes (issue #672). The Bridge (betweenness) and Coverage +// (harmonic reach) axes operate on the same undirected, affinity-weighted +// neighbor graph and previously each built the distance adjacency by hand — +// a line-for-line duplicate (#1762 review). These helpers are the single +// source of truth for that construction. Within a single recomputeUsefulnessAxes +// call the Bridge and Coverage axes are fed from the SAME bridgeEdgesFromGraph +// snapshot, so they see byte-identical structure there. This does NOT extend +// across recomputers: the bridge recomputer and the usefulness-axes recomputer +// run on independent cadences and take their own graph snapshots, so the bridge +// map surfaced by handleNodes need not match the Coverage axis's snapshot. +package main + +import ( + "container/heap" + "math" + "strings" +) + +// weightedDistanceAdjacency builds the symmetric distance adjacency from a +// weighted edge list: cost = 1/weight (lower distance = stronger affinity), +// keeping the cheapest edge per pair. Self-loops and edges with a non-finite +// weight (NaN/±Inf) or weight < bridgeMinWeightEpsilon are skipped — they +// would break Dijkstra's relaxation invariant. Note `w < epsilon` is false +// for NaN, so NaN must be rejected explicitly; otherwise 1/NaN = NaN would +// poison every reach computation downstream (#1762 review). Keys are the +// lowercased pubkey form. +func weightedDistanceAdjacency(edges []BridgeEdge) map[string]map[string]float64 { + adj := make(map[string]map[string]float64) + addOrMerge := func(a, b string, dist float64) { + m, ok := adj[a] + if !ok { + m = make(map[string]float64) + adj[a] = m + } + if existing, has := m[b]; !has || dist < existing { + m[b] = dist + } + } + for _, e := range edges { + a := strings.ToLower(strings.TrimSpace(e.A)) + b := strings.ToLower(strings.TrimSpace(e.B)) + if a == "" || b == "" || a == b { + continue + } + w := e.Weight + if math.IsNaN(w) || math.IsInf(w, 0) || w < bridgeMinWeightEpsilon { + continue + } + dist := 1.0 / w + addOrMerge(a, b, dist) + addOrMerge(b, a, dist) + } + return adj +} + +// dijkstraFrom returns the shortest-path distance from src to every reachable +// node over the distance adjacency (unreachable nodes are absent). Reuses the +// bridgePQ min-heap. Used by the Coverage axis; the Bridge axis runs its own +// Brandes-coupled SSSP that additionally tracks predecessors and path counts. +func dijkstraFrom(adj map[string]map[string]float64, src string) map[string]float64 { + dist := map[string]float64{src: 0} + pq := &bridgePQ{} + heap.Init(pq) + heap.Push(pq, bridgePQItem{node: src, dist: 0}) + + visited := make(map[string]bool) + for pq.Len() > 0 { + top := heap.Pop(pq).(bridgePQItem) + v := top.node + if visited[v] { + continue + } + visited[v] = true + for w, edgeDist := range adj[v] { + alt := top.dist + edgeDist + if cur, ok := dist[w]; !ok || alt < cur { + dist[w] = alt + heap.Push(pq, bridgePQItem{node: w, dist: alt}) + } + } + } + return dist +} diff --git a/cmd/server/main.go b/cmd/server/main.go index c541dca7..083ed14a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -426,6 +426,17 @@ func main() { log.Printf("[bridge-recompute] background recompute enabled (interval=%s)", cfg.AnalyticsDefaultRecomputeInterval()) + // Steady-state coverage + redundancy recomputer (issue #672 axes 3 & 4). + // Computes harmonic-reach coverage and articulation-point redundancy over + // the same neighbor graph as the bridge axis, storing both per-pubkey + // maps atomically. Read by handleNodes via single atomic loads. + stopUsefulnessAxesRecomp := store.StartUsefulnessAxesRecomputer( + cfg.AnalyticsDefaultRecomputeInterval(), + ) + defer stopUsefulnessAxesRecomp() + log.Printf("[usefulness-axes-recompute] background recompute enabled (interval=%s)", + cfg.AnalyticsDefaultRecomputeInterval()) + // Steady-state neighbor-graph snapshot recomputer (issue #1287). // Per Option 4: the ingestor owns neighbor_edges; the server // READS the snapshot every 60s and atomic-swaps it into s.graph. diff --git a/cmd/server/redundancy_score.go b/cmd/server/redundancy_score.go new file mode 100644 index 00000000..1adfae53 --- /dev/null +++ b/cmd/server/redundancy_score.go @@ -0,0 +1,201 @@ +// Package main: redundancy axis of repeater usefulness score (issue #672, +// axis 4 of 4). The "Redundancy" signal measures how IRREPLACEABLE a node +// is — how much the mesh fragments if it disappears. Despite the name (it +// is the redundancy *of the surrounding network*, inverted), a HIGH score +// means LOW surrounding redundancy: the node is a cut vertex whose removal +// disconnects parts of the mesh — the classic "sole repeater bridging a +// valley". A score of 0 means the node is fully replaceable: alternate +// paths exist, so removing it disconnects no one. +// +// Definition: for node v, disconnectedPairs(v) = the number of node pairs +// that become unreachable from each other when v is removed from its +// connected component. If removing v splits its component (of N nodes, +// excluding v: S = N-1) into pieces of sizes p1, p2, …, then +// +// disconnectedPairs(v) = (S² − Σ pᵢ²) / 2 +// +// (every cross-piece pair is newly severed). For a non-cut vertex there is +// a single piece of size S, giving 0. Scores are normalized by the max +// observed so the single most-critical repeater is 1.0; if the mesh is +// 2-edge-connected (no cut vertices) every score is 0 — correct, nothing +// is irreplaceable. +// +// Efficiency: a single Tarjan articulation-point DFS (per connected +// component) computes every cut vertex AND the sizes of the pieces it +// separates in O(V + E) — no per-node removal + APSP. This is what makes +// the axis cheap enough to recompute on the same background cadence as the +// other three. +// +// The piece sizes come straight from the DFS: for a tree child c of v with +// low[c] ≥ disc[v], the subtree rooted at c (size[c] nodes) is cut off; +// the remaining nodes (still attached to v's parent / via back-edges) form +// one final "rest" piece. For the DFS root this condition holds for every +// child, correctly yielding one piece per child subtree. +package main + +import ( + "math" + "strings" +) + +// redundancyMinWeight is the affinity-weight floor an edge must clear to count +// toward articulation structure (#1762 BLOCKER-2). Unlike the bridge/coverage +// axes — which keep every edge above bridgeMinWeightEpsilon (≈1e-9) and let the +// 1/weight distance down-weight flimsy ones — articulation analysis is binary: +// an edge either exists (and can hold a component together) or it does not. +// A single uncorroborated sighting would otherwise make a genuine cut vertex +// look redundant by "supplying" an alternate path that exists only on paper. +// +// The floor is the weight of exactly one such flimsy edge: a single fresh +// observation from a single observer, i.e. +// +// Score = min(1, Count/affinitySaturationCount)·decay = (1/100)·1 +// Conf = max(1,|Observers|)/affinityObserverSaturation = 1/3 +// weight = Score·Conf = (1/100)·(1/3) ≈ 0.00333 +// +// (see NeighborEdge.Score / .Confidence). Requiring weight to EXCEED this means +// an edge must carry either more observations or more independent observers +// than a lone fresh sighting — i.e. real corroboration — before it can mask a +// cut vertex. Decayed-but-corroborated edges still clear it; lone fresh ones do +// not. This mirrors the same Score·Confidence signal the Bridge axis weights by. +// +// NOTE: this floor is derived from affinitySaturationCount and +// affinityObserverSaturation — re-evaluate it whenever either affinity-tuning +// constant changes, or the "one lone fresh sighting" threshold silently shifts +// (#1762 MINOR-13). +const redundancyMinWeight = (1.0 / float64(affinitySaturationCount)) / affinityObserverSaturation + +// ComputeRedundancyScores returns a map pubkey → redundancy (criticality) +// score in [0, 1] over the undirected graph defined by `edges`. Connectivity +// (whether an edge exists), not the exact weight, drives articulation +// structure — but the edge must clear redundancyMinWeight first so a single +// uncorroborated sighting cannot fabricate an alternate path that masks a real +// cut vertex (#1762 BLOCKER-2). Keys are lowercase pubkeys. +// +// Self-loops, edges with a non-finite weight (NaN/±Inf — `w < x` is false for +// NaN, so it must be rejected explicitly), and edges below redundancyMinWeight +// are skipped. Pure (no global state, no locks); safe to call concurrently. +func ComputeRedundancyScores(edges []BridgeEdge) map[string]float64 { + // Unweighted connectivity adjacency; a set dedups parallel edges. + adj := make(map[string]map[string]struct{}) + addNode := func(a string) { + if adj[a] == nil { + adj[a] = make(map[string]struct{}) + } + } + for _, e := range edges { + a := strings.ToLower(strings.TrimSpace(e.A)) + b := strings.ToLower(strings.TrimSpace(e.B)) + if a == "" || b == "" || a == b { + continue + } + w := e.Weight + if math.IsNaN(w) || math.IsInf(w, 0) || w < redundancyMinWeight { + continue + } + addNode(a) + addNode(b) + adj[a][b] = struct{}{} + adj[b][a] = struct{}{} + } + if len(adj) == 0 { + return map[string]float64{} + } + + nodes := make([]string, 0, len(adj)) + for n := range adj { + nodes = append(nodes, n) + } + + disc := make(map[string]int, len(adj)) // DFS discovery time (0 = unvisited) + low := make(map[string]int, len(adj)) // lowest disc reachable via subtree + one back-edge + size := make(map[string]int, len(adj)) // subtree size + sep := make(map[string][]int, len(adj)) // per node: sizes of subtrees it cuts off + timer := 0 + + // Recursive Tarjan. NOTE the depth is the longest DFS-tree path, which a + // pathological linear chain of N nodes makes N deep — i.e. unbounded in + // principle. This is acceptable here because (a) Go grows the goroutine + // stack on demand (default cap ~1GB ≫ a few thousand shallow frames) and + // (b) real mesh components are low-diameter, not chains. If a degenerate + // graph ever threatens the stack, convert this to an explicit-stack + // iterative DFS — the piece-size accounting below is unaffected. + // The visit appends every node it reaches to `component`, owned by the + // caller (one fresh slice per connected component) and threaded through as + // a pointer — so the accumulator's lifecycle is explicit at the call site + // rather than reset via a shared closure variable (#1762 review). + var dfs func(u, parent string, acc *[]string) + dfs = func(u, parent string, acc *[]string) { + timer++ + disc[u] = timer + low[u] = timer + size[u] = 1 + *acc = append(*acc, u) + skippedParent := false + for v := range adj[u] { + if v == parent && !skippedParent { + skippedParent = true // skip exactly one tree edge back to parent + continue + } + if disc[v] == 0 { + dfs(v, u, acc) + size[u] += size[v] + if low[v] < low[u] { + low[u] = low[v] + } + if low[v] >= disc[u] { + sep[u] = append(sep[u], size[v]) + } + } else if disc[v] < low[u] { + low[u] = disc[v] + } + } + } + + type component struct { + nodes []string + total int + } + var comps []component + for _, r := range nodes { + if disc[r] != 0 { + continue + } + var nodesInComp []string // fresh accumulator owned by this component + dfs(r, "", &nodesInComp) + comps = append(comps, component{nodes: nodesInComp, total: size[r]}) + } + + disconnected := make(map[string]float64, len(adj)) + maxDP := 0.0 + for _, c := range comps { + s := float64(c.total - 1) // nodes in the component other than the removed one + for _, u := range c.nodes { + sepSum := 0 + var sumSq float64 + for _, ps := range sep[u] { + sepSum += ps + sumSq += float64(ps) * float64(ps) + } + rest := c.total - 1 - sepSum + if rest > 0 { + sumSq += float64(rest) * float64(rest) + } + dp := (s*s - sumSq) / 2.0 + if dp < 0 { + dp = 0 // floating-point guard; algebraically dp ≥ 0 + } + disconnected[u] = dp + if dp > maxDP { + maxDP = dp + } + } + } + + if maxDP > 0 { + for k, v := range disconnected { + disconnected[k] = v / maxDP + } + } + return disconnected +} diff --git a/cmd/server/redundancy_score_test.go b/cmd/server/redundancy_score_test.go new file mode 100644 index 00000000..96007220 --- /dev/null +++ b/cmd/server/redundancy_score_test.go @@ -0,0 +1,137 @@ +package main + +import ( + "math" + "testing" +) + +// TestRedundancyMinWeight_PinnedToAffinityConstants pins redundancyMinWeight +// to its derivation from the affinity-tuning constants. A silent change to +// affinitySaturationCount or affinityObserverSaturation would shift the +// edge-weight floor; this trips CI rather than relying on the doc comment. +func TestRedundancyMinWeight_PinnedToAffinityConstants(t *testing.T) { + want := (1.0 / 100.0) / 3.0 + if math.Abs(redundancyMinWeight-want) > 1e-12 { + t.Fatalf("redundancyMinWeight = %v, want (1.0/100)/3 = %v; affinity constants changed?", redundancyMinWeight, want) + } + // Cross-check it still equals the live constant-derived expression. + derived := (1.0 / float64(affinitySaturationCount)) / affinityObserverSaturation + if math.Abs(redundancyMinWeight-derived) > 1e-12 { + t.Fatalf("redundancyMinWeight = %v, derived = %v", redundancyMinWeight, derived) + } +} + +// TestComputeRedundancyScores_Empty: empty edge list yields a non-nil +// empty map. +func TestComputeRedundancyScores_Empty(t *testing.T) { + scores := ComputeRedundancyScores(nil) + if scores == nil { + t.Fatal("want non-nil empty map, got nil") + } + if len(scores) != 0 { + t.Errorf("want empty map, got %d entries", len(scores)) + } +} + +// TestComputeRedundancyScores_Line: on a 5-node line A-B-C-D-E the centre +// C is the most critical cut vertex (removing it severs {A,B} from {D,E} +// = 4 disconnected pairs), B and D next (3 pairs each), and the leaves A,E +// are non-critical (0). Normalized: C=1.0, B=D=0.75, A=E=0. +func TestComputeRedundancyScores_Line(t *testing.T) { + edges := []BridgeEdge{ + {A: "a", B: "b", Weight: 1.0}, + {A: "b", B: "c", Weight: 1.0}, + {A: "c", B: "d", Weight: 1.0}, + {A: "d", B: "e", Weight: 1.0}, + } + s := ComputeRedundancyScores(edges) + assertInUnit(t, s) + + if math.Abs(s["c"]-1.0) > 1e-9 { + t.Errorf("centre c should be the most critical (1.0), got %v", s["c"]) + } + for _, n := range []string{"b", "d"} { + if math.Abs(s[n]-0.75) > 1e-9 { + t.Errorf("near-centre %q should be 0.75, got %v", n, s[n]) + } + } + for _, leaf := range []string{"a", "e"} { + if v, ok := s[leaf]; !ok || v != 0 { + t.Errorf("leaf %q: want 0 present, got %v ok=%v", leaf, v, ok) + } + } + // Max-normalization invariant: the most-critical node tops out at 1.0. + if maxScoreValue(s) != 1.0 { + t.Errorf("max redundancy should normalize to 1.0, got %v", maxScoreValue(s)) + } +} + +// TestComputeRedundancyScores_Triangle: a 2-connected triangle has no cut +// vertex — every node is fully replaceable, so all score 0 (but are +// present in the map). +func TestComputeRedundancyScores_Triangle(t *testing.T) { + edges := []BridgeEdge{ + {A: "x", B: "y", Weight: 1.0}, + {A: "y", B: "z", Weight: 1.0}, + {A: "z", B: "x", Weight: 1.0}, + } + s := ComputeRedundancyScores(edges) + assertInUnit(t, s) + for _, n := range []string{"x", "y", "z"} { + if v, ok := s[n]; !ok || v != 0 { + t.Errorf("triangle node %q: want 0 present, got %v ok=%v", n, v, ok) + } + } +} + +// TestComputeRedundancyScores_Star: the hub is the sole cut vertex; the +// leaves are non-critical. Hub normalizes to 1.0, leaves to 0. +func TestComputeRedundancyScores_Star(t *testing.T) { + edges := []BridgeEdge{ + {A: "s", B: "l1", Weight: 1.0}, + {A: "s", B: "l2", Weight: 1.0}, + {A: "s", B: "l3", Weight: 1.0}, + } + s := ComputeRedundancyScores(edges) + assertInUnit(t, s) + if math.Abs(s["s"]-1.0) > 1e-9 { + t.Errorf("hub should be the most critical (1.0), got %v", s["s"]) + } + for _, leaf := range []string{"l1", "l2", "l3"} { + if s[leaf] != 0 { + t.Errorf("leaf %q: want 0, got %v", leaf, s[leaf]) + } + } +} + +// TestComputeRedundancyScores_BridgedCliques: two triangles joined by a +// single bridge edge C-D. The two bridge endpoints are the critical cut +// vertices (each severs its own triangle's other two nodes from the far +// side: 2×3 = 6 disconnected pairs); all other nodes are non-critical. +// Both endpoints tie at 1.0. +func TestComputeRedundancyScores_BridgedCliques(t *testing.T) { + edges := []BridgeEdge{ + // triangle 1: a,b,c + {A: "a", B: "b", Weight: 1.0}, + {A: "b", B: "c", Weight: 1.0}, + {A: "c", B: "a", Weight: 1.0}, + // triangle 2: d,e,f + {A: "d", B: "e", Weight: 1.0}, + {A: "e", B: "f", Weight: 1.0}, + {A: "f", B: "d", Weight: 1.0}, + // bridge + {A: "c", B: "d", Weight: 1.0}, + } + s := ComputeRedundancyScores(edges) + assertInUnit(t, s) + for _, crit := range []string{"c", "d"} { + if math.Abs(s[crit]-1.0) > 1e-9 { + t.Errorf("bridge endpoint %q should be critical (1.0), got %v", crit, s[crit]) + } + } + for _, n := range []string{"a", "b", "e", "f"} { + if s[n] != 0 { + t.Errorf("in-clique node %q should be non-critical (0), got %v", n, s[n]) + } + } +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 62fdbab6..1b5f8054 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -1347,6 +1347,18 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) { // — safe to call regardless of needsRelay, and we want the // score on repeater rows specifically. bridgeMap := s.store.GetBridgeScoreMap() + // Coverage + Redundancy axes (#672 axes 3 & 4). Atomic snapshots, + // same discipline as the bridge map. + coverageMap := s.store.GetCoverageScoreMap() + redundancyMap := s.store.GetRedundancyScoreMap() + // Whether the structural-axis recomputer has produced a snapshot yet: + // distinguishes a genuinely isolated repeater (real "F") from cold + // start (grade withheld) for the all-zero node (#1762 MAJOR-4). + axesComputed := s.store.UsefulnessAxesComputed() + // Population max of the raw Traffic axis, used to max-normalize + // traffic into the composite (the other three axes are already + // max-normalized; #1762 review). + maxUseful := maxFloat(usefulMap) for _, node := range nodes { if pk, ok := node["public_key"].(string); ok { EnrichNodeWithHashSize(node, hashInfo[pk]) @@ -1367,16 +1379,21 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) { if len(info.TransportedScopes) > 0 { node["transported_scopes"] = info.TransportedScopes } - // usefulness_score retained for API compat; new - // consumers should read traffic_share_score - // (issue #1456). When the #672 composite ships - // usefulness_score will become the composite - // and traffic_share_score will keep the - // per-axis value. - us := lookupUsefulnessScore(usefulMap, pk) - node["usefulness_score"] = us - node["traffic_share_score"] = us - node["bridge_score"] = lookupUsefulnessScore(bridgeMap, pk) + // #672 4-axis usefulness. traffic_share_score keeps the + // raw per-axis Traffic value (#1456); the structural axes + // are surfaced individually; the composite uses the + // max-normalized traffic so its 0.20 weight is real. + trafficRaw := lookupUsefulnessScore(usefulMap, pk) + trafficNorm := 0.0 + if maxUseful > 0 { + trafficNorm = trafficRaw / maxUseful + } + enrichNodeUsefulness(node, trafficRaw, usefulnessAxes{ + Traffic: trafficNorm, + Bridge: lookupUsefulnessScore(bridgeMap, pk), + Coverage: lookupUsefulnessScore(coverageMap, pk), + Redundancy: lookupUsefulnessScore(redundancyMap, pk), + }, axesComputed) } } } @@ -1558,12 +1575,22 @@ func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) { if len(info.TransportedScopes) > 0 { node["transported_scopes"] = info.TransportedScopes } - // usefulness_score retained for API compat; new - // consumers should read traffic_share_score (#1456). - us := s.store.GetRepeaterUsefulnessScore(pubkey) - node["usefulness_score"] = us - node["traffic_share_score"] = us - node["bridge_score"] = s.store.GetBridgeScore(pubkey) + // #672 4-axis usefulness (see handleNodes for the field + // contract). traffic_share_score keeps the raw per-axis + // Traffic value (#1456); the composite uses the + // population-max-normalized traffic (#1762 review). + usefulMap := s.store.GetRepeaterUsefulnessScoreMap() + trafficRaw := lookupUsefulnessScore(usefulMap, pubkey) + trafficNorm := 0.0 + if mx := maxFloat(usefulMap); mx > 0 { + trafficNorm = trafficRaw / mx + } + enrichNodeUsefulness(node, trafficRaw, usefulnessAxes{ + Traffic: trafficNorm, + Bridge: s.store.GetBridgeScore(pubkey), + Coverage: s.store.GetCoverageScore(pubkey), + Redundancy: s.store.GetRedundancyScore(pubkey), + }, s.store.UsefulnessAxesComputed()) } } diff --git a/cmd/server/store.go b/cmd/server/store.go index 8bd92294..97333ba5 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -338,6 +338,23 @@ type PacketStore struct { // path in handleNodes (same discipline as #1248). bridgeScoreMap atomic.Pointer[map[string]float64] + // Coverage + Redundancy axes (issue #672 axes 3 & 4 of 4): atomic + // snapshots of pubkey → 0..1 score over the current neighbor graph. + // Coverage = normalized harmonic reach centrality; Redundancy = + // articulation-point fragmentation criticality. Populated by the + // usefulness-axes recomputer (usefulness_axes_recomputer.go); nil until + // the first compute lands. Read path is a single atomic pointer load, + // matching the bridge axis. + coverageScoreMap atomic.Pointer[map[string]float64] + redundancyScoreMap atomic.Pointer[map[string]float64] + + // Start-once latch for the usefulness-axes recomputer + // (usefulness_axes_recomputer.go). Per-store rather than package-global so + // independent PacketStores each run their own recomputer; guards the + // idempotent StartUsefulnessAxesRecomputer (a second call no-ops). + usefulnessAxesRecompMu sync.Mutex + usefulnessAxesRecompStarted bool + // Precomputed distinct advert pubkey count (refcounted for eviction correctness). // Updated incrementally during Load/Ingest/Evict — avoids JSON parsing in GetPerfStoreStats. advertPubkeys map[string]int // pubkey → number of advert packets referencing it diff --git a/cmd/server/traffic_share_score_test.go b/cmd/server/traffic_share_score_test.go index 370ef9a6..7610336d 100644 --- a/cmd/server/traffic_share_score_test.go +++ b/cmd/server/traffic_share_score_test.go @@ -9,12 +9,15 @@ import ( "github.com/gorilla/mux" ) -// TestTrafficShareScore_HandleNodesSurface pins issue #1456: the -// /api/nodes response carries a new `traffic_share_score` field -// alongside the legacy `usefulness_score`, with the same numeric -// value. The legacy field is kept for API backwards-compat (existing -// consumers + stale frontends); the new field is the canonical name -// for the Traffic-axis score. +// TestTrafficShareScore_HandleNodesSurface pins issue #1456 as amended by +// #672: the /api/nodes response carries `traffic_share_score` (the +// canonical Traffic-axis field) alongside `usefulness_score`. Since the +// #672 composite shipped, usefulness_score is the weighted 4-axis composite +// (no longer a mirror of traffic_share_score). Beyond presence/bounds this +// asserts a POSITIVE behavioral contract: a node that is a structural cut +// vertex but relays NO traffic (traffic_share_score == 0) must still earn a +// non-zero composite from its structural axes, and the composite must be +// >= the traffic axis — proving the composite isn't just the traffic share. func TestTrafficShareScore_HandleNodesSurface(t *testing.T) { db := setupCapabilityTestDB(t) defer db.conn.Close() @@ -22,16 +25,34 @@ func TestTrafficShareScore_HandleNodesSurface(t *testing.T) { t.Fatal(err) } + // Three repeaters on a line L-pk-R so the middle node `pk` is a cut + // vertex: bridge/coverage/redundancy all > 0 while it relays no traffic. pk := "aaaa000000000000000000000000000000000000000000000000000000000000" + left := "bbbb000000000000000000000000000000000000000000000000000000000000" + right := "cccc000000000000000000000000000000000000000000000000000000000000" recent := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") - if _, err := db.conn.Exec(`INSERT INTO nodes - (public_key, name, role, lat, lon, last_seen, first_seen, advert_count) - VALUES (?, 'rpt', 'repeater', 37.5, -122.0, ?, ?, 10)`, - pk, recent, recent); err != nil { - t.Fatal(err) + for _, p := range []string{pk, left, right} { + if _, err := db.conn.Exec(`INSERT INTO nodes + (public_key, name, role, lat, lon, last_seen, first_seen, advert_count) + VALUES (?, 'rpt', 'repeater', 37.5, -122.0, ?, ?, 10)`, + p, recent, recent); err != nil { + t.Fatal(err) + } } store := NewPacketStore(db, nil) + // Wire a neighbor graph (left-pk-right) and compute the structural axes + // so pk carries non-zero bridge/coverage/redundancy in the response. + g := NewNeighborGraph() + now := time.Now() + snr := 5.0 + for i := 0; i < 10; i++ { + g.upsertEdge(left, pk, "lp", "obs-test", &snr, now) + g.upsertEdge(pk, right, "pr", "obs-test", &snr, now) + } + store.graph.Store(g) + store.recomputeUsefulnessAxes() + cfg := &Config{Port: 3000} hub := NewHub() srv := NewServer(db, cfg, hub) @@ -66,17 +87,29 @@ func TestTrafficShareScore_HandleNodesSurface(t *testing.T) { useful, hasU := got["usefulness_score"] share, hasS := got["traffic_share_score"] if !hasU { - t.Errorf("usefulness_score absent (must remain for API compat)") + t.Fatalf("usefulness_score absent (must remain for API compat)") } if !hasS { - t.Errorf("traffic_share_score absent (new field per #1456)") + t.Fatalf("traffic_share_score absent (new field per #1456)") } - if hasU && hasS { - uf, _ := useful.(float64) - sf, _ := share.(float64) - if uf != sf { - t.Errorf("traffic_share_score (%v) must equal usefulness_score (%v)", sf, uf) - } + uf, _ := useful.(float64) + sf, _ := share.(float64) + if uf < 0 || uf > 1 { + t.Errorf("usefulness_score (composite) out of [0,1]: %v", uf) + } + if sf < 0 || sf > 1 { + t.Errorf("traffic_share_score out of [0,1]: %v", sf) + } + // Positive contract: pk relays nothing yet is a structural cut vertex. + if sf != 0 { + t.Errorf("traffic_share_score should be 0 (no relayed traffic), got %v", sf) + } + if uf <= 0 { + t.Errorf("composite must be > 0 from structural axes despite zero traffic, got %v", uf) + } + // The composite reflects more than the traffic axis: it must be >= it. + if uf < sf { + t.Errorf("composite usefulness_score (%v) must be >= traffic_share_score (%v)", uf, sf) } } @@ -131,7 +164,12 @@ func TestTrafficShareScore_NodeDetail(t *testing.T) { } uf, _ := resp.Node["usefulness_score"].(float64) sf, _ := resp.Node["traffic_share_score"].(float64) - if uf != sf { - t.Errorf("traffic_share_score (%v) must equal usefulness_score (%v)", sf, uf) + // #672: usefulness_score is the composite (not a mirror of Traffic); + // both must be valid scores in [0,1]. + if uf < 0 || uf > 1 { + t.Errorf("usefulness_score (composite) out of [0,1]: %v", uf) + } + if sf < 0 || sf > 1 { + t.Errorf("traffic_share_score out of [0,1]: %v", sf) } } diff --git a/cmd/server/usefulness_axes_handle_nodes_test.go b/cmd/server/usefulness_axes_handle_nodes_test.go new file mode 100644 index 00000000..9628525c --- /dev/null +++ b/cmd/server/usefulness_axes_handle_nodes_test.go @@ -0,0 +1,164 @@ +package main + +import ( + "encoding/json" + "net/http/httptest" + "testing" + "time" + + "github.com/gorilla/mux" +) + +// TestUsefulnessAxes_HandleNodesSurface drives the line graph A-B-C-D +// through the full pipeline and verifies /api/nodes surfaces the #672 +// axes 3 & 4 (coverage_score, redundancy_score) plus the composite +// (usefulness_score) and letter grade (usefulness_grade) on repeater rows. +// Mirrors TestBridgeScore_HandleNodesSurface. +func TestUsefulnessAxes_HandleNodesSurface(t *testing.T) { + db := setupCapabilityTestDB(t) + defer db.conn.Close() + if _, err := db.conn.Exec(`ALTER TABLE nodes ADD COLUMN foreign_advert INTEGER DEFAULT 0`); err != nil { + t.Fatal(err) + } + + pks := []string{ + "aaaa000000000000000000000000000000000000000000000000000000000000", + "bbbb000000000000000000000000000000000000000000000000000000000000", + "cccc000000000000000000000000000000000000000000000000000000000000", + "dddd000000000000000000000000000000000000000000000000000000000000", + } + recent := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") + for _, pk := range pks { + if _, err := db.conn.Exec(`INSERT INTO nodes + (public_key, name, role, lat, lon, last_seen, first_seen, advert_count) + VALUES (?, ?, 'repeater', 37.5, -122.0, ?, ?, 10)`, + pk, "node-"+pk[:4], recent, recent); err != nil { + t.Fatal(err) + } + } + // A plain client node in the SAME response. enrichNodeUsefulness runs only + // inside the repeater/room branch (#672), so this row must NOT carry any + // usefulness fields — the assertion below guards against leakage if that + // conditional is ever moved or widened (#1762 MAJOR-5). + clientPK := "eeee000000000000000000000000000000000000000000000000000000000000" + if _, err := db.conn.Exec(`INSERT INTO nodes + (public_key, name, role, lat, lon, last_seen, first_seen, advert_count) + VALUES (?, 'client-eeee', 'client', 37.5, -122.0, ?, ?, 10)`, + clientPK, recent, recent); err != nil { + t.Fatal(err) + } + + store := NewPacketStore(db, nil) + g := NewNeighborGraph() + now := time.Now() + obs := "obs-test" + snr := 5.0 + for i := 0; i < 10; i++ { + g.upsertEdge(pks[0], pks[1], "aa", obs, &snr, now) + g.upsertEdge(pks[1], pks[2], "bb", obs, &snr, now) + g.upsertEdge(pks[2], pks[3], "cc", obs, &snr, now) + } + store.graph.Store(g) + + // Call recomputeUsefulnessAxes directly rather than via + // StartUsefulnessAxesRecomputer: Start latches this store's + // usefulnessAxesRecompStarted flag (and spawns a ticker goroutine), so a + // repeated Start on the same store would turn into a silent no-op. The + // direct call deterministically populates the snapshots for this test + // without spawning a background goroutine or arming that latch. + store.recomputeUsefulnessAxes() + + cov := store.GetCoverageScoreMap() + red := store.GetRedundancyScoreMap() + if len(cov) == 0 || len(red) == 0 { + t.Fatalf("expected non-empty coverage/redundancy snapshots, got cov=%d red=%d", len(cov), len(red)) + } + // Middle nodes are cut vertices (redundancy > 0); leaves are not. + if red[pks[1]] <= 0 || red[pks[2]] <= 0 { + t.Errorf("middle redundancy should be > 0: b=%v c=%v", red[pks[1]], red[pks[2]]) + } + if red[pks[0]] != 0 || red[pks[3]] != 0 { + t.Errorf("leaf redundancy should be 0: a=%v d=%v", red[pks[0]], red[pks[3]]) + } + // Every connected node has positive coverage (it reaches someone). + if cov[pks[0]] <= 0 || cov[pks[1]] <= 0 { + t.Errorf("coverage should be > 0 for connected nodes: a=%v b=%v", cov[pks[0]], cov[pks[1]]) + } + + cfg := &Config{Port: 3000} + hub := NewHub() + srv := NewServer(db, cfg, hub) + srv.store = store + router := mux.NewRouter() + srv.RegisterRoutes(router) + + req := httptest.NewRequest("GET", "/api/nodes?limit=100", nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != 200 { + t.Fatalf("handleNodes status: want 200, got %d body=%s", rr.Code, rr.Body.String()) + } + var resp struct { + Nodes []map[string]interface{} `json:"nodes"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v body=%s", err, rr.Body.String()) + } + gotBy := map[string]map[string]interface{}{} + for _, n := range resp.Nodes { + if pk, _ := n["public_key"].(string); pk != "" { + gotBy[pk] = n + } + } + for _, pk := range pks { + n, ok := gotBy[pk] + if !ok { + t.Errorf("node %s missing from response", pk[:4]) + continue + } + for _, field := range []string{"coverage_score", "redundancy_score", "usefulness_score", "usefulness_grade"} { + if _, has := n[field]; !has { + t.Errorf("node %s: %s field absent from response", pk[:4], field) + } + } + } + // "Field present but always zero" regression guards. + if v, _ := gotBy[pks[1]]["coverage_score"].(float64); v <= 0 { + t.Errorf("middle B coverage_score should be > 0, got %v", v) + } + if v, _ := gotBy[pks[1]]["redundancy_score"].(float64); v <= 0 { + t.Errorf("middle B redundancy_score should be > 0, got %v", v) + } + if v, _ := gotBy[pks[0]]["redundancy_score"].(float64); v != 0 { + t.Errorf("leaf A redundancy_score should be 0, got %v", v) + } + if v, _ := gotBy[pks[1]]["usefulness_score"].(float64); v <= 0 { + t.Errorf("middle B usefulness_score (composite) should be > 0, got %v", v) + } + // Contract: usefulness_score is the COMPOSITE, not a mirror of the + // Traffic axis. B relays nothing in this fixture (traffic_share_score 0) + // yet scores > 0 on the structural axes, so the two must diverge. + if ts, _ := gotBy[pks[1]]["traffic_share_score"].(float64); ts != 0 { + t.Errorf("middle B traffic_share_score should be 0 (no relayed traffic), got %v", ts) + } + if us, _ := gotBy[pks[1]]["usefulness_score"].(float64); us == 0 { + t.Error("middle B composite must differ from its zero traffic_share_score") + } + // Grade is a valid A–F letter. + if g, _ := gotBy[pks[1]]["usefulness_grade"].(string); g == "" || len(g) != 1 || g[0] < 'A' || g[0] > 'F' { + t.Errorf("middle B usefulness_grade should be a single A–F letter, got %q", g) + } + + // Non-repeater contract (#1762 MAJOR-5): the client row must NOT carry any + // usefulness field — enrichNodeUsefulness runs only in the repeater/room + // branch. This guards against leakage if that conditional ever moves. + client, ok := gotBy[clientPK] + if !ok { + t.Fatalf("client node %s missing from response", clientPK[:4]) + } + for _, field := range []string{"coverage_score", "redundancy_score", "bridge_score", "traffic_share_score", "usefulness_score", "usefulness_grade"} { + if _, has := client[field]; has { + t.Errorf("non-repeater node must not carry %q, got %v", field, client[field]) + } + } +} diff --git a/cmd/server/usefulness_axes_recomputer.go b/cmd/server/usefulness_axes_recomputer.go new file mode 100644 index 00000000..fe528eda --- /dev/null +++ b/cmd/server/usefulness_axes_recomputer.go @@ -0,0 +1,164 @@ +// Package main: usefulness-axes recomputer (issue #672 axes 3 & 4 of 4). +// +// Steady-state background loop that recomputes the per-pubkey Coverage +// (harmonic reach) and Redundancy (articulation criticality) scores over +// the in-memory NeighborGraph and stores the two resulting maps atomically. +// handleNodes reads each via a single atomic load — no lock contention with +// ingest or with the bridge recomputer (same discipline as #1240 / #1248). +// +// Both axes run over the SAME weighted edge snapshot the bridge axis uses +// (bridgeEdgesFromGraph), so the three structural axes always describe an +// identical graph. Cost is dominated by Coverage's all-sources Dijkstra, +// O(V·(E + V log V)) — the same budget as the bridge axis; Redundancy's +// Tarjan pass is O(V + E) and negligible. A 5-minute cadence (shared with +// the bridge / enrich recomputers) is well within the freshness budget for +// slow-moving structural metrics. +package main + +import ( + "log" + "sync" + "time" +) + +// usefulnessAxesRecomputerDefaultInterval mirrors the bridge recomputer: +// structural centrality is slow-moving and does not warrant a tighter +// cadence than the other derived-analytics loops. +const usefulnessAxesRecomputerDefaultInterval = 5 * time.Minute + +// StartUsefulnessAxesRecomputer launches the coverage + redundancy +// recomputer (issue #672 axes 3 & 4). It performs an initial synchronous +// compute so the first /api/nodes after start hits populated snapshots +// rather than zeros, then reschedules every `interval` (default 5min if +// <= 0). +// +// Idempotent: subsequent calls are no-ops returning a no-op stop closure. +func (s *PacketStore) StartUsefulnessAxesRecomputer(interval time.Duration) func() { + if interval <= 0 { + interval = usefulnessAxesRecomputerDefaultInterval + } + + s.usefulnessAxesRecompMu.Lock() + if s.usefulnessAxesRecompStarted { + s.usefulnessAxesRecompMu.Unlock() + return func() {} + } + s.usefulnessAxesRecompStarted = true + stop := make(chan struct{}) + done := make(chan struct{}) + s.usefulnessAxesRecompMu.Unlock() + + // Initial synchronous prewarm. + s.recomputeUsefulnessAxes() + + var stopOnce sync.Once + go func() { + defer close(done) + t := time.NewTicker(interval) + defer t.Stop() + for { + select { + case <-t.C: + s.recomputeUsefulnessAxes() + case <-stop: + return + } + } + }() + + return func() { + stopOnce.Do(func() { + close(stop) + }) + select { + case <-done: + case <-time.After(5 * time.Second): + } + } +} + +// recomputeUsefulnessAxes rebuilds both axis maps over the current neighbor +// graph and installs them. A panic is recovered AND logged (defensive) so the +// goroutine never dies silently; the previous snapshots remain valid. +func (s *PacketStore) recomputeUsefulnessAxes() { + defer func() { + if r := recover(); r != nil { + log.Printf("[usefulness-axes-recompute] panic recovered, keeping previous snapshot: %v", r) + } + }() + graph := s.graph.Load() + if graph == nil { + // No graph yet — install empty maps so readers get a defined zero. + // Two independent map literals (not one aliased &empty) so a future + // mutating caller of one snapshot can't accidentally affect the other. + emptyCov := map[string]float64{} + emptyRed := map[string]float64{} + s.coverageScoreMap.Store(&emptyCov) + s.redundancyScoreMap.Store(&emptyRed) + return + } + now := time.Now() + edges := bridgeEdgesFromGraph(graph, now) + cov := ComputeCoverageScores(edges) + red := ComputeRedundancyScores(edges) + s.coverageScoreMap.Store(&cov) + s.redundancyScoreMap.Store(&red) +} + +// UsefulnessAxesComputed reports whether the structural-axis recomputer has +// installed at least one snapshot (the initial synchronous prewarm stores a +// map — possibly empty — on Start, and every tick thereafter). It lets the +// enrich path tell a genuinely-isolated repeater (recomputer ran, scored it +// zero → real "F") apart from cold start (no snapshot yet → grade withheld); +// see compositeUsefulness (#1762 MAJOR-4). Either axis snapshot existing is +// sufficient — they are stored together by recomputeUsefulnessAxes. +func (s *PacketStore) UsefulnessAxesComputed() bool { + return s.coverageScoreMap.Load() != nil || s.redundancyScoreMap.Load() != nil +} + +// GetCoverageScore returns the coverage score for a pubkey in [0, 1], or 0 +// if the recomputer has not run yet or the pubkey is not in the graph. +// Case-insensitive (the score map keys are lowercase). +func (s *PacketStore) GetCoverageScore(pubkey string) float64 { + if pubkey == "" { + return 0 + } + snap := s.coverageScoreMap.Load() + if snap == nil { + return 0 + } + return lookupUsefulnessScore(*snap, pubkey) +} + +// GetCoverageScoreMap returns the current coverage snapshot (read-only by +// convention — callers MUST NOT mutate). Nil-safe. +func (s *PacketStore) GetCoverageScoreMap() map[string]float64 { + snap := s.coverageScoreMap.Load() + if snap == nil { + return map[string]float64{} + } + return *snap +} + +// GetRedundancyScore returns the redundancy (criticality) score for a +// pubkey in [0, 1], or 0 if unavailable. Case-insensitive. +func (s *PacketStore) GetRedundancyScore(pubkey string) float64 { + if pubkey == "" { + return 0 + } + snap := s.redundancyScoreMap.Load() + if snap == nil { + return 0 + } + return lookupUsefulnessScore(*snap, pubkey) +} + +// GetRedundancyScoreMap returns the current redundancy snapshot (read-only +// by convention — callers MUST NOT mutate). Nil-safe. +func (s *PacketStore) GetRedundancyScoreMap() map[string]float64 { + snap := s.redundancyScoreMap.Load() + if snap == nil { + return map[string]float64{} + } + return *snap +} diff --git a/cmd/server/usefulness_composite.go b/cmd/server/usefulness_composite.go new file mode 100644 index 00000000..ef03c1ac --- /dev/null +++ b/cmd/server/usefulness_composite.go @@ -0,0 +1,156 @@ +// Package main: composite repeater usefulness score + letter grade +// (issue #672). Combines the four per-axis scores — each already in +// [0, 1] — into a single weighted score, plus an A–F grade for at-a- +// glance ranking. +// +// Weights (sum = 1.0) follow the #672 proposal: +// +// Bridge 0.30 structural betweenness (chokepoint) +// Coverage 0.25 harmonic reach (how much of the mesh it reaches) +// Redundancy 0.25 irreplaceability (fragmentation on removal) +// Traffic 0.20 observed relayed load +// +// All four inputs are expected to be max-normalized over the current +// repeater population (top node = 1.0): Bridge/Coverage/Redundancy by their +// Compute* functions, and Traffic by the caller dividing the raw share by the +// population max BEFORE calling here (#1762 review — without this, the raw +// traffic share (~0.02–0.05) collapsed its 0.20 weight to a negligible +// contribution). The exposed `traffic_share_score` field keeps the RAW share; +// only the composite uses the normalized form. +package main + +// #672 composite weights. Exposed as named constants so the maintainer can +// retune without hunting through the arithmetic. Must sum to 1.0. +const ( + usefulnessWeightBridge = 0.30 + usefulnessWeightCoverage = 0.25 + usefulnessWeightRedundancy = 0.25 + usefulnessWeightTraffic = 0.20 +) + +// Grade thresholds on the composite score. These are a first-cut calibration, +// NOT empirically tuned against a labelled dataset: with all four axes +// max-normalized, the single most-important repeater approaches 1.0, so the +// bands are placed to spread the realistic mid-field — a node strong on two of +// the three .25–.30 structural axes clears B (~0.45), one dominant axis clears +// C (~0.30), and peripheral/low-traffic nodes fall to D/F. Revisit once score +// distributions from real meshes are available; they are named constants +// precisely so retuning is a one-line change. +// +// FOLLOW-UP: a separate tuning issue should be opened to recalibrate these +// bands against observed real-mesh score histograms (cannot be filed from +// here); until then treat the letter grade as a coarse first impression and +// rank on the numeric usefulness_score for anything precise. +const ( + usefulnessGradeA = 0.65 + usefulnessGradeB = 0.45 + usefulnessGradeC = 0.30 + usefulnessGradeD = 0.15 +) + +// usefulnessAxes bundles the four #672 axis scores (each already in [0,1]) +// so callers pass them BY NAME rather than as four adjacent, easily-swapped +// float64 arguments (#1762 review). Traffic here is the max-normalized share +// used in the composite, not the raw traffic_share_score. +type usefulnessAxes struct { + Traffic float64 + Bridge float64 + Coverage float64 + Redundancy float64 +} + +// compositeUsefulness combines the four axis scores into a weighted +// composite in [0, 1] and its letter grade. Inputs are clamped defensively +// to [0, 1]; out-of-range axis values cannot push the composite outside +// the unit interval. +// +// The all-zero case is intentionally ambiguous and `axesComputed` disambiguates +// it (#1762 MAJOR-4): +// +// - axesComputed == false → the recomputers have not populated any snapshot +// yet (the first ~5 min after boot). All-zero then means "no signal YET", +// so the grade is "" (empty) and callers omit usefulness_grade rather than +// flash a misleading boot-time "F". +// - axesComputed == true → the recomputers HAVE run and genuinely scored this +// node zero on every axis (a fully isolated / unreached repeater). That is a +// real, deserved "F" and is returned as such — not hidden. +// +// A node with any non-zero axis is graded normally regardless of the flag. +func compositeUsefulness(ax usefulnessAxes, axesComputed bool) (float64, string) { + t, b, c, r := clamp01(ax.Traffic), clamp01(ax.Bridge), clamp01(ax.Coverage), clamp01(ax.Redundancy) + score := usefulnessWeightBridge*b + + usefulnessWeightCoverage*c + + usefulnessWeightRedundancy*r + + usefulnessWeightTraffic*t + score = clamp01(score) + if t == 0 && b == 0 && c == 0 && r == 0 && !axesComputed { + // Cold start: no axes computed yet — withhold the grade. + return 0, "" + } + return score, usefulnessGrade(score) +} + +// usefulnessGrade maps a composite score in [0, 1] to an A–F letter grade. +func usefulnessGrade(score float64) string { + switch { + case score >= usefulnessGradeA: + return "A" + case score >= usefulnessGradeB: + return "B" + case score >= usefulnessGradeC: + return "C" + case score >= usefulnessGradeD: + return "D" + default: + return "F" + } +} + +// clamp01 bounds v to [0, 1]. +func clamp01(v float64) float64 { + if v < 0 { + return 0 + } + if v > 1 { + return 1 + } + return v +} + +// maxFloat returns the largest value in m, or 0 for an empty map. The local +// is named `largest` (not `max`) to avoid shadowing the Go 1.21 builtin — +// consistent with maxScoreValue in coverage_score_test.go (#1762 nit). +func maxFloat(m map[string]float64) float64 { + largest := 0.0 + for _, v := range m { + if v > largest { + largest = v + } + } + return largest +} + +// enrichNodeUsefulness writes the four #672 axes + composite + grade onto a +// node map (shared by the node-list and node-detail handlers). trafficRaw is +// the Traffic-axis share exposed verbatim as traffic_share_score; ax holds the +// composite inputs — its Traffic is the max-normalized share (across the +// repeater population) so all four axes contribute on a comparable [0,1] +// scale. axesComputed reports whether the structural-axis recomputers have +// produced a snapshot yet; it only matters for the all-zero node, where it +// distinguishes cold start (grade withheld) from a genuinely isolated repeater +// (real "F") — see compositeUsefulness. The usefulness_grade field is omitted +// only when the grade is empty (cold start). +func enrichNodeUsefulness(node map[string]interface{}, trafficRaw float64, ax usefulnessAxes, axesComputed bool) { + if node == nil { + return + } + node["traffic_share_score"] = trafficRaw + node["bridge_score"] = ax.Bridge + node["coverage_score"] = ax.Coverage + node["redundancy_score"] = ax.Redundancy + composite, grade := compositeUsefulness(ax, axesComputed) + node["usefulness_score"] = composite + if grade != "" { + node["usefulness_grade"] = grade + } +} diff --git a/cmd/server/usefulness_composite_test.go b/cmd/server/usefulness_composite_test.go new file mode 100644 index 00000000..b7d6ac38 --- /dev/null +++ b/cmd/server/usefulness_composite_test.go @@ -0,0 +1,167 @@ +package main + +import ( + "math" + "testing" +) + +// TestUsefulnessWeightsSumToOne: the four axis weights must form a convex +// combination so the composite stays in [0,1]. +func TestUsefulnessWeightsSumToOne(t *testing.T) { + sum := usefulnessWeightBridge + usefulnessWeightCoverage + + usefulnessWeightRedundancy + usefulnessWeightTraffic + if math.Abs(sum-1.0) > 1e-9 { + t.Errorf("axis weights must sum to 1.0, got %v", sum) + } +} + +// TestCompositeUsefulness_Extremes: all-1 axes give 1.0 / grade A; all-0 +// give 0.0 / grade F. +func TestCompositeUsefulness_Extremes(t *testing.T) { + if score, grade := compositeUsefulness(usefulnessAxes{1, 1, 1, 1}, true); math.Abs(score-1.0) > 1e-9 || grade != "A" { + t.Errorf("all-ones: want 1.0/A, got %v/%s", score, grade) + } + // All-zero with axes NOT yet computed is the cold-start / no-signal case: + // score 0 with an EMPTY grade (not "F"), so callers can omit the field + // instead of showing a misleading failing grade at boot. + if score, grade := compositeUsefulness(usefulnessAxes{0, 0, 0, 0}, false); score != 0 || grade != "" { + t.Errorf("all-zeros cold-start: want 0.0 and empty grade, got %v/%q", score, grade) + } + // All-zero AFTER the recomputers ran is a genuinely isolated repeater: a + // real, deserved "F" — not hidden (#1762 MAJOR-4). + if score, grade := compositeUsefulness(usefulnessAxes{0, 0, 0, 0}, true); score != 0 || grade != "F" { + t.Errorf("all-zeros computed: want 0.0 and grade F, got %v/%q", score, grade) + } + // A single non-zero axis is NOT cold-start — it grades normally regardless + // of the computed flag. + if _, grade := compositeUsefulness(usefulnessAxes{0, 0, 0, 0.0001}, false); grade == "" { + t.Error("a non-zero axis should produce a non-empty grade") + } +} + +// TestCompositeUsefulness_Weighting: a single axis at 1.0 contributes +// exactly its weight. Bridge alone ⇒ 0.30; traffic alone ⇒ 0.20. +func TestCompositeUsefulness_Weighting(t *testing.T) { + if score, _ := compositeUsefulness(usefulnessAxes{0, 1, 0, 0}, true); math.Abs(score-usefulnessWeightBridge) > 1e-9 { + t.Errorf("bridge-only: want %v, got %v", usefulnessWeightBridge, score) + } + if score, _ := compositeUsefulness(usefulnessAxes{1, 0, 0, 0}, true); math.Abs(score-usefulnessWeightTraffic) > 1e-9 { + t.Errorf("traffic-only: want %v, got %v", usefulnessWeightTraffic, score) + } + if score, _ := compositeUsefulness(usefulnessAxes{0, 0, 1, 0}, true); math.Abs(score-usefulnessWeightCoverage) > 1e-9 { + t.Errorf("coverage-only: want %v, got %v", usefulnessWeightCoverage, score) + } + if score, _ := compositeUsefulness(usefulnessAxes{0, 0, 0, 1}, true); math.Abs(score-usefulnessWeightRedundancy) > 1e-9 { + t.Errorf("redundancy-only: want %v, got %v", usefulnessWeightRedundancy, score) + } +} + +// TestCompositeUsefulness_Clamps: out-of-range axis inputs are clamped to +// [0,1] before weighting, so the composite cannot escape the unit +// interval. +func TestCompositeUsefulness_Clamps(t *testing.T) { + // negative traffic clamps to 0, bridge>1 clamps to 1 ⇒ only bridge's + // weight contributes. + score, grade := compositeUsefulness(usefulnessAxes{-5, 2, 0, 0}, true) + if math.Abs(score-usefulnessWeightBridge) > 1e-9 { + t.Errorf("clamped: want %v, got %v", usefulnessWeightBridge, score) + } + if grade != "C" { // 0.30 ≥ gradeC threshold + t.Errorf("clamped grade: want C at score %v, got %s", score, grade) + } +} + +// TestUsefulnessGrade_Thresholds: each grade boundary maps to the expected +// letter (inclusive lower bound). +func TestUsefulnessGrade_Thresholds(t *testing.T) { + cases := []struct { + score float64 + want string + }{ + {usefulnessGradeA, "A"}, + {usefulnessGradeA - 1e-9, "B"}, + {usefulnessGradeB, "B"}, + {usefulnessGradeB - 1e-9, "C"}, + {usefulnessGradeC, "C"}, + {usefulnessGradeC - 1e-9, "D"}, + {usefulnessGradeD, "D"}, + {usefulnessGradeD - 1e-9, "F"}, + {0, "F"}, + {1, "A"}, + } + for _, c := range cases { + if got := usefulnessGrade(c.score); got != c.want { + t.Errorf("grade(%v): want %s, got %s", c.score, c.want, got) + } + } +} + +// TestClamp01: bounds enforcement. +func TestClamp01(t *testing.T) { + for _, c := range []struct{ in, want float64 }{ + {-1, 0}, {0, 0}, {0.5, 0.5}, {1, 1}, {2, 1}, + } { + if got := clamp01(c.in); got != c.want { + t.Errorf("clamp01(%v): want %v, got %v", c.in, c.want, got) + } + } +} + +func TestMaxFloat(t *testing.T) { + if v := maxFloat(nil); v != 0 { + t.Errorf("maxFloat(nil): want 0, got %v", v) + } + if v := maxFloat(map[string]float64{"a": 0.05, "b": 0.4, "c": 0.1}); v != 0.4 { + t.Errorf("maxFloat: want 0.4, got %v", v) + } +} + +// TestEnrichNodeUsefulness_TrafficNormalization is the regression guard for +// the #1762 BLOCKER: the exposed traffic_share_score must stay the RAW share, +// while the composite must use the max-NORMALIZED traffic so the 0.20 weight +// is fully realized (not collapsed to ~0.01 by a tiny raw fraction). +func TestEnrichNodeUsefulness_TrafficNormalization(t *testing.T) { + node := map[string]interface{}{} + // Raw share 0.05, but it IS the population max → normalized 1.0. + enrichNodeUsefulness(node, 0.05, usefulnessAxes{1.0, 0, 0, 0}, true) + + if node["traffic_share_score"] != 0.05 { + t.Errorf("traffic_share_score should be the RAW 0.05, got %v", node["traffic_share_score"]) + } + // Composite = 0.20 * normalized(1.0) = 0.20, NOT 0.20*0.05 = 0.01. + if got, _ := node["usefulness_score"].(float64); math.Abs(got-usefulnessWeightTraffic) > 1e-9 { + t.Errorf("composite should be the full traffic weight %v, got %v", usefulnessWeightTraffic, got) + } + if node["usefulness_grade"] == nil { + t.Error("a node with non-zero traffic should carry a grade") + } +} + +// TestEnrichNodeUsefulness_ColdStartOmitsGrade: all-zero axes BEFORE the +// recomputers have run (axesComputed=false) → no usefulness_grade field (cold +// start), not a misleading "F". +func TestEnrichNodeUsefulness_ColdStartOmitsGrade(t *testing.T) { + node := map[string]interface{}{} + enrichNodeUsefulness(node, 0, usefulnessAxes{0, 0, 0, 0}, false) + if _, ok := node["usefulness_grade"]; ok { + t.Errorf("usefulness_grade should be omitted on cold-start, got %v", node["usefulness_grade"]) + } + if node["usefulness_score"] != float64(0) { + t.Errorf("usefulness_score should be 0 on cold-start, got %v", node["usefulness_score"]) + } +} + +// TestEnrichNodeUsefulness_IsolatedNodeGetsF: all-zero axes AFTER the +// recomputers have run (axesComputed=true) is a genuinely isolated repeater — +// it MUST surface a real "F" rather than have its grade withheld (#1762 +// MAJOR-4). +func TestEnrichNodeUsefulness_IsolatedNodeGetsF(t *testing.T) { + node := map[string]interface{}{} + enrichNodeUsefulness(node, 0, usefulnessAxes{0, 0, 0, 0}, true) + if g, ok := node["usefulness_grade"]; !ok || g != "F" { + t.Errorf("isolated node (axes computed) should grade F, got %v ok=%v", g, ok) + } + if node["usefulness_score"] != float64(0) { + t.Errorf("usefulness_score should be 0 for isolated node, got %v", node["usefulness_score"]) + } +}