mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-19 08:25:14 +00:00
8bf7709970
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>
207 lines
6.2 KiB
Go
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
|
|
}
|