Files
Kpa-clawbot 8bf7709970 feat(repeater): usefulness score — bridge axis (#672 axis 2 of 4) (#1275)
RED test commit: `fd661569` — CI will fail on this (stub returns empty
map; assertions fail by design). GREEN: `bf4b8592`.

## What

Implements **axis 2 of 4** for the repeater usefulness score per #672
([status
comment](https://github.com/Kpa-clawbot/CoreScope/issues/672#issuecomment-4484635378)).
The Bridge axis measures *structural importance*: how many shortest
paths between other nodes route through this one. A high-traffic
redundant node and a low-traffic critical bridge will no longer look
identical.

## Algorithm

**Brandes' weighted betweenness centrality** with Dijkstra for shortest
paths (`cmd/server/bridge_score.go`).

- Nodes: pubkeys in the `neighbor_edges` graph
- Edge weight: `Score(now) * Confidence()` — per the convention from
#1235 (count + recency decay scaled by observer-diversity confidence).
Geo-rejected edges already excluded at graph build time (#1230) so we
don't re-filter here.
- Dijkstra distance: `1 / max(epsilon, weight)` — high affinity = cheap
cost.
- Normalize: divide by max observed centrality so output is in `[0, 1]`.

Cost: `O(V · (E + V log V))`. Staging-scale (~600 nodes / ~2 000 edges)
≈ ~4.8M ops, completes in milliseconds.

## Where it lives

- `cmd/server/bridge_score.go` — pure algorithm, no locks
- `cmd/server/bridge_recomputer.go` — background recomputer (mirrors
#1240/#1262 pattern), 5-min default interval, initial sync prewarm,
snapshot stored in `s.bridgeScoreMap atomic.Pointer[map[string]float64]`
- `cmd/server/routes.go` — `handleNodes` adds `node["bridge_score"]` on
repeater/room rows; node-detail handler adds it on the single-node path
- `public/nodes.js` — separate **Bridge** row in the node detail panel,
alongside the existing **Usefulness** (Traffic) row. Distinct
colour-coded bar.

## What's NOT in this PR (still pending for #672)

- **Coverage axis** (axis 3) — unique observer-pair connectivity
- **Redundancy axis** (axis 4) — simulated node-removal impact
- **Composite** — once all 4 axes ship, swap the `usefulness_score`
formula from "traffic-only" to the weighted composite

`Refs #672` (not `Fixes` — issue stays open until all 4 axes + composite
ship).

## Tests

- `TestComputeBridgeScores_LineGraph` — 4-node line: middles non-zero,
leaves zero, max normalized to 1.0
- `TestComputeBridgeScores_TriangleNoBridge` — clique has zero bridges
- `TestComputeBridgeScores_Empty` — defensive nil-safety
- `TestComputeBridgeScores_WeightSensitive` — mutation guard: revert the
`1/w` inversion and this test fails
- `TestBridgeScore_HandleNodesSurface` — integration: `/api/nodes`
returns `bridge_score` on repeater rows; middle nodes > 0, ends == 0

---------

Co-authored-by: clawbot <bot@meshcore.local>
2026-05-18 22:51:23 -07:00

207 lines
6.2 KiB
Go

// Package main: bridge axis of repeater usefulness score (issue #672,
// axis 2 of 4). The "Bridge" signal is the betweenness centrality of a
// node in the (undirected, weighted) neighbor graph: a high value means
// the node lies on many shortest paths between other pairs and is hence
// structurally important — removing it would force traffic around or
// fragment the mesh.
//
// Algorithm: Brandes' algorithm (1) with Dijkstra for weighted
// shortest paths. Complexity O(V · (E + V log V)). For the staging
// graph (~600 nodes, ~2 000 edges) this is ~4.8M ops — trivial,
// completes in milliseconds. We accumulate raw betweenness across all
// sources, halve (an undirected pair is counted from each endpoint
// once), then normalize by the max observed value so the per-node
// score is in [0, 1].
//
// Edge weight follows the convention established by #1235: the
// affinity score (count + recency decay) is multiplied by the
// observer-diversity confidence — stronger, more corroborated
// neighborships are preferred when there is a choice of paths.
// Geo-rejected edges are already excluded from the input graph at
// 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.
//
// (1) Brandes, "A Faster Algorithm for Betweenness Centrality" (2001).
package main
import (
"container/heap"
"math"
"strings"
)
// BridgeEdge is the algorithm-facing edge tuple consumed by
// ComputeBridgeScores. Endpoints A and B are pubkeys (case preserved
// by caller; we lowercase internally for stable keying). Weight is
// the affinity (higher = stronger connection). Edges with zero or
// negative weight are skipped — they would break Dijkstra's
// relaxation invariant.
type BridgeEdge struct {
A, B string
Weight float64
}
// bridgeMinWeightEpsilon is the floor applied to weights before we
// invert them into Dijkstra distances. 1e-9 is small enough that any
// real weight (Score in [0,1] times Confidence in [0,1]) dominates,
// but large enough to avoid Inf when weight is exactly zero.
const bridgeMinWeightEpsilon = 1e-9
// ComputeBridgeScores returns a map pubkey → bridge score in [0, 1]
// computed via Brandes' weighted betweenness centrality on the
// undirected graph defined by `edges`. Returned map is keyed by the
// lowercase pubkey form (matching the byPathHop / persisted-edge
// convention). Nodes appearing in the graph but with zero betweenness
// are still present in the map with value 0.0.
//
// Self-loops (A == B) and edges with weight < epsilon are silently
// skipped. Duplicate edges between the same pair keep the cheapest
// (= the highest-weight) version — consistent with shortest-path
// semantics.
//
// 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)
}
if len(adj) == 0 {
return map[string]float64{}
}
nodes := make([]string, 0, len(adj))
for n := range adj {
nodes = append(nodes, n)
}
bc := make(map[string]float64, len(nodes))
for _, n := range nodes {
bc[n] = 0
}
// 2. Brandes outer loop: one Dijkstra-based single-source shortest
// path computation per source vertex.
for _, s := range nodes {
stack := make([]string, 0, len(nodes))
pred := make(map[string][]string, len(nodes))
sigma := make(map[string]float64, len(nodes))
dist := make(map[string]float64, len(nodes))
for _, n := range nodes {
sigma[n] = 0
dist[n] = math.Inf(1)
}
sigma[s] = 1
dist[s] = 0
pq := &bridgePQ{}
heap.Init(pq)
heap.Push(pq, bridgePQItem{node: s, dist: 0})
visited := make(map[string]bool, len(nodes))
for pq.Len() > 0 {
top := heap.Pop(pq).(bridgePQItem)
v := top.node
if visited[v] {
continue
}
visited[v] = true
stack = append(stack, v)
for w, edgeDist := range adj[v] {
alt := dist[v] + edgeDist
if alt < dist[w]-1e-12 {
dist[w] = alt
sigma[w] = sigma[v]
pred[w] = append(pred[w][:0], v)
heap.Push(pq, bridgePQItem{node: w, dist: alt})
} else if math.Abs(alt-dist[w]) <= 1e-12 {
sigma[w] += sigma[v]
pred[w] = append(pred[w], v)
}
}
}
// 3. Back-propagation: walk the stack in reverse order.
delta := make(map[string]float64, len(nodes))
for i := len(stack) - 1; i >= 0; i-- {
w := stack[i]
for _, v := range pred[w] {
if sigma[w] == 0 {
continue
}
delta[v] += (sigma[v] / sigma[w]) * (1.0 + delta[w])
}
if w != s {
bc[w] += delta[w]
}
}
}
// 4. Undirected graphs double-count each (s,t) pair, so halve.
for k := range bc {
bc[k] /= 2.0
}
// 5. Normalize by max so scores live in [0, 1]. If max is 0
// (clique or single edge) we leave everything at zero.
maxBC := 0.0
for _, v := range bc {
if v > maxBC {
maxBC = v
}
}
if maxBC > 0 {
for k, v := range bc {
bc[k] = v / maxBC
}
}
return bc
}
// ─── min-heap for Dijkstra ─────────────────────────────────────────────────────
type bridgePQItem struct {
node string
dist float64
}
type bridgePQ []bridgePQItem
func (h bridgePQ) Len() int { return len(h) }
func (h bridgePQ) Less(i, j int) bool { return h[i].dist < h[j].dist }
func (h bridgePQ) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *bridgePQ) Push(x interface{}) { *h = append(*h, x.(bridgePQItem)) }
func (h *bridgePQ) Pop() interface{} {
old := *h
n := len(old)
it := old[n-1]
*h = old[:n-1]
return it
}