mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-11 16:54:58 +00:00
54f7f9d35b
## feat: path-prefix candidate inspector with map view (#944) Implements the locked spec from #944: a beam-search-based path prefix inspector that enumerates candidate full-pubkey paths from short hex prefixes and scores them. ### Server (`cmd/server/path_inspect.go`) - **`POST /api/paths/inspect`** — accepts 1-64 hex prefixes (1-3 bytes, uniform length per request) - Beam search (width 20) over cached `prefixMap` + `NeighborGraph` - Per-hop scoring: edge weight (35%), GPS plausibility (20%), recency (15%), prefix selectivity (30%) - Geometric mean aggregation with 0.05 floor per hop - Speculative threshold: score < 0.7 - Score cache: 30s TTL, keyed by (prefixes, observer, window) - Cold-start: synchronous NeighborGraph rebuild with 2s hard timeout → 503 `{retry:true}` - Body limit: 4096 bytes via `http.MaxBytesReader` - Zero SQL queries in handler hot path - Request validation: rejects empty, odd-length, >3 bytes, mixed lengths, >64 hops ### Frontend (`public/path-inspector.js`) - New page under Tools route with input field (comma/space separated hex prefixes) - Client-side validation with error feedback - Results table: rank, score (color-coded speculative), path names, per-hop evidence (collapsed) - "Show on Map" button calls `drawPacketRoute` (one path at a time, clears prior) - Deep link: `#/tools/path-inspector?prefixes=2c,a1,f4` ### Nav reorganization - `Traces` nav item renamed to `Tools` - Backward-compat: `#/traces/<hash>` redirects to `#/tools/trace/<hash>` - Tools sub-routing dispatches to traces or path-inspector ### Store changes - Added `LastSeen time.Time` to `nodeInfo` struct, populated from `nodes.last_seen` - Added `inspectMu` + `inspectCache` fields to `PacketStore` ### Tests - **Go unit tests** (`path_inspect_test.go`): scoreHop components, beam width cap, speculative flag, all validation error cases, valid request integration - **Frontend tests** (`test-path-inspector.js`): parse comma/space/mixed, validation (empty, odd, >3 bytes, mixed lengths, invalid hex, valid) - Anti-tautology gate verified: removing beam pruning fails width test; removing validation fails reject tests ### CSS - `--path-inspector-speculative` variable in both themes (amber, WCAG AA on both dark/light backgrounds) - All colors via CSS variables (no hardcoded hex in production code) Closes #944 --------- Co-authored-by: you <you@example.com>
428 lines
12 KiB
Go
428 lines
12 KiB
Go
package main
|
||
|
||
import (
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"math"
|
||
"net/http"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// ─── Path Inspector ────────────────────────────────────────────────────────────
|
||
// POST /api/paths/inspect — beam-search scorer for prefix path candidates.
|
||
// Spec: issue #944 §2.1–2.5.
|
||
|
||
// pathInspectRequest is the JSON body for the inspect endpoint.
|
||
type pathInspectRequest struct {
|
||
Prefixes []string `json:"prefixes"`
|
||
Context *pathInspectContext `json:"context,omitempty"`
|
||
Limit int `json:"limit,omitempty"`
|
||
}
|
||
|
||
type pathInspectContext struct {
|
||
ObserverID string `json:"observerId,omitempty"`
|
||
Since string `json:"since,omitempty"`
|
||
Until string `json:"until,omitempty"`
|
||
}
|
||
|
||
// pathCandidate is one scored candidate path in the response.
|
||
type pathCandidate struct {
|
||
Path []string `json:"path"`
|
||
Names []string `json:"names"`
|
||
Score float64 `json:"score"`
|
||
Speculative bool `json:"speculative"`
|
||
Evidence pathEvidence `json:"evidence"`
|
||
}
|
||
|
||
type pathEvidence struct {
|
||
PerHop []hopEvidence `json:"perHop"`
|
||
}
|
||
|
||
type hopEvidence struct {
|
||
Prefix string `json:"prefix"`
|
||
CandidatesConsidered int `json:"candidatesConsidered"`
|
||
Chosen string `json:"chosen"`
|
||
EdgeWeight float64 `json:"edgeWeight"`
|
||
Alternatives []hopAlternative `json:"alternatives,omitempty"`
|
||
}
|
||
|
||
// hopAlternative shows a candidate that was considered but not chosen for this hop.
|
||
type hopAlternative struct {
|
||
PublicKey string `json:"publicKey"`
|
||
Name string `json:"name"`
|
||
Score float64 `json:"score"`
|
||
}
|
||
|
||
type pathInspectResponse struct {
|
||
Candidates []pathCandidate `json:"candidates"`
|
||
Input map[string]interface{} `json:"input"`
|
||
Stats map[string]interface{} `json:"stats"`
|
||
}
|
||
|
||
// beamEntry represents a partial path being extended during beam search.
|
||
type beamEntry struct {
|
||
pubkeys []string
|
||
names []string
|
||
evidence []hopEvidence
|
||
score float64 // product of per-hop scores (pre-geometric-mean)
|
||
}
|
||
|
||
const (
|
||
beamWidth = 20
|
||
maxInputHops = 64
|
||
maxPrefixBytes = 3
|
||
maxRequestItems = 64
|
||
geoMaxKm = 50.0
|
||
hopScoreFloor = 0.05
|
||
speculativeThreshold = 0.7
|
||
inspectCacheTTL = 30 * time.Second
|
||
inspectBodyLimit = 4096
|
||
)
|
||
|
||
// Weights per spec §2.3.
|
||
const (
|
||
wEdge = 0.35
|
||
wGeo = 0.20
|
||
wRecency = 0.15
|
||
wSelectivity = 0.30
|
||
)
|
||
|
||
func (s *Server) handlePathInspect(w http.ResponseWriter, r *http.Request) {
|
||
// Body limit per spec §2.1.
|
||
r.Body = http.MaxBytesReader(w, r.Body, inspectBodyLimit)
|
||
|
||
var req pathInspectRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Validate prefixes.
|
||
if len(req.Prefixes) == 0 {
|
||
http.Error(w, `{"error":"prefixes required"}`, http.StatusBadRequest)
|
||
return
|
||
}
|
||
if len(req.Prefixes) > maxRequestItems {
|
||
http.Error(w, `{"error":"too many prefixes (max 64)"}`, http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Normalize + validate each prefix.
|
||
prefixByteLen := -1
|
||
for i, p := range req.Prefixes {
|
||
p = strings.ToLower(strings.TrimSpace(p))
|
||
req.Prefixes[i] = p
|
||
if len(p) == 0 || len(p)%2 != 0 {
|
||
http.Error(w, `{"error":"prefixes must be even-length hex"}`, http.StatusBadRequest)
|
||
return
|
||
}
|
||
if _, err := hex.DecodeString(p); err != nil {
|
||
http.Error(w, `{"error":"prefixes must be valid hex"}`, http.StatusBadRequest)
|
||
return
|
||
}
|
||
byteLen := len(p) / 2
|
||
if byteLen > maxPrefixBytes {
|
||
http.Error(w, `{"error":"prefix exceeds 3 bytes"}`, http.StatusBadRequest)
|
||
return
|
||
}
|
||
if prefixByteLen == -1 {
|
||
prefixByteLen = byteLen
|
||
} else if byteLen != prefixByteLen {
|
||
http.Error(w, `{"error":"mixed prefix lengths not allowed"}`, http.StatusBadRequest)
|
||
return
|
||
}
|
||
}
|
||
|
||
limit := req.Limit
|
||
if limit <= 0 {
|
||
limit = 10
|
||
}
|
||
if limit > 50 {
|
||
limit = 50
|
||
}
|
||
|
||
// Check cache.
|
||
cacheKey := s.store.inspectCacheKey(req)
|
||
s.store.inspectMu.RLock()
|
||
if cached, ok := s.store.inspectCache[cacheKey]; ok && time.Now().Before(cached.expiresAt) {
|
||
s.store.inspectMu.RUnlock()
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(cached.data)
|
||
return
|
||
}
|
||
s.store.inspectMu.RUnlock()
|
||
|
||
// Snapshot data under read lock.
|
||
nodes, pm := s.store.getCachedNodesAndPM()
|
||
|
||
// Build pubkey→nodeInfo map for O(1) geo lookup in scorer.
|
||
nodeByPK := make(map[string]*nodeInfo, len(nodes))
|
||
for i := range nodes {
|
||
nodeByPK[strings.ToLower(nodes[i].PublicKey)] = &nodes[i]
|
||
}
|
||
|
||
// Get neighbor graph; handle cold start.
|
||
graph := s.store.graph
|
||
if graph == nil || graph.IsStale() {
|
||
rebuilt := make(chan struct{})
|
||
go func() {
|
||
s.store.ensureNeighborGraph()
|
||
close(rebuilt)
|
||
}()
|
||
select {
|
||
case <-rebuilt:
|
||
graph = s.store.graph
|
||
case <-time.After(2 * time.Second):
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusServiceUnavailable)
|
||
json.NewEncoder(w).Encode(map[string]interface{}{"retry": true})
|
||
return
|
||
}
|
||
if graph == nil {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusServiceUnavailable)
|
||
json.NewEncoder(w).Encode(map[string]interface{}{"retry": true})
|
||
return
|
||
}
|
||
}
|
||
|
||
now := time.Now()
|
||
start := now
|
||
|
||
// Beam search.
|
||
beam := s.store.beamSearch(req.Prefixes, pm, graph, nodeByPK, now)
|
||
|
||
// Sort by score descending, take top limit.
|
||
sortBeam(beam)
|
||
if len(beam) > limit {
|
||
beam = beam[:limit]
|
||
}
|
||
|
||
// Build response with per-hop alternatives (spec §2.7, M2 fix).
|
||
candidates := make([]pathCandidate, 0, len(beam))
|
||
for _, entry := range beam {
|
||
nHops := len(entry.pubkeys)
|
||
var score float64
|
||
if nHops > 0 {
|
||
score = math.Pow(entry.score, 1.0/float64(nHops))
|
||
}
|
||
|
||
// Populate per-hop alternatives: other candidates at each hop that weren't chosen.
|
||
evidence := make([]hopEvidence, len(entry.evidence))
|
||
copy(evidence, entry.evidence)
|
||
for hi, ev := range evidence {
|
||
if hi >= len(req.Prefixes) {
|
||
break
|
||
}
|
||
prefix := req.Prefixes[hi]
|
||
allCands := pm.m[prefix]
|
||
var alts []hopAlternative
|
||
for _, c := range allCands {
|
||
if !canAppearInPath(c.Role) || c.PublicKey == ev.Chosen {
|
||
continue
|
||
}
|
||
// Score this alternative in context of the partial path up to this hop.
|
||
var partialEntry beamEntry
|
||
if hi > 0 {
|
||
partialEntry = beamEntry{pubkeys: entry.pubkeys[:hi], names: entry.names[:hi], score: 1.0}
|
||
}
|
||
altScore := s.store.scoreHop(partialEntry, c, ev.CandidatesConsidered, graph, nodeByPK, now, hi)
|
||
alts = append(alts, hopAlternative{PublicKey: c.PublicKey, Name: c.Name, Score: math.Round(altScore*1000) / 1000})
|
||
}
|
||
// Sort alts by score desc, cap at 5.
|
||
sort.Slice(alts, func(i, j int) bool { return alts[i].Score > alts[j].Score })
|
||
if len(alts) > 5 {
|
||
alts = alts[:5]
|
||
}
|
||
evidence[hi] = hopEvidence{
|
||
Prefix: ev.Prefix,
|
||
CandidatesConsidered: ev.CandidatesConsidered,
|
||
Chosen: ev.Chosen,
|
||
EdgeWeight: ev.EdgeWeight,
|
||
Alternatives: alts,
|
||
}
|
||
}
|
||
|
||
candidates = append(candidates, pathCandidate{
|
||
Path: entry.pubkeys,
|
||
Names: entry.names,
|
||
Score: math.Round(score*1000) / 1000,
|
||
Speculative: score < speculativeThreshold,
|
||
Evidence: pathEvidence{PerHop: evidence},
|
||
})
|
||
}
|
||
|
||
elapsed := time.Since(start).Milliseconds()
|
||
resp := pathInspectResponse{
|
||
Candidates: candidates,
|
||
Input: map[string]interface{}{
|
||
"prefixes": req.Prefixes,
|
||
"hops": len(req.Prefixes),
|
||
},
|
||
Stats: map[string]interface{}{
|
||
"beamWidth": beamWidth,
|
||
"expansionsRun": len(req.Prefixes) * beamWidth,
|
||
"elapsedMs": elapsed,
|
||
},
|
||
}
|
||
|
||
// Cache result (and evict stale entries).
|
||
s.store.inspectMu.Lock()
|
||
if s.store.inspectCache == nil {
|
||
s.store.inspectCache = make(map[string]*inspectCachedResult)
|
||
}
|
||
now2 := time.Now()
|
||
for k, v := range s.store.inspectCache {
|
||
if now2.After(v.expiresAt) {
|
||
delete(s.store.inspectCache, k)
|
||
}
|
||
}
|
||
s.store.inspectCache[cacheKey] = &inspectCachedResult{
|
||
data: resp,
|
||
expiresAt: now2.Add(inspectCacheTTL),
|
||
}
|
||
s.store.inspectMu.Unlock()
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(resp)
|
||
}
|
||
|
||
type inspectCachedResult struct {
|
||
data pathInspectResponse
|
||
expiresAt time.Time
|
||
}
|
||
|
||
func (s *PacketStore) inspectCacheKey(req pathInspectRequest) string {
|
||
key := strings.Join(req.Prefixes, ",")
|
||
if req.Context != nil {
|
||
key += "|" + req.Context.ObserverID + "|" + req.Context.Since + "|" + req.Context.Until
|
||
}
|
||
return key
|
||
}
|
||
|
||
func (s *PacketStore) beamSearch(prefixes []string, pm *prefixMap, graph *NeighborGraph, nodeByPK map[string]*nodeInfo, now time.Time) []beamEntry {
|
||
// Start with empty beam.
|
||
beam := []beamEntry{{pubkeys: nil, names: nil, evidence: nil, score: 1.0}}
|
||
|
||
for hopIdx, prefix := range prefixes {
|
||
candidates := pm.m[prefix]
|
||
// Filter by role at lookup time (spec §2.2 step 2).
|
||
var filtered []nodeInfo
|
||
for _, c := range candidates {
|
||
if canAppearInPath(c.Role) {
|
||
filtered = append(filtered, c)
|
||
}
|
||
}
|
||
|
||
candidateCount := len(filtered)
|
||
if candidateCount == 0 {
|
||
// No candidates for this hop — beam dies.
|
||
return nil
|
||
}
|
||
|
||
var nextBeam []beamEntry
|
||
for _, entry := range beam {
|
||
for _, cand := range filtered {
|
||
hopScore := s.scoreHop(entry, cand, candidateCount, graph, nodeByPK, now, hopIdx)
|
||
if hopScore < hopScoreFloor {
|
||
hopScore = hopScoreFloor
|
||
}
|
||
|
||
newEntry := beamEntry{
|
||
pubkeys: append(append([]string{}, entry.pubkeys...), cand.PublicKey),
|
||
names: append(append([]string{}, entry.names...), cand.Name),
|
||
evidence: append(append([]hopEvidence{}, entry.evidence...), hopEvidence{
|
||
Prefix: prefix,
|
||
CandidatesConsidered: candidateCount,
|
||
Chosen: cand.PublicKey,
|
||
EdgeWeight: hopScore,
|
||
}),
|
||
score: entry.score * hopScore,
|
||
}
|
||
nextBeam = append(nextBeam, newEntry)
|
||
}
|
||
}
|
||
|
||
// Prune to beam width.
|
||
sortBeam(nextBeam)
|
||
if len(nextBeam) > beamWidth {
|
||
nextBeam = nextBeam[:beamWidth]
|
||
}
|
||
beam = nextBeam
|
||
}
|
||
|
||
return beam
|
||
}
|
||
|
||
func (s *PacketStore) scoreHop(entry beamEntry, cand nodeInfo, candidateCount int, graph *NeighborGraph, nodeByPK map[string]*nodeInfo, now time.Time, hopIdx int) float64 {
|
||
var edgeScore float64
|
||
var geoScore float64 = 1.0
|
||
var recencyScore float64 = 1.0
|
||
|
||
if hopIdx == 0 || len(entry.pubkeys) == 0 {
|
||
// First hop: no prior node to compare against.
|
||
edgeScore = 1.0
|
||
} else {
|
||
lastPK := entry.pubkeys[len(entry.pubkeys)-1]
|
||
|
||
// Single scan over neighbors for both edge weight and recency.
|
||
edges := graph.Neighbors(lastPK)
|
||
var foundEdge *NeighborEdge
|
||
for _, e := range edges {
|
||
peer := e.NodeA
|
||
if strings.EqualFold(peer, lastPK) {
|
||
peer = e.NodeB
|
||
}
|
||
if strings.EqualFold(peer, cand.PublicKey) {
|
||
foundEdge = e
|
||
break
|
||
}
|
||
}
|
||
|
||
if foundEdge != nil {
|
||
edgeScore = foundEdge.Score(now)
|
||
hoursSince := now.Sub(foundEdge.LastSeen).Hours()
|
||
if hoursSince <= 24 {
|
||
recencyScore = 1.0
|
||
} else {
|
||
recencyScore = math.Max(0.1, 24.0/hoursSince)
|
||
}
|
||
} else {
|
||
edgeScore = 0
|
||
recencyScore = 0
|
||
}
|
||
|
||
// Geographic plausibility.
|
||
prevNode := nodeByPK[strings.ToLower(lastPK)]
|
||
if prevNode != nil && prevNode.HasGPS && cand.HasGPS {
|
||
dist := haversineKm(prevNode.Lat, prevNode.Lon, cand.Lat, cand.Lon)
|
||
if dist > geoMaxKm {
|
||
geoScore = math.Max(0.1, geoMaxKm/dist)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Prefix selectivity.
|
||
selectivityScore := 1.0 / float64(candidateCount)
|
||
|
||
return wEdge*edgeScore + wGeo*geoScore + wRecency*recencyScore + wSelectivity*selectivityScore
|
||
}
|
||
|
||
|
||
func sortBeam(beam []beamEntry) {
|
||
sort.Slice(beam, func(i, j int) bool {
|
||
return beam[i].score > beam[j].score
|
||
})
|
||
}
|
||
|
||
// ensureNeighborGraph triggers a graph rebuild if nil or stale.
|
||
func (s *PacketStore) ensureNeighborGraph() {
|
||
if s.graph != nil && !s.graph.IsStale() {
|
||
return
|
||
}
|
||
g := BuildFromStore(s)
|
||
s.graph = g
|
||
}
|