From 4a56be0b481b2165966cd52cb4e368057580c60f Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Thu, 2 Apr 2026 21:14:58 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20neighbor=20affinity=20graph=20builder?= =?UTF-8?q?=20(#482)=20=E2=80=94=20milestone=201=20(#507)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Milestone 1 of 7 for the neighbor affinity graph feature (#482). Implements the core `NeighborGraph` data structure and `BuildFromStore()` algorithm. **Spec:** `docs/specs/neighbor-affinity-graph.md` on `spec/482-neighbor-affinity` branch. ## What's Built ### `cmd/server/neighbor_graph.go` - **`NeighborGraph` struct** — thread-safe (sync.RWMutex) in-memory graph with edge map and per-node index - **`BuildFromStore(*PacketStore)`** — iterates all packets/observations to extract first-hop edges: - `originator ↔ path[0]` for ADVERT packets only (originator identity known) - `observer ↔ path[last]` for ALL packet types - Zero-hop ADVERTs: `originator ↔ observer` direct edge - **Affinity scoring** — `score = min(1.0, count/100) × exp(-λ × hours)` with 7-day half-life - **Jaccard disambiguation** — resolves ambiguous hash prefixes using mutual-neighbor overlap - **Confidence threshold** — auto-resolve only when best ≥ 3× second-best AND ≥ 3 observations - **Transitivity poisoning guard** — only fully-resolved edges used as evidence - **Orphan prefix handling** — unknown prefixes stored as unresolved markers - **Cache management** — 60s TTL, `IsStale()` check for rebuild triggering ### `cmd/server/neighbor_graph_test.go` 22 unit tests covering all spec requirements: | Test | What it validates | |------|-------------------| | EmptyStore | Empty graph from empty store | | AdvertSingleHopPath | Both edge types from single-hop ADVERT | | AdvertMultiHopPath | originator↔path[0] + observer↔path[last] | | AdvertZeroHop | Direct originator↔observer edge | | NonAdvertEmptyPath | No edges from non-ADVERT empty path | | NonAdvertOnlyObserverEdge | Only observer↔last_hop for non-ADVERTs | | NonAdvertSingleHop | observer↔path[0] only | | HashCollision | Ambiguous edge with candidates | | JaccardScoring | Jaccard coefficient computation | | ConfidenceAutoResolve | Auto-resolve when ratio ≥ 3× | | EqualScoresAmbiguous | Remains ambiguous with equal scores | | ObserverSelfEdgeGuard | No self-edges | | OrphanPrefix | Unresolved prefix handling | | AffinityScore_Fresh | Score ≈ 1.0 for fresh high-count | | AffinityScore_Decayed | Score ≈ 0.5 at 7-day half-life | | AffinityScore_LowCount | Score ≈ 0.05 for count=5 | | AffinityScore_StaleAndLow | Score ≈ 0 for old low-count | | CountAccumulation | 5 observations → count=5 | | MultipleObservers | Observer set tracks all witnesses | | TimeDecayOldObservations | Month-old edge scores very low | | ADVERTOnlyConstraint | Non-ADVERTs don't create originator edges | | CacheTTL | Stale detection works correctly | ## Not in scope (future milestones) - API endpoints (M2) - Frontend integration (M3-M5) - Debug tools (M6) - Analytics visualization (M7) Part of #482 --------- Co-authored-by: you --- cmd/server/neighbor_graph.go | 500 +++++++++++++++++++++++ cmd/server/neighbor_graph_test.go | 642 ++++++++++++++++++++++++++++++ 2 files changed, 1142 insertions(+) create mode 100644 cmd/server/neighbor_graph.go create mode 100644 cmd/server/neighbor_graph_test.go diff --git a/cmd/server/neighbor_graph.go b/cmd/server/neighbor_graph.go new file mode 100644 index 00000000..b6ca5775 --- /dev/null +++ b/cmd/server/neighbor_graph.go @@ -0,0 +1,500 @@ +package main + +import ( + "encoding/json" + "math" + "strings" + "sync" + "time" +) + +// ─── Constants ───────────────────────────────────────────────────────────────── + +const ( + // After this many observations, count contributes max weight to the score. + affinitySaturationCount = 100 + // Time-decay half-life: 7 days. + affinityHalfLifeHours = 168.0 + // Cache TTL for the built graph. + neighborGraphTTL = 60 * time.Second + // Auto-resolve confidence: best must be >= this factor × second-best. + affinityConfidenceRatio = 3.0 + // Minimum observation count to auto-resolve. + affinityMinObservations = 3 +) + +// affinityLambda = ln(2) / half-life-hours, precomputed. +var affinityLambda = math.Ln2 / affinityHalfLifeHours + +// ─── Data model ──────────────────────────────────────────────────────────────── + +// edgeKey is the canonical key for an undirected edge (A < B lexicographically). +// For ambiguous edges where NodeB is unknown, B is the raw prefix prefixed with "prefix:". +type edgeKey struct { + A, B string +} + +func makeEdgeKey(a, b string) edgeKey { + if a > b { + a, b = b, a + } + return edgeKey{A: a, B: b} +} + +// NeighborEdge represents a weighted, undirected first-hop neighbor relationship. +type NeighborEdge struct { + NodeA string // full pubkey + NodeB string // full pubkey, or "" if unresolved/ambiguous + Prefix string // raw hop prefix that established this edge + Count int // total observations + FirstSeen time.Time // + LastSeen time.Time // + SNRSum float64 // running sum for average + SNRCount int // how many SNR samples + Observers map[string]bool // observer pubkeys that witnessed + Ambiguous bool // multiple candidates or zero candidates + Candidates []string // candidate pubkeys when ambiguous + Resolved bool // true if auto-resolved via Jaccard +} + +// Score computes the affinity score at query time with time decay. +func (e *NeighborEdge) Score(now time.Time) float64 { + countFactor := math.Min(1.0, float64(e.Count)/float64(affinitySaturationCount)) + hoursSince := now.Sub(e.LastSeen).Hours() + if hoursSince < 0 { + hoursSince = 0 + } + decay := math.Exp(-affinityLambda * hoursSince) + return countFactor * decay +} + +// AvgSNR returns the average SNR, or 0 if no samples. +func (e *NeighborEdge) AvgSNR() float64 { + if e.SNRCount == 0 { + return 0 + } + return e.SNRSum / float64(e.SNRCount) +} + +// ─── NeighborGraph ───────────────────────────────────────────────────────────── + +// NeighborGraph is a cached, in-memory first-hop neighbor affinity graph. +type NeighborGraph struct { + mu sync.RWMutex + edges map[edgeKey]*NeighborEdge + byNode map[string][]*NeighborEdge // pubkey → edges involving this node + builtAt time.Time +} + +// NewNeighborGraph creates an empty graph. +func NewNeighborGraph() *NeighborGraph { + return &NeighborGraph{ + edges: make(map[edgeKey]*NeighborEdge), + byNode: make(map[string][]*NeighborEdge), + } +} + +// Neighbors returns all edges for a given node pubkey. +func (g *NeighborGraph) Neighbors(pubkey string) []*NeighborEdge { + g.mu.RLock() + defer g.mu.RUnlock() + return g.byNode[strings.ToLower(pubkey)] +} + +// AllEdges returns all edges in the graph. +func (g *NeighborGraph) AllEdges() []*NeighborEdge { + g.mu.RLock() + defer g.mu.RUnlock() + out := make([]*NeighborEdge, 0, len(g.edges)) + for _, e := range g.edges { + out = append(out, e) + } + return out +} + +// IsStale returns true if the graph cache has expired. +func (g *NeighborGraph) IsStale() bool { + g.mu.RLock() + defer g.mu.RUnlock() + return g.builtAt.IsZero() || time.Since(g.builtAt) > neighborGraphTTL +} + +// ─── Builder ─────────────────────────────────────────────────────────────────── + +// BuildFromStore constructs the neighbor graph from all packets in the store. +// The store's read-lock must NOT be held by the caller. +func BuildFromStore(store *PacketStore) *NeighborGraph { + g := NewNeighborGraph() + + store.mu.RLock() + // Snapshot what we need under lock. + packets := make([]*StoreTx, len(store.packets)) + copy(packets, store.packets) + store.mu.RUnlock() + + // Build prefix map for candidate resolution. + // Use cached nodes+PM (avoids DB call if cache is fresh). + _, pm := store.getCachedNodesAndPM() + + // Phase 1: Extract edges from every transmission + observation. + for _, tx := range packets { + isAdvert := tx.PayloadType != nil && *tx.PayloadType == 4 + fromNode := "" // originator pubkey (from byNode index key) + // Find the originator pubkey — it's the key in store.byNode. + // StoreTx doesn't store from_node directly; we find it via decoded JSON + // or the byNode index. However, iterating byNode is expensive. + // The originator pubkey is in the decoded JSON "from_node" field, + // but parsing JSON per tx is expensive too. + // Actually, let's look at how byNode is keyed. + // Looking at store.go, byNode maps pubkey → transmissions where that + // pubkey is the "from" node. We need the reverse: tx → from_node. + // The from_node is embedded in DecodedJSON. + // For efficiency, let's extract it once. + fromNode = extractFromNode(tx) + + for _, obs := range tx.Observations { + path := parsePathJSON(obs.PathJSON) + observerPK := strings.ToLower(obs.ObserverID) + + if len(path) == 0 { + // Zero-hop + if isAdvert && fromNode != "" { + fromLower := strings.ToLower(fromNode) + if fromLower != observerPK { // self-edge guard + g.upsertEdge(fromLower, observerPK, "", observerPK, obs.SNR, parseTimestamp(obs.Timestamp)) + } + } + continue + } + + // Edge 1: originator ↔ path[0] — ADVERTs only + if isAdvert && fromNode != "" { + firstHop := strings.ToLower(path[0]) + fromLower := strings.ToLower(fromNode) + if fromLower != firstHop { // self-edge guard (shouldn't happen but spec says check) + candidates := pm.m[firstHop] + g.upsertEdgeWithCandidates(fromLower, firstHop, candidates, observerPK, obs.SNR, parseTimestamp(obs.Timestamp)) + } + } + + // Edge 2: observer ↔ path[last] — ALL packet types + lastHop := strings.ToLower(path[len(path)-1]) + if observerPK != lastHop { // self-edge guard + candidates := pm.m[lastHop] + g.upsertEdgeWithCandidates(observerPK, lastHop, candidates, observerPK, obs.SNR, parseTimestamp(obs.Timestamp)) + } + } + } + + // Phase 2: Disambiguation via Jaccard similarity. + g.disambiguate() + + g.mu.Lock() + g.builtAt = time.Now() + g.mu.Unlock() + + return g +} + +// extractFromNode pulls the from_node pubkey from a StoreTx. +// It looks in DecodedJSON for "from_node" or "from". +func extractFromNode(tx *StoreTx) string { + if tx.DecodedJSON == "" { + return "" + } + // Fast path: look for "from_node" key. + var decoded map[string]interface{} + if err := jsonUnmarshalFast(tx.DecodedJSON, &decoded); err != nil { + return "" + } + if v, ok := decoded["from_node"]; ok { + if s, ok := v.(string); ok { + return s + } + } + if v, ok := decoded["from"]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +// jsonUnmarshalFast is a thin wrapper; could be optimized later. +func jsonUnmarshalFast(data string, v interface{}) error { + return json.Unmarshal([]byte(data), v) +} + +// upsertEdge adds/updates an edge between two fully-known pubkeys. +func (g *NeighborGraph) upsertEdge(pubkeyA, pubkeyB, prefix, observer string, snr *float64, ts time.Time) { + key := makeEdgeKey(pubkeyA, pubkeyB) + + g.mu.Lock() + defer g.mu.Unlock() + + e, exists := g.edges[key] + if !exists { + e = &NeighborEdge{ + NodeA: key.A, + NodeB: key.B, + Prefix: prefix, + Observers: make(map[string]bool), + FirstSeen: ts, + LastSeen: ts, + } + g.edges[key] = e + g.byNode[key.A] = append(g.byNode[key.A], e) + g.byNode[key.B] = append(g.byNode[key.B], e) + } + + e.Count++ + if ts.After(e.LastSeen) { + e.LastSeen = ts + } + if ts.Before(e.FirstSeen) { + e.FirstSeen = ts + } + if snr != nil { + e.SNRSum += *snr + e.SNRCount++ + } + if observer != "" { + e.Observers[observer] = true + } +} + +// upsertEdgeWithCandidates handles prefix-based edges that may be ambiguous. +func (g *NeighborGraph) upsertEdgeWithCandidates(knownPK, prefix string, candidates []nodeInfo, observer string, snr *float64, ts time.Time) { + if len(candidates) == 1 { + resolved := strings.ToLower(candidates[0].PublicKey) + if resolved == knownPK { + return // self-edge guard + } + g.upsertEdge(knownPK, resolved, prefix, observer, snr, ts) + return + } + + // Filter out self from candidates + filtered := make([]string, 0, len(candidates)) + for _, c := range candidates { + pk := strings.ToLower(c.PublicKey) + if pk != knownPK { + filtered = append(filtered, pk) + } + } + + if len(filtered) == 1 { + g.upsertEdge(knownPK, filtered[0], prefix, observer, snr, ts) + return + } + + // Ambiguous or orphan: use prefix-based key + pseudoB := "prefix:" + prefix + key := makeEdgeKey(knownPK, pseudoB) + + g.mu.Lock() + defer g.mu.Unlock() + + e, exists := g.edges[key] + if !exists { + e = &NeighborEdge{ + NodeA: key.A, + NodeB: "", + Prefix: prefix, + Observers: make(map[string]bool), + Ambiguous: true, + Candidates: filtered, + FirstSeen: ts, + LastSeen: ts, + } + g.edges[key] = e + g.byNode[knownPK] = append(g.byNode[knownPK], e) + } + + e.Count++ + if ts.After(e.LastSeen) { + e.LastSeen = ts + } + if ts.Before(e.FirstSeen) { + e.FirstSeen = ts + } + if snr != nil { + e.SNRSum += *snr + e.SNRCount++ + } + if observer != "" { + e.Observers[observer] = true + } +} + +// ─── Disambiguation ──────────────────────────────────────────────────────────── + +// disambiguate resolves ambiguous edges using Jaccard similarity of neighbor sets. +// Only fully-resolved edges are used as evidence (transitivity poisoning guard). +func (g *NeighborGraph) disambiguate() { + g.mu.Lock() + defer g.mu.Unlock() + + // Build resolved neighbor sets: for each node, collect the set of nodes + // it has fully-resolved (non-ambiguous) edges with. + resolvedNeighbors := make(map[string]map[string]bool) + for _, e := range g.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 + } + + // Try to resolve each ambiguous edge. + for key, e := range g.edges { + if !e.Ambiguous || len(e.Candidates) < 2 { + continue + } + if e.Count < affinityMinObservations { + continue + } + + // Determine the known node (the one that's a real pubkey, not the prefix side). + knownNode := e.NodeA + if strings.HasPrefix(e.NodeA, "prefix:") { + knownNode = e.NodeB + } + // If knownNode is empty (shouldn't happen for ambiguous edges with candidates), skip. + if knownNode == "" { + continue + } + + knownNeighbors := resolvedNeighbors[knownNode] + + type scored struct { + pubkey string + jaccard float64 + } + var scores []scored + + for _, cand := range e.Candidates { + candNeighbors := resolvedNeighbors[cand] + j := jaccardSimilarity(knownNeighbors, candNeighbors) + scores = append(scores, scored{cand, j}) + } + + if len(scores) < 2 { + continue + } + + // Find best and second-best. + best, secondBest := scores[0], scores[1] + if secondBest.jaccard > best.jaccard { + best, secondBest = secondBest, best + } + for i := 2; i < len(scores); i++ { + if scores[i].jaccard > best.jaccard { + secondBest = best + best = scores[i] + } else if scores[i].jaccard > secondBest.jaccard { + secondBest = scores[i] + } + } + + // Auto-resolve only if best >= 3× second-best AND enough observations. + if secondBest.jaccard == 0 { + // If second-best is 0 and best > 0, ratio is infinite → resolve. + if best.jaccard > 0 { + g.resolveEdge(key, e, knownNode, best.pubkey) + } + } else if best.jaccard/secondBest.jaccard >= affinityConfidenceRatio { + g.resolveEdge(key, e, knownNode, best.pubkey) + } + // Otherwise remain ambiguous. + } +} + +// resolveEdge converts an ambiguous edge to a resolved one. +// Must be called with g.mu held. +func (g *NeighborGraph) resolveEdge(oldKey edgeKey, e *NeighborEdge, knownNode, resolvedPK string) { + // Remove old edge. + delete(g.edges, oldKey) + g.removeFromByNode(oldKey.A, e) + g.removeFromByNode(oldKey.B, e) + + // Update edge. + newKey := makeEdgeKey(knownNode, resolvedPK) + e.NodeA = newKey.A + e.NodeB = newKey.B + e.Ambiguous = false + e.Resolved = true + + // Merge with existing edge if any. + if existing, ok := g.edges[newKey]; ok { + existing.Count += e.Count + if e.LastSeen.After(existing.LastSeen) { + existing.LastSeen = e.LastSeen + } + if e.FirstSeen.Before(existing.FirstSeen) { + existing.FirstSeen = e.FirstSeen + } + existing.SNRSum += e.SNRSum + existing.SNRCount += e.SNRCount + for obs := range e.Observers { + existing.Observers[obs] = true + } + return + } + + g.edges[newKey] = e + g.byNode[newKey.A] = append(g.byNode[newKey.A], e) + g.byNode[newKey.B] = append(g.byNode[newKey.B], e) +} + +// removeFromByNode removes an edge from the byNode index for the given key. +func (g *NeighborGraph) removeFromByNode(nodeKey string, edge *NeighborEdge) { + edges := g.byNode[nodeKey] + for i, e := range edges { + if e == edge { + g.byNode[nodeKey] = append(edges[:i], edges[i+1:]...) + return + } + } +} + +// jaccardSimilarity computes |A ∩ B| / |A ∪ B|. +func jaccardSimilarity(a, b map[string]bool) float64 { + if len(a) == 0 && len(b) == 0 { + return 0 + } + intersection := 0 + for k := range a { + if b[k] { + intersection++ + } + } + union := len(a) + len(b) - intersection + if union == 0 { + return 0 + } + return float64(intersection) / float64(union) +} + +// parseTimestamp parses a timestamp string into time.Time. +func parseTimestamp(s string) time.Time { + // Try common formats. + for _, fmt := range []string{ + time.RFC3339, + "2006-01-02T15:04:05Z", + "2006-01-02 15:04:05", + "2006-01-02T15:04:05.000Z", + } { + if t, err := time.Parse(fmt, s); err == nil { + return t + } + } + return time.Time{} +} + diff --git a/cmd/server/neighbor_graph_test.go b/cmd/server/neighbor_graph_test.go new file mode 100644 index 00000000..1a1b1029 --- /dev/null +++ b/cmd/server/neighbor_graph_test.go @@ -0,0 +1,642 @@ +package main + +import ( + "encoding/json" + "math" + "testing" + "time" +) + +// ─── Helpers ─────────────────────────────────────────────────────────────────── + +// ngTestStore creates a minimal PacketStore with injected nodes and packets. +func ngTestStore(nodes []nodeInfo, packets []*StoreTx) *PacketStore { + if nodes == nil { + nodes = []nodeInfo{} + } + if packets == nil { + packets = []*StoreTx{} + } + ps := &PacketStore{ + packets: packets, + byHash: make(map[string]*StoreTx), + byTxID: make(map[int]*StoreTx), + byObsID: make(map[int]*StoreObs), + byObserver: make(map[string][]*StoreObs), + byNode: make(map[string][]*StoreTx), + nodeHashes: make(map[string]map[string]bool), + byPayloadType: make(map[int][]*StoreTx), + rfCache: make(map[string]*cachedResult), + topoCache: make(map[string]*cachedResult), + hashCache: make(map[string]*cachedResult), + collisionCache: make(map[string]*cachedResult), + chanCache: make(map[string]*cachedResult), + distCache: make(map[string]*cachedResult), + subpathCache: make(map[string]*cachedResult), + spIndex: make(map[string]int), + } + ps.nodeCache = nodes + ps.nodePM = buildPrefixMap(nodes) + ps.nodeCacheTime = time.Now().Add(1 * time.Hour) + return ps +} + +func ngIntPtr(v int) *int { return &v } +func ngFloatPtr(v float64) *float64 { return &v } + +func ngMakeTx(id int, payloadType int, decodedJSON string, obs []*StoreObs) *StoreTx { + tx := &StoreTx{ + ID: id, + PayloadType: ngIntPtr(payloadType), + DecodedJSON: decodedJSON, + Observations: obs, + } + return tx +} + +func ngMakeObs(observerID, pathJSON, timestamp string, snr *float64) *StoreObs { + return &StoreObs{ + ObserverID: observerID, + PathJSON: pathJSON, + Timestamp: timestamp, + SNR: snr, + } +} + +func ngFromNodeJSON(pubkey string) string { + b, _ := json.Marshal(map[string]string{"from_node": pubkey}) + return string(b) +} + +var now = time.Now() +var nowStr = now.UTC().Format(time.RFC3339) +var weekAgoStr = now.Add(-7 * 24 * time.Hour).UTC().Format(time.RFC3339) +var monthAgoStr = now.Add(-30 * 24 * time.Hour).UTC().Format(time.RFC3339) + +// ─── Tests ───────────────────────────────────────────────────────────────────── + +func TestBuildNeighborGraph_EmptyStore(t *testing.T) { + store := ngTestStore(nil, nil) + g := BuildFromStore(store) + if len(g.edges) != 0 { + t.Errorf("expected 0 edges, got %d", len(g.edges)) + } +} + +func TestBuildNeighborGraph_AdvertSingleHopPath(t *testing.T) { + // ADVERT from X, path=["R1_prefix"] → edges: X↔R1 and Observer↔R1 + nodes := []nodeInfo{ + {PublicKey: "aaaa1111", Name: "NodeX"}, + {PublicKey: "r1aabbcc", Name: "R1"}, + {PublicKey: "obs00001", Name: "Observer"}, + } + tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{ + ngMakeObs("obs00001", `["r1aa"]`, nowStr, ngFloatPtr(-10)), + }) + store := ngTestStore(nodes, []*StoreTx{tx}) + g := BuildFromStore(store) + + // Should have 2 edges: X↔R1 and Observer↔R1 + // But since path has 1 element, path[0]==path[last], so for ADVERTs + // both edge types point to the same hop. X↔R1 and Obs↔R1 = 2 edges. + edges := g.AllEdges() + if len(edges) != 2 { + t.Fatalf("expected 2 edges, got %d", len(edges)) + } + + // Check X↔R1 exists + found := false + for _, e := range edges { + if (e.NodeA == "aaaa1111" && e.NodeB == "r1aabbcc") || + (e.NodeA == "r1aabbcc" && e.NodeB == "aaaa1111") { + found = true + } + } + if !found { + t.Error("missing originator↔path[0] edge (X↔R1)") + } + + // Check Observer↔R1 exists + found = false + for _, e := range edges { + if (e.NodeA == "obs00001" && e.NodeB == "r1aabbcc") || + (e.NodeA == "r1aabbcc" && e.NodeB == "obs00001") { + found = true + } + } + if !found { + t.Error("missing observer↔path[last] edge (Observer↔R1)") + } +} + +func TestBuildNeighborGraph_AdvertMultiHopPath(t *testing.T) { + // ADVERT from X, path=["R1","R2"] → X↔R1 and Observer↔R2 + nodes := []nodeInfo{ + {PublicKey: "aaaa1111", Name: "NodeX"}, + {PublicKey: "r1aabbcc", Name: "R1"}, + {PublicKey: "r2ddeeff", Name: "R2"}, + {PublicKey: "obs00001", Name: "Observer"}, + } + tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{ + ngMakeObs("obs00001", `["r1aa","r2dd"]`, nowStr, nil), + }) + store := ngTestStore(nodes, []*StoreTx{tx}) + g := BuildFromStore(store) + + edges := g.AllEdges() + if len(edges) != 2 { + t.Fatalf("expected 2 edges, got %d", len(edges)) + } + + // X↔R1 + hasXR1 := false + hasObsR2 := false + for _, e := range edges { + if (e.NodeA == "aaaa1111" && e.NodeB == "r1aabbcc") || (e.NodeA == "r1aabbcc" && e.NodeB == "aaaa1111") { + hasXR1 = true + } + if (e.NodeA == "obs00001" && e.NodeB == "r2ddeeff") || (e.NodeA == "r2ddeeff" && e.NodeB == "obs00001") { + hasObsR2 = true + } + } + if !hasXR1 { + t.Error("missing X↔R1 edge") + } + if !hasObsR2 { + t.Error("missing Observer↔R2 edge") + } +} + +func TestBuildNeighborGraph_AdvertZeroHop(t *testing.T) { + // ADVERT from X, path=[] → X↔Observer direct edge + nodes := []nodeInfo{ + {PublicKey: "aaaa1111", Name: "NodeX"}, + {PublicKey: "obs00001", Name: "Observer"}, + } + tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{ + ngMakeObs("obs00001", `[]`, nowStr, nil), + }) + store := ngTestStore(nodes, []*StoreTx{tx}) + g := BuildFromStore(store) + + edges := g.AllEdges() + if len(edges) != 1 { + t.Fatalf("expected 1 edge, got %d", len(edges)) + } + e := edges[0] + if !((e.NodeA == "aaaa1111" && e.NodeB == "obs00001") || (e.NodeA == "obs00001" && e.NodeB == "aaaa1111")) { + t.Errorf("expected X↔Observer edge, got %s↔%s", e.NodeA, e.NodeB) + } + if e.Ambiguous { + t.Error("zero-hop edge should not be ambiguous") + } +} + +func TestBuildNeighborGraph_NonAdvertEmptyPath(t *testing.T) { + // Non-ADVERT, path=[] → no edges + nodes := []nodeInfo{ + {PublicKey: "aaaa1111", Name: "NodeX"}, + {PublicKey: "obs00001", Name: "Observer"}, + } + tx := ngMakeTx(1, 2, ngFromNodeJSON("aaaa1111"), []*StoreObs{ + ngMakeObs("obs00001", `[]`, nowStr, nil), + }) + store := ngTestStore(nodes, []*StoreTx{tx}) + g := BuildFromStore(store) + + if len(g.edges) != 0 { + t.Errorf("expected 0 edges for non-ADVERT empty path, got %d", len(g.edges)) + } +} + +func TestBuildNeighborGraph_NonAdvertOnlyObserverEdge(t *testing.T) { + // Non-ADVERT with path=["R1","R2"] → only Observer↔R2, NO originator edge + nodes := []nodeInfo{ + {PublicKey: "aaaa1111", Name: "NodeX"}, + {PublicKey: "r1aabbcc", Name: "R1"}, + {PublicKey: "r2ddeeff", Name: "R2"}, + {PublicKey: "obs00001", Name: "Observer"}, + } + tx := ngMakeTx(1, 2, ngFromNodeJSON("aaaa1111"), []*StoreObs{ + ngMakeObs("obs00001", `["r1aa","r2dd"]`, nowStr, nil), + }) + store := ngTestStore(nodes, []*StoreTx{tx}) + g := BuildFromStore(store) + + edges := g.AllEdges() + if len(edges) != 1 { + t.Fatalf("expected 1 edge, got %d", len(edges)) + } + e := edges[0] + if !((e.NodeA == "obs00001" && e.NodeB == "r2ddeeff") || (e.NodeA == "r2ddeeff" && e.NodeB == "obs00001")) { + t.Errorf("expected Observer↔R2 edge, got %s↔%s", e.NodeA, e.NodeB) + } +} + +func TestBuildNeighborGraph_NonAdvertSingleHop(t *testing.T) { + // Non-ADVERT with path=["R1"] → Observer↔R1 only + nodes := []nodeInfo{ + {PublicKey: "aaaa1111", Name: "NodeX"}, + {PublicKey: "r1aabbcc", Name: "R1"}, + {PublicKey: "obs00001", Name: "Observer"}, + } + tx := ngMakeTx(1, 2, ngFromNodeJSON("aaaa1111"), []*StoreObs{ + ngMakeObs("obs00001", `["r1aa"]`, nowStr, nil), + }) + store := ngTestStore(nodes, []*StoreTx{tx}) + g := BuildFromStore(store) + + edges := g.AllEdges() + if len(edges) != 1 { + t.Fatalf("expected 1 edge, got %d", len(edges)) + } + e := edges[0] + if !((e.NodeA == "obs00001" && e.NodeB == "r1aabbcc") || (e.NodeA == "r1aabbcc" && e.NodeB == "obs00001")) { + t.Errorf("expected Observer↔R1, got %s↔%s", e.NodeA, e.NodeB) + } +} + +func TestBuildNeighborGraph_HashCollision(t *testing.T) { + // Two nodes share prefix "a3" → ambiguous edge + nodes := []nodeInfo{ + {PublicKey: "aaaa1111", Name: "NodeX"}, + {PublicKey: "a3bb1111", Name: "CandidateA"}, + {PublicKey: "a3bb2222", Name: "CandidateB"}, + {PublicKey: "obs00001", Name: "Observer"}, + } + tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{ + ngMakeObs("obs00001", `["a3bb"]`, nowStr, nil), + }) + store := ngTestStore(nodes, []*StoreTx{tx}) + g := BuildFromStore(store) + + // Should have ambiguous edges + var ambigCount int + for _, e := range g.AllEdges() { + if e.Ambiguous { + ambigCount++ + if len(e.Candidates) < 2 { + t.Errorf("expected >=2 candidates, got %d", len(e.Candidates)) + } + } + } + if ambigCount == 0 { + t.Error("expected at least one ambiguous edge for hash collision") + } +} + +func TestBuildNeighborGraph_JaccardScoring(t *testing.T) { + // Test Jaccard similarity computation directly + a := map[string]bool{"x": true, "y": true, "z": true} + b := map[string]bool{"y": true, "z": true, "w": true} + j := jaccardSimilarity(a, b) + // intersection = {y, z} = 2, union = {x, y, z, w} = 4 → 0.5 + if math.Abs(j-0.5) > 0.001 { + t.Errorf("expected Jaccard 0.5, got %f", j) + } + + // Empty sets + j = jaccardSimilarity(nil, nil) + if j != 0 { + t.Errorf("expected 0 for empty sets, got %f", j) + } +} + +func TestBuildNeighborGraph_ConfidenceAutoResolve(t *testing.T) { + // Setup: NodeX has known neighbors N1, N2, N3 (resolved edges). + // CandidateA also has known neighbors N1, N2, N3 (high Jaccard with X). + // CandidateB has no known neighbors (Jaccard = 0). + // An ambiguous edge X↔prefix "a3" with candidates [A, B] should auto-resolve to A. + nodes := []nodeInfo{ + {PublicKey: "aaaa1111", Name: "NodeX"}, + {PublicKey: "n1111111", Name: "N1"}, + {PublicKey: "n2222222", Name: "N2"}, + {PublicKey: "n3333333", Name: "N3"}, + {PublicKey: "a3001111", Name: "CandidateA"}, + {PublicKey: "a3002222", Name: "CandidateB"}, + {PublicKey: "obs00001", Name: "Observer"}, + } + + // Create resolved edges: X↔N1, X↔N2, X↔N3, A↔N1, A↔N2, A↔N3 + // Then an ambiguous edge X↔"a300" prefix with 3+ observations. + var txs []*StoreTx + txID := 1 + + // X sends ADVERTs through N1, N2, N3 + for _, nhop := range []string{"n111", "n222", "n333"} { + txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{ + ngMakeObs("obs00001", `["`+nhop+`"]`, nowStr, nil), + })) + txID++ + } + + // CandidateA sends ADVERTs through N1, N2, N3 + for _, nhop := range []string{"n111", "n222", "n333"} { + txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("a3001111"), []*StoreObs{ + ngMakeObs("obs00001", `["`+nhop+`"]`, nowStr, nil), + })) + txID++ + } + + // Ambiguous edge: X sends ADVERTs with path[0]="a300" (matches both candidates) + // Need 3+ observations for confidence threshold. + for i := 0; i < 3; i++ { + txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{ + ngMakeObs("obs00001", `["a300"]`, nowStr, nil), + })) + txID++ + } + + store := ngTestStore(nodes, txs) + g := BuildFromStore(store) + + // The ambiguous edge X↔a300 should have been resolved to CandidateA + neighbors := g.Neighbors("aaaa1111") + foundA := false + for _, e := range neighbors { + other := e.NodeB + if e.NodeA != "aaaa1111" { + other = e.NodeA + } + if other == "a3001111" { + foundA = true + if e.Ambiguous { + t.Error("edge should have been resolved (not ambiguous)") + } + } + } + if !foundA { + t.Error("expected edge X↔CandidateA to be auto-resolved") + } +} + +func TestBuildNeighborGraph_EqualScoresAmbiguous(t *testing.T) { + // Two candidates with identical neighbor sets → should NOT auto-resolve. + nodes := []nodeInfo{ + {PublicKey: "aaaa1111", Name: "NodeX"}, + {PublicKey: "n1111111", Name: "N1"}, + {PublicKey: "a3001111", Name: "CandidateA"}, + {PublicKey: "a3002222", Name: "CandidateB"}, + {PublicKey: "obs00001", Name: "Observer"}, + } + + var txs []*StoreTx + txID := 1 + + // X↔N1 + txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{ + ngMakeObs("obs00001", `["n111"]`, nowStr, nil), + })) + txID++ + + // Both candidates have same neighbor (N1) + txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("a3001111"), []*StoreObs{ + ngMakeObs("obs00001", `["n111"]`, nowStr, nil), + })) + txID++ + txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("a3002222"), []*StoreObs{ + ngMakeObs("obs00001", `["n111"]`, nowStr, nil), + })) + txID++ + + // Ambiguous edge with 3+ observations + for i := 0; i < 3; i++ { + txs = append(txs, ngMakeTx(txID, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{ + ngMakeObs("obs00001", `["a300"]`, nowStr, nil), + })) + txID++ + } + + store := ngTestStore(nodes, txs) + g := BuildFromStore(store) + + // Should remain ambiguous + var ambigFound bool + for _, e := range g.AllEdges() { + if e.Ambiguous && e.Prefix == "a300" { + ambigFound = true + } + } + if !ambigFound { + t.Error("expected ambiguous edge to remain unresolved with equal scores") + } +} + +func TestBuildNeighborGraph_ObserverSelfEdgeGuard(t *testing.T) { + // Observer's own prefix in path → should NOT create self-edge. + nodes := []nodeInfo{ + {PublicKey: "aaaa1111", Name: "NodeX"}, + {PublicKey: "obs00001", Name: "Observer"}, + } + tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{ + ngMakeObs("obs00001", `["obs0"]`, nowStr, nil), + }) + store := ngTestStore(nodes, []*StoreTx{tx}) + g := BuildFromStore(store) + + // Check no self-edge for observer + for _, e := range g.AllEdges() { + if e.NodeA == e.NodeB && e.NodeA == "obs00001" { + t.Error("self-edge created for observer") + } + } +} + +func TestBuildNeighborGraph_OrphanPrefix(t *testing.T) { + // Path contains prefix matching zero nodes → edge recorded as unresolved. + nodes := []nodeInfo{ + {PublicKey: "aaaa1111", Name: "NodeX"}, + {PublicKey: "obs00001", Name: "Observer"}, + } + tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{ + ngMakeObs("obs00001", `["ff99"]`, nowStr, nil), + }) + store := ngTestStore(nodes, []*StoreTx{tx}) + g := BuildFromStore(store) + + // Should have ambiguous edges with empty candidates. + var orphanFound bool + for _, e := range g.AllEdges() { + if e.Ambiguous && len(e.Candidates) == 0 { + orphanFound = true + if e.Prefix != "ff99" { + t.Errorf("expected prefix ff99, got %s", e.Prefix) + } + } + } + if !orphanFound { + t.Error("expected orphan prefix edge with empty candidates") + } +} + +func TestAffinityScore_Fresh(t *testing.T) { + e := &NeighborEdge{Count: 100, LastSeen: time.Now()} + s := e.Score(time.Now()) + if s < 0.99 || s > 1.0 { + t.Errorf("expected score ≈ 1.0, got %f", s) + } +} + +func TestAffinityScore_Decayed(t *testing.T) { + e := &NeighborEdge{Count: 100, LastSeen: time.Now().Add(-7 * 24 * time.Hour)} + s := e.Score(time.Now()) + // 7 days → half-life → ~0.5 + if math.Abs(s-0.5) > 0.05 { + t.Errorf("expected score ≈ 0.5, got %f", s) + } +} + +func TestAffinityScore_LowCount(t *testing.T) { + e := &NeighborEdge{Count: 5, LastSeen: time.Now()} + s := e.Score(time.Now()) + // 5/100 = 0.05 + if math.Abs(s-0.05) > 0.01 { + t.Errorf("expected score ≈ 0.05, got %f", s) + } +} + +func TestAffinityScore_StaleAndLow(t *testing.T) { + e := &NeighborEdge{Count: 5, LastSeen: time.Now().Add(-30 * 24 * time.Hour)} + s := e.Score(time.Now()) + // Very small + if s > 0.01 { + t.Errorf("expected score ≈ 0, got %f", s) + } +} + +func TestBuildNeighborGraph_CountAccumulation(t *testing.T) { + nodes := []nodeInfo{ + {PublicKey: "aaaa1111", Name: "NodeX"}, + {PublicKey: "r1aabbcc", Name: "R1"}, + {PublicKey: "obs00001", Name: "Observer"}, + } + + var txs []*StoreTx + for i := 0; i < 5; i++ { + txs = append(txs, ngMakeTx(i+1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{ + ngMakeObs("obs00001", `["r1aa"]`, nowStr, nil), + })) + } + + store := ngTestStore(nodes, txs) + g := BuildFromStore(store) + + // Check count on X↔R1 edge + for _, e := range g.AllEdges() { + if (e.NodeA == "aaaa1111" && e.NodeB == "r1aabbcc") || (e.NodeA == "r1aabbcc" && e.NodeB == "aaaa1111") { + if e.Count != 5 { + t.Errorf("expected count 5, got %d", e.Count) + } + return + } + } + t.Error("X↔R1 edge not found") +} + +func TestBuildNeighborGraph_MultipleObservers(t *testing.T) { + nodes := []nodeInfo{ + {PublicKey: "aaaa1111", Name: "NodeX"}, + {PublicKey: "r1aabbcc", Name: "R1"}, + {PublicKey: "obs00001", Name: "Obs1"}, + {PublicKey: "obs00002", Name: "Obs2"}, + } + + tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{ + ngMakeObs("obs00001", `["r1aa"]`, nowStr, nil), + ngMakeObs("obs00002", `["r1aa"]`, nowStr, nil), + }) + + store := ngTestStore(nodes, []*StoreTx{tx}) + g := BuildFromStore(store) + + for _, e := range g.AllEdges() { + if (e.NodeA == "aaaa1111" && e.NodeB == "r1aabbcc") || (e.NodeA == "r1aabbcc" && e.NodeB == "aaaa1111") { + if len(e.Observers) != 2 { + t.Errorf("expected 2 observers, got %d", len(e.Observers)) + } + if !e.Observers["obs00001"] || !e.Observers["obs00002"] { + t.Error("missing expected observer") + } + return + } + } + t.Error("X↔R1 edge not found") +} + +func TestBuildNeighborGraph_TimeDecayOldObservations(t *testing.T) { + nodes := []nodeInfo{ + {PublicKey: "aaaa1111", Name: "NodeX"}, + {PublicKey: "r1aabbcc", Name: "R1"}, + {PublicKey: "obs00001", Name: "Observer"}, + } + + tx := ngMakeTx(1, 4, ngFromNodeJSON("aaaa1111"), []*StoreObs{ + ngMakeObs("obs00001", `["r1aa"]`, monthAgoStr, nil), + }) + + store := ngTestStore(nodes, []*StoreTx{tx}) + g := BuildFromStore(store) + + for _, e := range g.AllEdges() { + if (e.NodeA == "aaaa1111" && e.NodeB == "r1aabbcc") || (e.NodeA == "r1aabbcc" && e.NodeB == "aaaa1111") { + score := e.Score(time.Now()) + if score > 0.05 { + t.Errorf("expected decayed score < 0.05, got %f", score) + } + return + } + } + t.Error("X↔R1 edge not found") +} + +func TestBuildNeighborGraph_ADVERTOnlyConstraint(t *testing.T) { + // Non-ADVERT: should NOT create originator↔path[0] edge, only observer↔path[last]. + nodes := []nodeInfo{ + {PublicKey: "aaaa1111", Name: "NodeX"}, + {PublicKey: "r1aabbcc", Name: "R1"}, + {PublicKey: "r2ddeeff", Name: "R2"}, + {PublicKey: "obs00001", Name: "Observer"}, + } + tx := ngMakeTx(1, 2, ngFromNodeJSON("aaaa1111"), []*StoreObs{ + ngMakeObs("obs00001", `["r1aa","r2dd"]`, nowStr, nil), + }) + store := ngTestStore(nodes, []*StoreTx{tx}) + g := BuildFromStore(store) + + for _, e := range g.AllEdges() { + a, b := e.NodeA, e.NodeB + if (a == "aaaa1111" && b == "r1aabbcc") || (a == "r1aabbcc" && b == "aaaa1111") { + t.Error("non-ADVERT should NOT produce originator↔path[0] edge") + } + } + + // Should have Observer↔R2 + found := false + for _, e := range g.AllEdges() { + if (e.NodeA == "obs00001" && e.NodeB == "r2ddeeff") || (e.NodeA == "r2ddeeff" && e.NodeB == "obs00001") { + found = true + } + } + if !found { + t.Error("missing Observer↔R2 edge from non-ADVERT") + } +} + +func TestNeighborGraph_CacheTTL(t *testing.T) { + g := NewNeighborGraph() + if !g.IsStale() { + t.Error("new graph should be stale") + } + g.mu.Lock() + g.builtAt = time.Now() + g.mu.Unlock() + if g.IsStale() { + t.Error("just-built graph should not be stale") + } + g.mu.Lock() + g.builtAt = time.Now().Add(-2 * neighborGraphTTL) + g.mu.Unlock() + if !g.IsStale() { + t.Error("old graph should be stale") + } +}