feat(server): complete the #672 4-axis repeater usefulness score (#1762)

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 <build@waydroid.local>
This commit is contained in:
Michael J. Arcan
2026-06-27 22:03:05 -07:00
committed by GitHub
co-authored by Waydroid Builder
parent 5e096147e5
commit 3efa37c46c
14 changed files with 1425 additions and 66 deletions
+8 -29
View File
@@ -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{}
}
+83
View File
@@ -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
}
+131
View File
@@ -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
}
+84
View File
@@ -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
}
+11
View File
@@ -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.
+201
View File
@@ -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
}
+137
View File
@@ -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])
}
}
}
+43 -16
View File
@@ -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())
}
}
+17
View File
@@ -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
+59 -21
View File
@@ -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)
}
}
@@ -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 AF 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 AF 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])
}
}
}
+164
View File
@@ -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
}
+156
View File
@@ -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 AF 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.020.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 AF 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
}
}
+167
View File
@@ -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"])
}
}