mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-14 08:05:49 +00:00
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:
co-authored by
Waydroid Builder
parent
5e096147e5
commit
3efa37c46c
@@ -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{}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user