mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-14 07:11:04 +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>
186 lines
6.0 KiB
Go
186 lines
6.0 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): 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
|
|
|
|
import (
|
|
"container/heap"
|
|
"math"
|
|
)
|
|
|
|
// 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 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{}
|
|
}
|
|
|
|
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
|
|
}
|