mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-20 10:25:27 +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>
124 lines
4.0 KiB
Go
124 lines
4.0 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gorilla/mux"
|
|
)
|
|
|
|
// TestBridgeScore_HandleNodesSurface verifies that /api/nodes
|
|
// includes a `bridge_score` field on repeater rows after the bridge
|
|
// recomputer has run. Drives the line-graph A-B-C-D through the full
|
|
// pipeline: insert nodes, populate the neighbor graph, force a
|
|
// recompute, hit the handler, parse the response. Issue #672 axis 2.
|
|
func TestBridgeScore_HandleNodesSurface(t *testing.T) {
|
|
db := setupCapabilityTestDB(t)
|
|
defer db.conn.Close()
|
|
// handleNodes/db.GetNodes selects a foreign_advert column not in
|
|
// the minimal capability-test schema.
|
|
if _, err := db.conn.Exec(`ALTER TABLE nodes ADD COLUMN foreign_advert INTEGER DEFAULT 0`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Four repeater nodes in a line.
|
|
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)
|
|
}
|
|
}
|
|
|
|
store := NewPacketStore(db, nil)
|
|
// Build neighbor graph with the line A-B-C-D. Add each edge
|
|
// `count` times so its time-decayed Score saturates.
|
|
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)
|
|
|
|
// Direct invocation of the recomputer's compute path — bypassing
|
|
// StartBridgeScoreRecomputer's package-level once-flag (which is
|
|
// problematic across tests).
|
|
recomputeBridgeScoresSafe(store)
|
|
|
|
snap := store.GetBridgeScoreMap()
|
|
if len(snap) == 0 {
|
|
t.Fatalf("expected non-empty bridge score snapshot, got empty")
|
|
}
|
|
// Sanity: middle nodes b/c must be positive, ends must be zero.
|
|
if snap[pks[1]] <= 0 || snap[pks[2]] <= 0 {
|
|
t.Errorf("middle nodes should have positive bridge: b=%v c=%v",
|
|
snap[pks[1]], snap[pks[2]])
|
|
}
|
|
if snap[pks[0]] != 0 || snap[pks[3]] != 0 {
|
|
t.Errorf("end nodes should have zero bridge: a=%v d=%v",
|
|
snap[pks[0]], snap[pks[3]])
|
|
}
|
|
|
|
// Wire a Server, call handleNodes, parse the response.
|
|
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
|
|
}
|
|
if _, has := n["bridge_score"]; !has {
|
|
t.Errorf("node %s: bridge_score field absent from response", pk[:4])
|
|
}
|
|
}
|
|
// Middle node B must report a non-zero bridge_score; end node A
|
|
// must report exactly zero. These two assertions together prevent
|
|
// a "field present but always 0" regression.
|
|
if v, _ := gotBy[pks[1]]["bridge_score"].(float64); v <= 0 {
|
|
t.Errorf("middle node B bridge_score in API response should be > 0, got %v", v)
|
|
}
|
|
if v, _ := gotBy[pks[0]]["bridge_score"].(float64); v != 0 {
|
|
t.Errorf("end node A bridge_score in API response should be 0, got %v", v)
|
|
}
|
|
}
|