mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-04-14 07:26:28 +00:00
## Summary Milestone 6 of #482: Observability & Debugging tools for the neighbor affinity system. These tools exist because someone will need them at 3 AM when "Show Neighbors is showing the wrong node for C0DE" and they have 5 minutes to diagnose it. ## Changes ### 1. Debug API — `GET /api/debug/affinity` - Full graph state dump: all edges with weights, observation counts, last-seen timestamps - Per-prefix resolution log with disambiguation reasoning (Jaccard scores, ratios, thresholds) - Query params: `?prefix=C0DE` filter to specific prefix, `?node=<pubkey>` for specific node's edges - Protected by API key (same auth as `/api/admin/prune`) - Response includes: edge count, node count, cache age, last rebuild time ### 2. Debug Overlay on Map - Toggle-able checkbox "🔍 Affinity Debug" in map controls - Draws lines between nodes showing affinity edges with color coding: - Green = high confidence (score ≥ 0.6) - Yellow = medium (0.3–0.6) - Red = ambiguous (< 0.3) - Line thickness proportional to weight, dashed for ambiguous - Unresolved prefixes shown as ❓ markers - Click edge → popup with observation count, last seen, score, observers - Hidden behind `debugAffinity` config flag or `localStorage.setItem('meshcore-affinity-debug', 'true')` ### 3. Per-Node Debug Panel - Expandable "🔍 Affinity Debug" section in node detail page (collapsed by default) - Shows: neighbor edges table with scores, prefix resolutions with reasoning trace - Candidates table with Jaccard scores, highlighting the chosen candidate - Graph-level stats summary ### 4. Server-Side Structured Logging - Integrated into `disambiguate()` — logs every resolution decision during graph build - Format: `[affinity] resolve C0DE: c0dedad4 score=47 Jaccard=0.82 vs c0dedad9 score=3 Jaccard=0.11 → neighbor_affinity (ratio 15.7×)` - Logs ambiguous decisions: `scores too close (12 vs 9, ratio 1.3×) → ambiguous` - Gated by `debugAffinity` config flag ### 5. Dashboard Stats Widget - Added to analytics overview tab when debug mode is enabled - Metrics: total edges/nodes, resolved/ambiguous counts (%), avg confidence, cold-start coverage, cache age, last rebuild ## Files Changed - `cmd/server/neighbor_debug.go` — new: debug API handler, resolution builder, cold-start coverage - `cmd/server/neighbor_debug_test.go` — new: 7 tests for debug API - `cmd/server/neighbor_graph.go` — added structured logging to disambiguate(), `logFn` field, `BuildFromStoreWithLog` - `cmd/server/neighbor_api.go` — pass debug flag through `BuildFromStoreWithLog` - `cmd/server/config.go` — added `DebugAffinity` config field - `cmd/server/routes.go` — registered `/api/debug/affinity` route, exposed `debugAffinity` in client config - `cmd/server/types.go` — added `DebugAffinity` to `ClientConfigResponse` - `public/map.js` — affinity debug overlay layer with edge visualization - `public/nodes.js` — per-node affinity debug panel - `public/analytics.js` — dashboard stats widget - `test-e2e-playwright.js` — 3 Playwright tests for debug UI ## Tests - ✅ 7 Go unit tests (API shape, prefix/node filters, auth, structured logging, cold-start coverage) - ✅ 3 Playwright E2E tests (overlay checkbox, toggle without crash, panel expansion) - ✅ All existing tests pass (`go test ./cmd/server/... -count=1`) Part of #482 --------- Co-authored-by: you <you@example.com>
400 lines
11 KiB
Go
400 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ─── Debug API response types ──────────────────────────────────────────────────
|
|
|
|
type DebugAffinityResponse struct {
|
|
Edges []DebugEdge `json:"edges"`
|
|
Resolutions []DebugResolution `json:"resolutions"`
|
|
Stats DebugStats `json:"stats"`
|
|
}
|
|
|
|
type DebugEdge struct {
|
|
NodeA string `json:"nodeA"`
|
|
NodeAName string `json:"nodeAName,omitempty"`
|
|
NodeB string `json:"nodeB"`
|
|
NodeBName string `json:"nodeBName,omitempty"`
|
|
Prefix string `json:"prefix"`
|
|
Weight int `json:"weight"`
|
|
ObservationCount int `json:"observationCount"`
|
|
LastSeen string `json:"lastSeen"`
|
|
FirstSeen string `json:"firstSeen"`
|
|
Score float64 `json:"score"`
|
|
Jaccard float64 `json:"jaccard,omitempty"`
|
|
AvgSNR *float64 `json:"avgSnr,omitempty"`
|
|
Observers []string `json:"observers"`
|
|
Ambiguous bool `json:"ambiguous"`
|
|
Unresolved bool `json:"unresolved,omitempty"`
|
|
Resolved bool `json:"resolved,omitempty"`
|
|
}
|
|
|
|
type DebugResolution struct {
|
|
Prefix string `json:"prefix"`
|
|
Chosen string `json:"chosen,omitempty"`
|
|
ChosenName string `json:"chosenName,omitempty"`
|
|
ChosenScore int `json:"chosenScore"`
|
|
ChosenJaccard float64 `json:"chosenJaccard"`
|
|
Confidence string `json:"confidence"`
|
|
Candidates []DebugCandidate `json:"candidates"`
|
|
Ratio float64 `json:"ratio"`
|
|
ThresholdApplied float64 `json:"thresholdApplied"`
|
|
Method string `json:"method"`
|
|
Tier string `json:"tier"`
|
|
KnownNode string `json:"knownNode"`
|
|
KnownNodeName string `json:"knownNodeName,omitempty"`
|
|
}
|
|
|
|
type DebugCandidate struct {
|
|
Pubkey string `json:"pubkey"`
|
|
Name string `json:"name,omitempty"`
|
|
Score int `json:"score"`
|
|
Jaccard float64 `json:"jaccard"`
|
|
}
|
|
|
|
type DebugStats struct {
|
|
TotalEdges int `json:"totalEdges"`
|
|
TotalNodes int `json:"totalNodes"`
|
|
ResolvedCount int `json:"resolvedCount"`
|
|
AmbiguousCount int `json:"ambiguousCount"`
|
|
UnresolvedCount int `json:"unresolvedCount"`
|
|
AvgConfidence float64 `json:"avgConfidence"`
|
|
ColdStartCoverage float64 `json:"coldStartCoverage"`
|
|
CacheAge string `json:"cacheAge"`
|
|
LastRebuild string `json:"lastRebuild"`
|
|
}
|
|
|
|
// ─── Debug API Handler ─────────────────────────────────────────────────────────
|
|
|
|
func (s *Server) handleDebugAffinity(w http.ResponseWriter, r *http.Request) {
|
|
prefixFilter := strings.ToLower(r.URL.Query().Get("prefix"))
|
|
nodeFilter := strings.ToLower(r.URL.Query().Get("node"))
|
|
|
|
graph := s.getNeighborGraph()
|
|
now := time.Now()
|
|
nodeMap := s.buildNodeInfoMap()
|
|
|
|
allEdges := graph.AllEdges()
|
|
|
|
// Build edges response
|
|
var debugEdges []DebugEdge
|
|
nodeSet := make(map[string]bool)
|
|
resolvedCount := 0
|
|
ambiguousCount := 0
|
|
unresolvedCount := 0
|
|
var scoreSum float64
|
|
var scoreCount int
|
|
|
|
for _, e := range allEdges {
|
|
// Apply filters
|
|
if prefixFilter != "" && !strings.EqualFold(e.Prefix, prefixFilter) {
|
|
continue
|
|
}
|
|
if nodeFilter != "" {
|
|
if !strings.EqualFold(e.NodeA, nodeFilter) && !strings.EqualFold(e.NodeB, nodeFilter) {
|
|
// Also check if any candidate matches
|
|
found := false
|
|
for _, c := range e.Candidates {
|
|
if strings.EqualFold(c, nodeFilter) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
score := e.Score(now)
|
|
de := DebugEdge{
|
|
NodeA: e.NodeA,
|
|
NodeB: e.NodeB,
|
|
Prefix: e.Prefix,
|
|
Weight: e.Count,
|
|
ObservationCount: e.Count,
|
|
LastSeen: e.LastSeen.UTC().Format(time.RFC3339),
|
|
FirstSeen: e.FirstSeen.UTC().Format(time.RFC3339),
|
|
Score: math.Round(score*1000) / 1000,
|
|
Observers: observerList(e.Observers),
|
|
Ambiguous: e.Ambiguous,
|
|
Resolved: e.Resolved,
|
|
}
|
|
|
|
if e.SNRCount > 0 {
|
|
avg := e.AvgSNR()
|
|
de.AvgSNR = &avg
|
|
}
|
|
|
|
// Add names
|
|
if nodeMap != nil {
|
|
if info, ok := nodeMap[strings.ToLower(e.NodeA)]; ok {
|
|
de.NodeAName = info.Name
|
|
}
|
|
if info, ok := nodeMap[strings.ToLower(e.NodeB)]; ok {
|
|
de.NodeBName = info.Name
|
|
}
|
|
}
|
|
|
|
if e.Ambiguous {
|
|
if len(e.Candidates) == 0 {
|
|
de.Unresolved = true
|
|
unresolvedCount++
|
|
} else {
|
|
ambiguousCount++
|
|
}
|
|
} else {
|
|
resolvedCount++
|
|
scoreSum += score
|
|
scoreCount++
|
|
}
|
|
|
|
debugEdges = append(debugEdges, de)
|
|
|
|
if e.NodeA != "" && !strings.HasPrefix(e.NodeA, "prefix:") {
|
|
nodeSet[e.NodeA] = true
|
|
}
|
|
if e.NodeB != "" && !strings.HasPrefix(e.NodeB, "prefix:") {
|
|
nodeSet[e.NodeB] = true
|
|
}
|
|
}
|
|
|
|
// Build resolutions from the graph's disambiguation history
|
|
resolutions := s.buildResolutions(graph, nodeMap, prefixFilter, nodeFilter)
|
|
|
|
// Cold-start coverage: % of 1-byte prefixes with ≥3 observations
|
|
coldStart := s.computeColdStartCoverage(allEdges)
|
|
|
|
avgConf := 0.0
|
|
if scoreCount > 0 {
|
|
avgConf = math.Round(scoreSum/float64(scoreCount)*1000) / 1000
|
|
}
|
|
|
|
if debugEdges == nil {
|
|
debugEdges = []DebugEdge{}
|
|
}
|
|
if resolutions == nil {
|
|
resolutions = []DebugResolution{}
|
|
}
|
|
|
|
// Sort edges by weight descending
|
|
sort.Slice(debugEdges, func(i, j int) bool {
|
|
return debugEdges[i].Weight > debugEdges[j].Weight
|
|
})
|
|
|
|
graph.mu.RLock()
|
|
builtAt := graph.builtAt
|
|
graph.mu.RUnlock()
|
|
|
|
cacheAge := ""
|
|
lastRebuild := ""
|
|
if !builtAt.IsZero() {
|
|
cacheAge = fmt.Sprintf("%.1fs", time.Since(builtAt).Seconds())
|
|
lastRebuild = builtAt.UTC().Format(time.RFC3339)
|
|
}
|
|
|
|
resp := DebugAffinityResponse{
|
|
Edges: debugEdges,
|
|
Resolutions: resolutions,
|
|
Stats: DebugStats{
|
|
TotalEdges: len(debugEdges),
|
|
TotalNodes: len(nodeSet),
|
|
ResolvedCount: resolvedCount,
|
|
AmbiguousCount: ambiguousCount,
|
|
UnresolvedCount: unresolvedCount,
|
|
AvgConfidence: avgConf,
|
|
ColdStartCoverage: coldStart,
|
|
CacheAge: cacheAge,
|
|
LastRebuild: lastRebuild,
|
|
},
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// buildResolutions generates per-prefix resolution decision logs.
|
|
// It uses resolveWithContext (M4) to show the actual 4-tier fallback path
|
|
// (affinity → geo → GPS → first_match) for each prefix resolution.
|
|
func (s *Server) buildResolutions(graph *NeighborGraph, nodeMap map[string]nodeInfo, prefixFilter, nodeFilter string) []DebugResolution {
|
|
graph.mu.RLock()
|
|
defer graph.mu.RUnlock()
|
|
|
|
// Get the prefix map for resolveWithContext tier computation.
|
|
var pm *prefixMap
|
|
if s.store != nil {
|
|
_, pm = s.store.getCachedNodesAndPM()
|
|
}
|
|
|
|
// Build resolved neighbor sets for Jaccard computation
|
|
resolvedNeighbors := make(map[string]map[string]bool)
|
|
for _, e := range graph.edges {
|
|
if e.Ambiguous || e.NodeB == "" {
|
|
continue
|
|
}
|
|
if resolvedNeighbors[e.NodeA] == nil {
|
|
resolvedNeighbors[e.NodeA] = make(map[string]bool)
|
|
}
|
|
if resolvedNeighbors[e.NodeB] == nil {
|
|
resolvedNeighbors[e.NodeB] = make(map[string]bool)
|
|
}
|
|
resolvedNeighbors[e.NodeA][e.NodeB] = true
|
|
resolvedNeighbors[e.NodeB][e.NodeA] = true
|
|
}
|
|
|
|
var resolutions []DebugResolution
|
|
|
|
for _, e := range graph.edges {
|
|
// Show resolution info for both resolved (auto-resolved) and ambiguous edges
|
|
if !e.Resolved && !e.Ambiguous {
|
|
continue
|
|
}
|
|
if len(e.Candidates) < 2 && !e.Resolved {
|
|
continue
|
|
}
|
|
|
|
if prefixFilter != "" && !strings.EqualFold(e.Prefix, prefixFilter) {
|
|
continue
|
|
}
|
|
|
|
knownNode := e.NodeA
|
|
if strings.HasPrefix(e.NodeA, "prefix:") {
|
|
knownNode = e.NodeB
|
|
}
|
|
|
|
if nodeFilter != "" && !strings.EqualFold(knownNode, nodeFilter) {
|
|
// Check if the resolved node matches
|
|
if e.Resolved && !strings.EqualFold(e.NodeB, nodeFilter) && !strings.EqualFold(e.NodeA, nodeFilter) {
|
|
continue
|
|
}
|
|
}
|
|
|
|
knownNeighbors := resolvedNeighbors[knownNode]
|
|
|
|
var candidates []DebugCandidate
|
|
candList := e.Candidates
|
|
// For resolved edges, add the resolved node as a candidate too
|
|
if e.Resolved {
|
|
resolvedPK := e.NodeB
|
|
if strings.EqualFold(e.NodeB, knownNode) {
|
|
resolvedPK = e.NodeA
|
|
}
|
|
// Include resolved + original candidates
|
|
found := false
|
|
for _, c := range candList {
|
|
if strings.EqualFold(c, resolvedPK) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
candList = append([]string{resolvedPK}, candList...)
|
|
}
|
|
}
|
|
|
|
for _, cpk := range candList {
|
|
candNeighbors := resolvedNeighbors[cpk]
|
|
j := jaccardSimilarity(knownNeighbors, candNeighbors)
|
|
dc := DebugCandidate{
|
|
Pubkey: cpk,
|
|
Score: e.Count,
|
|
Jaccard: math.Round(j*1000) / 1000,
|
|
}
|
|
if nodeMap != nil {
|
|
if info, ok := nodeMap[strings.ToLower(cpk)]; ok {
|
|
dc.Name = info.Name
|
|
}
|
|
}
|
|
candidates = append(candidates, dc)
|
|
}
|
|
|
|
// Sort candidates by Jaccard descending
|
|
sort.Slice(candidates, func(i, j int) bool {
|
|
return candidates[i].Jaccard > candidates[j].Jaccard
|
|
})
|
|
|
|
dr := DebugResolution{
|
|
Prefix: e.Prefix,
|
|
ThresholdApplied: affinityConfidenceRatio,
|
|
KnownNode: knownNode,
|
|
}
|
|
|
|
if nodeMap != nil {
|
|
if info, ok := nodeMap[strings.ToLower(knownNode)]; ok {
|
|
dr.KnownNodeName = info.Name
|
|
}
|
|
}
|
|
|
|
// Use resolveWithContext to determine the actual 4-tier fallback path.
|
|
tier := ""
|
|
if pm != nil {
|
|
contextPubkeys := []string{knownNode}
|
|
_, tierUsed, _ := pm.resolveWithContext(e.Prefix, contextPubkeys, graph)
|
|
tier = tierUsed
|
|
}
|
|
|
|
if e.Resolved && len(candidates) > 0 {
|
|
dr.Chosen = candidates[0].Pubkey
|
|
dr.ChosenName = candidates[0].Name
|
|
dr.ChosenScore = candidates[0].Score
|
|
dr.ChosenJaccard = candidates[0].Jaccard
|
|
dr.Confidence = "HIGH"
|
|
dr.Method = "auto-resolved"
|
|
dr.Tier = tier
|
|
if len(candidates) > 1 && candidates[1].Jaccard > 0 {
|
|
dr.Ratio = math.Round(candidates[0].Jaccard/candidates[1].Jaccard*10) / 10
|
|
} else if candidates[0].Jaccard > 0 {
|
|
dr.Ratio = 999.0 // effectively infinite — JSON doesn't support Infinity
|
|
}
|
|
} else {
|
|
dr.Confidence = "AMBIGUOUS"
|
|
dr.Method = "ambiguous"
|
|
dr.Tier = tier
|
|
if len(candidates) >= 2 {
|
|
dr.ChosenScore = candidates[0].Score
|
|
dr.ChosenJaccard = candidates[0].Jaccard
|
|
if candidates[1].Jaccard > 0 {
|
|
dr.Ratio = math.Round(candidates[0].Jaccard/candidates[1].Jaccard*10) / 10
|
|
}
|
|
}
|
|
}
|
|
dr.Candidates = candidates
|
|
|
|
resolutions = append(resolutions, dr)
|
|
}
|
|
|
|
return resolutions
|
|
}
|
|
|
|
// computeColdStartCoverage returns the % of active 1-byte hex prefixes with ≥3 observations.
|
|
func (s *Server) computeColdStartCoverage(edges []*NeighborEdge) float64 {
|
|
// Track which 1-byte prefixes have sufficient observations
|
|
prefixObs := make(map[string]int) // 1-byte prefix → total observations
|
|
for _, e := range edges {
|
|
if len(e.Prefix) == 2 { // 1-byte = 2 hex chars
|
|
prefixObs[strings.ToLower(e.Prefix)] += e.Count
|
|
}
|
|
}
|
|
|
|
if len(prefixObs) == 0 {
|
|
return 0
|
|
}
|
|
|
|
covered := 0
|
|
for _, count := range prefixObs {
|
|
if count >= affinityMinObservations {
|
|
covered++
|
|
}
|
|
}
|
|
|
|
return math.Round(float64(covered)/float64(len(prefixObs))*1000) / 10
|
|
}
|