mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-26 17:27:57 +00:00
Fixes #1638. ## Problem `getConfidenceIndicator` in `public/nodes.js` treats every observation as equal evidence, so a node seen 5 times via 1-byte hash prefixes (which collide ~8-way across a typical mesh) scores the same as a node seen 5 times via 6-byte prefixes (effectively unambiguous). The user asked for confidence to respect ambiguity. ## Change - `cmd/server/neighbor_graph.go` — new `CountsByMode map[int]int` on `NeighborEdge`, bumped in `upsertEdge` / `upsertEdgeWithCandidates` based on the observation's hash-prefix byte length (1/2/4/6). Merged in `resolveEdge` when ambiguous→resolved edges collapse. - `cmd/server/neighbor_api.go` — `NeighborEntry.counts_by_mode` exposed (omitempty), and `dedupPrefixEntries` merges per-mode counts when an unresolved prefix entry collapses into a resolved one. Flat `Count` field preserved for back-compat. - `public/nodes.js::getConfidenceIndicator` — weights observations by mode: 1-byte=0.125, 2-byte=0.5, 4/6-byte=1.0. A single 6-byte sighting counts ~8× a raw 1-byte one. HIGH triggers when EITHER the legacy heuristic clears OR weighted count ≥3. Legacy entries without `counts_by_mode` keep working (default weight 0.5). - Tooltip now shows the per-mode breakdown (e.g. "Observations: 5 (1-byte: 3, 6-byte: 2)"). ## TDD - RED: `cmd/server/neighbor_graph_test.go::TestBuildNeighborGraph_CountsByMode` — fixture with 1/2/4-byte sightings asserts per-mode tally (commit `838965f3`). - RED: `test-confidence-indicator.js` — 6-byte mostly-sighted neighbor must outrank 1-byte mostly-sighted neighbor at equal flat count (commit `4bd5e18e`). - GREEN: implementation in commit `7511606d`. All 4 JS tests pass; new Go test passes; full Go suite passes (two pre-existing flakes unrelated, both pass when isolated). ## Browser verification Synthetic side-by-side of OLD vs NEW classifier against representative inputs — see screenshot. 1-byte-only and 6-byte-only at the same flat count diverge from MEDIUM/MEDIUM to MEDIUM/HIGH, and 3 6-byte sightings now upgrade where 20 1-byte sightings stay MEDIUM. ## Preflight overrides - check-branch-scope: cross-stack: justified — backend exposes the new `counts_by_mode` field and the frontend consumes it; the whole point of the change. ## Compat - `Count` field unchanged in shape and value. - `counts_by_mode` is `omitempty`; legacy persisted edges (loaded from `neighbor_edges` via `neighbor_persist.go`) get no per-mode breakdown and fall back to the default weight (0.5) — no UI regression. --------- Co-authored-by: bot <bot@local> Co-authored-by: corescope-bot <bot@corescope.local>
302 lines
9.3 KiB
Go
302 lines
9.3 KiB
Go
// Package main: read-only neighbor-edges loader.
|
|
//
|
|
// Per issue #1287 (followup to #1283), cmd/server is the read path: it
|
|
// LOADS the in-memory neighbor graph from the SQLite snapshot the
|
|
// ingestor maintains, but never writes to it. The previous write-side
|
|
// helpers in this file (buildAndPersistEdges, asyncPersistResolvedPaths
|
|
// AndEdges, ensure*Column, softDeleteBlacklistedObservers,
|
|
// PruneNeighborEdges, openRW) all moved to cmd/ingestor; cmd/ingestor
|
|
// owns CREATE/ALTER/INSERT/UPDATE/DELETE on neighbor_edges and the
|
|
// observations/resolved_path column.
|
|
//
|
|
// Server now refreshes its in-memory copy of the graph via the
|
|
// recompNeighborGraph slot in analytics_recomputer.go: every 60s it
|
|
// re-reads neighbor_edges and atomic-swaps the resulting NeighborGraph
|
|
// into s.graph.
|
|
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"log"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ─── neighbor_edges loader (read-only) ─────────────────────────────────────────
|
|
|
|
// loadNeighborEdgesFromDB loads all edges from the neighbor_edges table
|
|
// and builds an in-memory NeighborGraph. Called on server startup and
|
|
// from the recompNeighborGraph background recomputer (#1287).
|
|
func loadNeighborEdgesFromDB(conn *sql.DB) *NeighborGraph {
|
|
g := NewNeighborGraph()
|
|
|
|
rows, err := conn.Query("SELECT node_a, node_b, count, last_seen FROM neighbor_edges")
|
|
if err != nil {
|
|
log.Printf("[neighbor] failed to load neighbor_edges: %v", err)
|
|
return g
|
|
}
|
|
defer rows.Close()
|
|
|
|
count := 0
|
|
for rows.Next() {
|
|
var a, b string
|
|
var cnt int
|
|
var lastSeen sql.NullString
|
|
if err := rows.Scan(&a, &b, &cnt, &lastSeen); err != nil {
|
|
continue
|
|
}
|
|
ts := time.Time{}
|
|
if lastSeen.Valid {
|
|
ts = parseTimestamp(lastSeen.String)
|
|
}
|
|
key := makeEdgeKey(a, b)
|
|
g.mu.Lock()
|
|
e, exists := g.edges[key]
|
|
if !exists {
|
|
// Persisted snapshot stores only the flat Count — no per-mode
|
|
// breakdown. Synthesize CountsByMode by attributing all Count
|
|
// to the legacy/unknown bucket (0) so the invariant
|
|
// sum(CountsByMode) == Count holds for downstream consumers.
|
|
// Issue #1638 adv-#1: legacy-edge invariant.
|
|
cbm := make(map[int]int)
|
|
if cnt > 0 {
|
|
cbm[0] = cnt
|
|
}
|
|
e = &NeighborEdge{
|
|
NodeA: key.A,
|
|
NodeB: key.B,
|
|
Observers: make(map[string]bool),
|
|
FirstSeen: ts,
|
|
LastSeen: ts,
|
|
Count: cnt,
|
|
CountsByMode: cbm,
|
|
}
|
|
g.edges[key] = e
|
|
g.byNode[key.A] = append(g.byNode[key.A], e)
|
|
g.byNode[key.B] = append(g.byNode[key.B], e)
|
|
} else {
|
|
e.Count += cnt
|
|
if e.CountsByMode == nil {
|
|
e.CountsByMode = make(map[int]int)
|
|
}
|
|
if cnt > 0 {
|
|
e.CountsByMode[0] += cnt
|
|
}
|
|
if ts.After(e.LastSeen) {
|
|
e.LastSeen = ts
|
|
}
|
|
}
|
|
g.mu.Unlock()
|
|
count++
|
|
}
|
|
|
|
if count > 0 {
|
|
g.mu.Lock()
|
|
g.builtAt = time.Now()
|
|
g.mu.Unlock()
|
|
log.Printf("[neighbor] loaded %d edges from neighbor_edges table", count)
|
|
}
|
|
|
|
return g
|
|
}
|
|
|
|
// neighborEdgesTableExists returns true when neighbor_edges contains at
|
|
// least one row. Used by main.go to decide between "load snapshot" and
|
|
// "start with empty graph and wait for the ingestor to populate it".
|
|
func neighborEdgesTableExists(conn *sql.DB) bool {
|
|
var cnt int
|
|
err := conn.QueryRow("SELECT COUNT(*) FROM neighbor_edges").Scan(&cnt)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return cnt > 0
|
|
}
|
|
|
|
// ─── resolved_path helpers (read-only / in-memory only) ────────────────────────
|
|
|
|
// resolvePathForObs resolves hop prefixes to full pubkeys for an
|
|
// observation. Pure compute — does NOT persist (the ingestor owns
|
|
// writes to observations.resolved_path).
|
|
func resolvePathForObs(pathJSON, observerID string, tx *StoreTx, pm *prefixMap, graph *NeighborGraph) []*string {
|
|
hops := parsePathJSON(pathJSON)
|
|
if len(hops) == 0 {
|
|
return nil
|
|
}
|
|
contextPKs := make([]string, 0, 3)
|
|
if observerID != "" {
|
|
contextPKs = append(contextPKs, strings.ToLower(observerID))
|
|
}
|
|
fromNode := extractFromNode(tx)
|
|
if fromNode != "" {
|
|
contextPKs = append(contextPKs, strings.ToLower(fromNode))
|
|
}
|
|
resolved := make([]*string, len(hops))
|
|
for i, hop := range hops {
|
|
ctx := make([]string, len(contextPKs), len(contextPKs)+2)
|
|
copy(ctx, contextPKs)
|
|
if i > 0 && resolved[i-1] != nil {
|
|
ctx = append(ctx, *resolved[i-1])
|
|
}
|
|
node, _, _ := pm.resolveWithContext(hop, ctx, graph)
|
|
if node != nil {
|
|
pk := strings.ToLower(node.PublicKey)
|
|
resolved[i] = &pk
|
|
}
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
// resolvePathForObsColdLoad is the cold-load (Load / loadChunk / scanAndMergeChunk)
|
|
// variant of resolvePathForObs that gates hop resolution on `unique_prefix`
|
|
// only. Live ingest uses the affinity/observation-count tiebreak via
|
|
// resolvePathForObs because it has roughly-current state. Cold load runs
|
|
// against observations up to retentionHours (168h) old, where today's
|
|
// affinity winner ≠ historical affinity winner for that prefix — silently
|
|
// mis-attributing the relay (PR #1643 R1 munger #1, "time-travel attribution
|
|
// gate").
|
|
//
|
|
// Behavior: hops whose prefix maps to exactly one repeater resolve as
|
|
// usual; hops whose prefix maps to multiple candidates return nil and
|
|
// increment skipped (caller-owned counter for observability — a single
|
|
// summary log line at the end of Load surfaces the total).
|
|
//
|
|
// Under-attribute > mis-attribute (reviewer consensus on PR #1643).
|
|
func resolvePathForObsColdLoad(pathJSON, observerID string, tx *StoreTx, pm *prefixMap, skipped *int) []*string {
|
|
hops := parsePathJSON(pathJSON)
|
|
if len(hops) == 0 {
|
|
return nil
|
|
}
|
|
resolved := make([]*string, len(hops))
|
|
for i, hop := range hops {
|
|
// unique_prefix iff the prefix maps to exactly one candidate
|
|
// after the observer-known nonRelay filter. Mirrors the
|
|
// `len(candidates) == 1 → "unique_prefix"` arm of
|
|
// resolveWithContext (store.go ~6380). Calling resolveWithContext
|
|
// with a nil graph and empty context skips the affinity/
|
|
// observation-count tiers entirely — but tier-4
|
|
// observation_count_fallback would still pick a winner for
|
|
// ambiguous prefixes, which is exactly what we must NOT do.
|
|
// Hence the explicit candidate-count check here.
|
|
h := strings.ToLower(hop)
|
|
candidates := pm.m[h]
|
|
if len(pm.nonRelay) > 0 && len(candidates) > 0 {
|
|
filtered := candidates[:0:0]
|
|
for j := range candidates {
|
|
if _, isListener := pm.nonRelay[strings.ToLower(candidates[j].PublicKey)]; isListener {
|
|
continue
|
|
}
|
|
filtered = append(filtered, candidates[j])
|
|
}
|
|
candidates = filtered
|
|
}
|
|
if len(candidates) == 1 {
|
|
pk := strings.ToLower(candidates[0].PublicKey)
|
|
resolved[i] = &pk
|
|
continue
|
|
}
|
|
// Ambiguous (len > 1) or no_match (len == 0). Under-attribute.
|
|
if len(candidates) > 1 && skipped != nil {
|
|
*skipped++
|
|
}
|
|
// resolved[i] stays nil; extractResolvedPubkeys filters it out.
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
// marshalResolvedPath converts []*string to JSON for in-memory caching.
|
|
func marshalResolvedPath(rp []*string) string {
|
|
if len(rp) == 0 {
|
|
return ""
|
|
}
|
|
b, err := json.Marshal(rp)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// unmarshalResolvedPath parses a resolved_path JSON string.
|
|
func unmarshalResolvedPath(s string) []*string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
var result []*string
|
|
if json.Unmarshal([]byte(s), &result) != nil {
|
|
return nil
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ─── Shared edge-extraction helper (used by ingestor + tests) ──────────────────
|
|
|
|
// edgeCandidate represents an extracted edge. The ingestor uses the
|
|
// same logic when computing edges from observations.
|
|
type edgeCandidate struct {
|
|
A, B, Timestamp string
|
|
}
|
|
|
|
// extractEdgesFromObs extracts neighbor edge candidates from a single
|
|
// observation. For ADVERTs: originator↔path[0] (if unambiguous). For
|
|
// ALL types: observer↔path[last] (if unambiguous). Also handles
|
|
// zero-hop ADVERTs (originator↔observer direct link).
|
|
//
|
|
// Kept in cmd/server because the in-memory graph builder
|
|
// (neighbor_graph.go) also calls it; it is pure compute and does not
|
|
// touch the DB.
|
|
func extractEdgesFromObs(obs *StoreObs, tx *StoreTx, pm *prefixMap) []edgeCandidate {
|
|
isAdvert := tx.PayloadType != nil && *tx.PayloadType == PayloadADVERT
|
|
fromNode := extractFromNode(tx)
|
|
path := parsePathJSON(obs.PathJSON)
|
|
observerPK := strings.ToLower(obs.ObserverID)
|
|
ts := obs.Timestamp
|
|
var edges []edgeCandidate
|
|
|
|
if len(path) == 0 {
|
|
if isAdvert && fromNode != "" {
|
|
fromLower := strings.ToLower(fromNode)
|
|
if fromLower != observerPK {
|
|
a, b := fromLower, observerPK
|
|
if a > b {
|
|
a, b = b, a
|
|
}
|
|
edges = append(edges, edgeCandidate{a, b, ts})
|
|
}
|
|
}
|
|
return edges
|
|
}
|
|
|
|
if isAdvert && fromNode != "" && pm != nil {
|
|
firstHop := strings.ToLower(path[0])
|
|
fromLower := strings.ToLower(fromNode)
|
|
candidates := pm.m[firstHop]
|
|
if len(candidates) == 1 {
|
|
resolved := strings.ToLower(candidates[0].PublicKey)
|
|
if resolved != fromLower {
|
|
a, b := fromLower, resolved
|
|
if a > b {
|
|
a, b = b, a
|
|
}
|
|
edges = append(edges, edgeCandidate{a, b, ts})
|
|
}
|
|
}
|
|
}
|
|
|
|
if pm != nil {
|
|
lastHop := strings.ToLower(path[len(path)-1])
|
|
candidates := pm.m[lastHop]
|
|
if len(candidates) == 1 {
|
|
resolved := strings.ToLower(candidates[0].PublicKey)
|
|
if resolved != observerPK {
|
|
a, b := observerPK, resolved
|
|
if a > b {
|
|
a, b = b, a
|
|
}
|
|
edges = append(edges, edgeCandidate{a, b, ts})
|
|
}
|
|
}
|
|
}
|
|
|
|
return edges
|
|
}
|