Files
meshcore-analyzer/cmd/server/neighbor_persist.go
T
d910ea0208 feat(#1638): confidence rating weighted by hash mode (#1687)
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>
2026-06-12 11:38:43 -07:00

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
}