From e66085092ef5ee8d136acfb473778eb0212ef33c Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Thu, 2 Apr 2026 21:30:23 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20neighbor=20affinity=20API=20endpoints?= =?UTF-8?q?=20(#482)=20=E2=80=94=20milestone=202=20(#508)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Milestone 2 of the neighbor affinity graph (#482). Adds two API endpoints that expose the neighbor graph built in M1 (PR #507). ### Endpoints #### `GET /api/nodes/{pubkey}/neighbors` Returns neighbors for a specific node with affinity scores. **Query params:** `min_count` (default 1), `min_score` (default 0.0), `include_ambiguous` (default true) **Response shape:** ```json { "node": "pubkey", "neighbors": [ { "pubkey": "...", "prefix": "BB", "name": "...", "role": "repeater", "count": 847, "score": 0.95, "first_seen": "...", "last_seen": "...", "avg_snr": -8.2, "observers": ["obs1"], "ambiguous": false } ], "total_observations": 847 } ``` Ambiguous entries have `candidates` array; unresolved prefixes have `unresolved: true`. #### `GET /api/analytics/neighbor-graph` Returns full graph summary for analytics/visualization. **Query params:** `min_count` (default 5), `min_score` (default 0.1), `region` (IATA code filter) **Response shape:** ```json { "nodes": [{ "pubkey": "...", "name": "...", "role": "...", "neighbor_count": 5 }], "edges": [{ "source": "...", "target": "...", "weight": 847, "score": 0.95, "ambiguous": false }], "stats": { "total_nodes": 42, "total_edges": 87, "ambiguous_edges": 3, "avg_cluster_size": 4.2 } } ``` ### Wiring - `NeighborGraph` + `neighborMu` added to `Server` struct - Lazy initialization: graph built on first API call, cached with 60s TTL - Node name/role lookups via existing `getCachedNodesAndPM()` - Region filtering via existing `resolveRegionObservers()` ### Tests (15 tests) - Empty graph, single neighbor, multiple neighbors (sorted by score) - Ambiguous candidates with candidate list - Unresolved prefix (orphan) with `unresolved: true` - `min_count` filter, `min_score` filter, `include_ambiguous=false` filter - Unknown node returns 200 with empty neighbors - Graph endpoint: empty, with edges, default min_count, ambiguous count - Region filter (graceful when no store) - Response shape validation (all required keys present) All existing tests continue to pass. Part of #482 --------- Co-authored-by: you --- cmd/server/neighbor_api.go | 361 +++++++++++++++++++++++++++++ cmd/server/neighbor_api_test.go | 396 ++++++++++++++++++++++++++++++++ cmd/server/routes.go | 6 + 3 files changed, 763 insertions(+) create mode 100644 cmd/server/neighbor_api.go create mode 100644 cmd/server/neighbor_api_test.go diff --git a/cmd/server/neighbor_api.go b/cmd/server/neighbor_api.go new file mode 100644 index 00000000..da303e2e --- /dev/null +++ b/cmd/server/neighbor_api.go @@ -0,0 +1,361 @@ +package main + +import ( + "encoding/json" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "github.com/gorilla/mux" +) + +// ─── Neighbor API response types ─────────────────────────────────────────────── + +type NeighborResponse struct { + Node string `json:"node"` + Neighbors []NeighborEntry `json:"neighbors"` + TotalObservations int `json:"total_observations"` +} + +type NeighborEntry struct { + Pubkey *string `json:"pubkey"` + Prefix string `json:"prefix"` + Name *string `json:"name"` + Role *string `json:"role"` + Count int `json:"count"` + Score float64 `json:"score"` + FirstSeen string `json:"first_seen"` + LastSeen string `json:"last_seen"` + AvgSNR *float64 `json:"avg_snr"` + Observers []string `json:"observers"` + Ambiguous bool `json:"ambiguous"` + Unresolved bool `json:"unresolved,omitempty"` + Candidates []CandidateEntry `json:"candidates,omitempty"` +} + +type CandidateEntry struct { + Pubkey string `json:"pubkey"` + Name string `json:"name"` + Role string `json:"role"` +} + +type NeighborGraphResponse struct { + Nodes []GraphNode `json:"nodes"` + Edges []GraphEdge `json:"edges"` + Stats GraphStats `json:"stats"` +} + +type GraphNode struct { + Pubkey string `json:"pubkey"` + Name string `json:"name"` + Role string `json:"role"` + NeighborCount int `json:"neighbor_count"` +} + +type GraphEdge struct { + Source string `json:"source"` + Target string `json:"target"` + Weight int `json:"weight"` + Score float64 `json:"score"` + Bidirectional bool `json:"bidirectional"` + AvgSNR *float64 `json:"avg_snr"` + Ambiguous bool `json:"ambiguous"` +} + +type GraphStats struct { + TotalNodes int `json:"total_nodes"` + TotalEdges int `json:"total_edges"` + AmbiguousEdges int `json:"ambiguous_edges"` + AvgClusterSize float64 `json:"avg_cluster_size"` +} + +// ─── Graph accessor on Server ────────────────────────────────────────────────── + +// getNeighborGraph returns the current neighbor graph, rebuilding if stale. +func (s *Server) getNeighborGraph() *NeighborGraph { + s.neighborMu.Lock() + defer s.neighborMu.Unlock() + + if s.neighborGraph == nil || s.neighborGraph.IsStale() { + if s.store != nil { + s.neighborGraph = BuildFromStore(s.store) + } else { + s.neighborGraph = NewNeighborGraph() + } + } + return s.neighborGraph +} + +// ─── Handlers ────────────────────────────────────────────────────────────────── + +func (s *Server) handleNodeNeighbors(w http.ResponseWriter, r *http.Request) { + pubkey := strings.ToLower(mux.Vars(r)["pubkey"]) + + minCount := 1 + if v := r.URL.Query().Get("min_count"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + minCount = n + } + } + minScore := 0.0 + if v := r.URL.Query().Get("min_score"); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil { + minScore = f + } + } + includeAmbiguous := true + if v := r.URL.Query().Get("include_ambiguous"); v == "false" { + includeAmbiguous = false + } + + graph := s.getNeighborGraph() + edges := graph.Neighbors(pubkey) + now := time.Now() + + // Build node info lookup for names/roles. + nodeMap := s.buildNodeInfoMap() + + var entries []NeighborEntry + totalObs := 0 + + for _, e := range edges { + score := e.Score(now) + if e.Count < minCount || score < minScore { + continue + } + if e.Ambiguous && !includeAmbiguous { + continue + } + + totalObs += e.Count + + // Determine the "other" node (neighbor of the queried pubkey). + neighborPK := e.NodeA + if strings.EqualFold(neighborPK, pubkey) { + neighborPK = e.NodeB + } + + entry := NeighborEntry{ + Prefix: e.Prefix, + Count: e.Count, + Score: score, + FirstSeen: e.FirstSeen.UTC().Format(time.RFC3339), + LastSeen: e.LastSeen.UTC().Format(time.RFC3339), + Ambiguous: e.Ambiguous, + Observers: observerList(e.Observers), + } + + if e.SNRCount > 0 { + avg := e.AvgSNR() + entry.AvgSNR = &avg + } + + if e.Ambiguous { + if len(e.Candidates) == 0 { + entry.Unresolved = true + } + for _, cpk := range e.Candidates { + ce := CandidateEntry{Pubkey: cpk} + if info, ok := nodeMap[strings.ToLower(cpk)]; ok { + ce.Name = info.Name + ce.Role = info.Role + } + entry.Candidates = append(entry.Candidates, ce) + } + } else if neighborPK != "" { + entry.Pubkey = &neighborPK + if info, ok := nodeMap[strings.ToLower(neighborPK)]; ok { + entry.Name = &info.Name + entry.Role = &info.Role + } + } + + entries = append(entries, entry) + } + + // Sort by score descending. + sort.Slice(entries, func(i, j int) bool { + return entries[i].Score > entries[j].Score + }) + + if entries == nil { + entries = []NeighborEntry{} + } + + resp := NeighborResponse{ + Node: pubkey, + Neighbors: entries, + TotalObservations: totalObs, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +func (s *Server) handleNeighborGraph(w http.ResponseWriter, r *http.Request) { + minCount := 5 + if v := r.URL.Query().Get("min_count"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + minCount = n + } + } + minScore := 0.1 + if v := r.URL.Query().Get("min_score"); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil { + minScore = f + } + } + region := r.URL.Query().Get("region") + roleFilter := strings.ToLower(r.URL.Query().Get("role")) + + graph := s.getNeighborGraph() + allEdges := graph.AllEdges() + now := time.Now() + + // Resolve region observers if filtering. + var regionObs map[string]bool + if region != "" && s.store != nil { + regionObs = s.store.resolveRegionObservers(region) + } + + nodeMap := s.buildNodeInfoMap() + nodeSet := make(map[string]bool) + var filteredEdges []GraphEdge + ambiguousCount := 0 + + for _, e := range allEdges { + score := e.Score(now) + if e.Count < minCount || score < minScore { + continue + } + + // Role filter: at least one endpoint must match the role. + if roleFilter != "" && nodeMap != nil { + aInfo, aOK := nodeMap[strings.ToLower(e.NodeA)] + bInfo, bOK := nodeMap[strings.ToLower(e.NodeB)] + aMatch := aOK && strings.EqualFold(aInfo.Role, roleFilter) + bMatch := bOK && strings.EqualFold(bInfo.Role, roleFilter) + if !aMatch && !bMatch { + continue + } + } + + // Region filter: at least one observer must be in the region. + if regionObs != nil { + match := false + for obs := range e.Observers { + if regionObs[obs] { + match = true + break + } + } + if !match { + continue + } + } + + ge := GraphEdge{ + Source: e.NodeA, + Target: e.NodeB, + Weight: e.Count, + Score: score, + Bidirectional: true, + Ambiguous: e.Ambiguous, + } + if e.SNRCount > 0 { + avg := e.AvgSNR() + ge.AvgSNR = &avg + } + + if e.Ambiguous { + ambiguousCount++ + // For ambiguous edges, use prefix as target. + if e.NodeB == "" { + ge.Target = "prefix:" + e.Prefix + } + } + + filteredEdges = append(filteredEdges, ge) + + // Track nodes. + 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 node list. + // Count neighbors per node from filtered edges. + neighborCounts := make(map[string]int) + for _, ge := range filteredEdges { + neighborCounts[ge.Source]++ + neighborCounts[ge.Target]++ + } + + var nodes []GraphNode + for pk := range nodeSet { + gn := GraphNode{Pubkey: pk, NeighborCount: neighborCounts[pk]} + if info, ok := nodeMap[strings.ToLower(pk)]; ok { + gn.Name = info.Name + gn.Role = info.Role + } + nodes = append(nodes, gn) + } + + if filteredEdges == nil { + filteredEdges = []GraphEdge{} + } + if nodes == nil { + nodes = []GraphNode{} + } + + avgCluster := 0.0 + if len(nodes) > 0 { + avgCluster = float64(len(filteredEdges)*2) / float64(len(nodes)) + } + + resp := NeighborGraphResponse{ + Nodes: nodes, + Edges: filteredEdges, + Stats: GraphStats{ + TotalNodes: len(nodes), + TotalEdges: len(filteredEdges), + AmbiguousEdges: ambiguousCount, + AvgClusterSize: avgCluster, + }, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +// ─── Helpers ─────────────────────────────────────────────────────────────────── + +func observerList(m map[string]bool) []string { + if len(m) == 0 { + return []string{} + } + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// buildNodeInfoMap returns a map of lowercase pubkey → nodeInfo for name/role lookups. +func (s *Server) buildNodeInfoMap() map[string]nodeInfo { + if s.store == nil { + return nil + } + nodes, _ := s.store.getCachedNodesAndPM() + m := make(map[string]nodeInfo, len(nodes)) + for _, n := range nodes { + m[strings.ToLower(n.PublicKey)] = n + } + return m +} diff --git a/cmd/server/neighbor_api_test.go b/cmd/server/neighbor_api_test.go new file mode 100644 index 00000000..5081586a --- /dev/null +++ b/cmd/server/neighbor_api_test.go @@ -0,0 +1,396 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gorilla/mux" +) + +// ─── Helpers ─────────────────────────────────────────────────────────────────── + +// makeTestServer creates a Server with a pre-built neighbor graph for testing. +func makeTestServer(graph *NeighborGraph) *Server { + srv := &Server{ + perfStats: NewPerfStats(), + } + srv.neighborGraph = graph + return srv +} + +// makeTestGraph creates a graph with given edges for testing. +func makeTestGraph(edges ...*NeighborEdge) *NeighborGraph { + g := NewNeighborGraph() + g.mu.Lock() + for _, e := range edges { + key := makeEdgeKey(e.NodeA, e.NodeB) + if e.NodeB == "" { + key = makeEdgeKey(e.NodeA, "prefix:"+e.Prefix) + } + e.NodeA = key.A + if e.NodeB != "" { + e.NodeB = key.B + } + g.edges[key] = e + g.byNode[key.A] = append(g.byNode[key.A], e) + if key.B != "" && key.B != key.A { + g.byNode[key.B] = append(g.byNode[key.B], e) + } + } + g.builtAt = time.Now() + g.mu.Unlock() + return g +} + +func newEdge(a, b, prefix string, count int, lastSeen time.Time) *NeighborEdge { + return &NeighborEdge{ + NodeA: a, + NodeB: b, + Prefix: prefix, + Count: count, + FirstSeen: lastSeen.Add(-24 * time.Hour), + LastSeen: lastSeen, + Observers: map[string]bool{"obs1": true}, + SNRSum: -8.0, + SNRCount: 1, + } +} + +func newAmbiguousEdge(knownPK, prefix string, candidates []string, count int, lastSeen time.Time) *NeighborEdge { + return &NeighborEdge{ + NodeA: knownPK, + NodeB: "", + Prefix: prefix, + Count: count, + FirstSeen: lastSeen.Add(-24 * time.Hour), + LastSeen: lastSeen, + Observers: map[string]bool{"obs1": true}, + Ambiguous: true, + Candidates: candidates, + } +} + +func serveRequest(srv *Server, method, path string) *httptest.ResponseRecorder { + router := mux.NewRouter() + router.HandleFunc("/api/nodes/{pubkey}/neighbors", srv.handleNodeNeighbors).Methods("GET") + router.HandleFunc("/api/analytics/neighbor-graph", srv.handleNeighborGraph).Methods("GET") + + req := httptest.NewRequest(method, path, nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + return rr +} + +// ─── Tests: /api/nodes/{pubkey}/neighbors ────────────────────────────────────── + +func TestNeighborAPI_EmptyGraph(t *testing.T) { + srv := makeTestServer(makeTestGraph()) + rr := serveRequest(srv, "GET", "/api/nodes/deadbeef/neighbors") + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + + var resp NeighborResponse + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("bad JSON: %v", err) + } + if resp.Node != "deadbeef" { + t.Errorf("node = %q, want deadbeef", resp.Node) + } + if len(resp.Neighbors) != 0 { + t.Errorf("expected 0 neighbors, got %d", len(resp.Neighbors)) + } + if resp.TotalObservations != 0 { + t.Errorf("expected 0 observations, got %d", resp.TotalObservations) + } +} + +func TestNeighborAPI_SingleNeighbor(t *testing.T) { + now := time.Now() + e := newEdge("aaaa", "bbbb", "bb", 50, now) + srv := makeTestServer(makeTestGraph(e)) + + rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors") + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + + var resp NeighborResponse + json.Unmarshal(rr.Body.Bytes(), &resp) + + if len(resp.Neighbors) != 1 { + t.Fatalf("expected 1 neighbor, got %d", len(resp.Neighbors)) + } + n := resp.Neighbors[0] + if n.Pubkey == nil || *n.Pubkey != "bbbb" { + t.Errorf("expected pubkey bbbb, got %v", n.Pubkey) + } + if n.Count != 50 { + t.Errorf("expected count 50, got %d", n.Count) + } + if n.Score <= 0 { + t.Errorf("expected positive score, got %f", n.Score) + } + if n.Ambiguous { + t.Error("expected not ambiguous") + } +} + +func TestNeighborAPI_MultipleNeighbors(t *testing.T) { + now := time.Now() + e1 := newEdge("aaaa", "bbbb", "bb", 100, now) + e2 := newEdge("aaaa", "cccc", "cc", 10, now) + srv := makeTestServer(makeTestGraph(e1, e2)) + + rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors") + var resp NeighborResponse + json.Unmarshal(rr.Body.Bytes(), &resp) + + if len(resp.Neighbors) != 2 { + t.Fatalf("expected 2 neighbors, got %d", len(resp.Neighbors)) + } + // Should be sorted by score descending. + if resp.Neighbors[0].Score < resp.Neighbors[1].Score { + t.Error("expected sorted by score descending") + } + if resp.TotalObservations != 110 { + t.Errorf("expected 110 total observations, got %d", resp.TotalObservations) + } +} + +func TestNeighborAPI_AmbiguousCandidates(t *testing.T) { + now := time.Now() + e := newAmbiguousEdge("aaaa", "c0", []string{"c0de01", "c0de02"}, 12, now) + srv := makeTestServer(makeTestGraph(e)) + + rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors") + var resp NeighborResponse + json.Unmarshal(rr.Body.Bytes(), &resp) + + if len(resp.Neighbors) != 1 { + t.Fatalf("expected 1 neighbor, got %d", len(resp.Neighbors)) + } + n := resp.Neighbors[0] + if !n.Ambiguous { + t.Error("expected ambiguous") + } + if n.Pubkey != nil { + t.Errorf("expected nil pubkey for ambiguous, got %v", n.Pubkey) + } + if len(n.Candidates) != 2 { + t.Fatalf("expected 2 candidates, got %d", len(n.Candidates)) + } +} + +func TestNeighborAPI_UnresolvedPrefix(t *testing.T) { + now := time.Now() + e := newAmbiguousEdge("aaaa", "ff", []string{}, 3, now) + srv := makeTestServer(makeTestGraph(e)) + + rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors") + var resp NeighborResponse + json.Unmarshal(rr.Body.Bytes(), &resp) + + if len(resp.Neighbors) != 1 { + t.Fatalf("expected 1 neighbor, got %d", len(resp.Neighbors)) + } + n := resp.Neighbors[0] + if !n.Unresolved { + t.Error("expected unresolved=true") + } + if len(n.Candidates) != 0 { + t.Error("expected empty candidates for unresolved") + } +} + +func TestNeighborAPI_MinCountFilter(t *testing.T) { + now := time.Now() + e1 := newEdge("aaaa", "bbbb", "bb", 100, now) + e2 := newEdge("aaaa", "cccc", "cc", 2, now) + srv := makeTestServer(makeTestGraph(e1, e2)) + + rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors?min_count=10") + var resp NeighborResponse + json.Unmarshal(rr.Body.Bytes(), &resp) + + if len(resp.Neighbors) != 1 { + t.Fatalf("expected 1 neighbor after min_count filter, got %d", len(resp.Neighbors)) + } + if *resp.Neighbors[0].Pubkey != "bbbb" { + t.Error("expected bbbb to survive filter") + } +} + +func TestNeighborAPI_MinScoreFilter(t *testing.T) { + now := time.Now() + e1 := newEdge("aaaa", "bbbb", "bb", 100, now) // score ~1.0 + e2 := newEdge("aaaa", "cccc", "cc", 1, now.Add(-30*24*time.Hour)) // very low score + srv := makeTestServer(makeTestGraph(e1, e2)) + + rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors?min_score=0.5") + var resp NeighborResponse + json.Unmarshal(rr.Body.Bytes(), &resp) + + if len(resp.Neighbors) != 1 { + t.Fatalf("expected 1 neighbor after min_score filter, got %d", len(resp.Neighbors)) + } +} + +func TestNeighborAPI_ExcludeAmbiguous(t *testing.T) { + now := time.Now() + e1 := newEdge("aaaa", "bbbb", "bb", 50, now) + e2 := newAmbiguousEdge("aaaa", "c0", []string{"c0de01"}, 10, now) + srv := makeTestServer(makeTestGraph(e1, e2)) + + rr := serveRequest(srv, "GET", "/api/nodes/aaaa/neighbors?include_ambiguous=false") + var resp NeighborResponse + json.Unmarshal(rr.Body.Bytes(), &resp) + + if len(resp.Neighbors) != 1 { + t.Fatalf("expected 1 non-ambiguous neighbor, got %d", len(resp.Neighbors)) + } +} + +func TestNeighborAPI_UnknownNode(t *testing.T) { + now := time.Now() + e := newEdge("aaaa", "bbbb", "bb", 50, now) + srv := makeTestServer(makeTestGraph(e)) + + rr := serveRequest(srv, "GET", "/api/nodes/unknown1234/neighbors") + if rr.Code != http.StatusOK { + t.Fatalf("expected 200 for unknown node, got %d", rr.Code) + } + + var resp NeighborResponse + json.Unmarshal(rr.Body.Bytes(), &resp) + if len(resp.Neighbors) != 0 { + t.Errorf("expected 0 neighbors for unknown node, got %d", len(resp.Neighbors)) + } +} + +// ─── Tests: /api/analytics/neighbor-graph ────────────────────────────────────── + +func TestNeighborGraphAPI_EmptyGraph(t *testing.T) { + srv := makeTestServer(makeTestGraph()) + rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph") + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + + var resp NeighborGraphResponse + json.Unmarshal(rr.Body.Bytes(), &resp) + + if len(resp.Edges) != 0 { + t.Errorf("expected 0 edges, got %d", len(resp.Edges)) + } + if resp.Stats.TotalEdges != 0 { + t.Errorf("expected 0 total edges, got %d", resp.Stats.TotalEdges) + } + if resp.Stats.TotalNodes != 0 { + t.Errorf("expected 0 total nodes, got %d", resp.Stats.TotalNodes) + } +} + +func TestNeighborGraphAPI_WithEdges(t *testing.T) { + now := time.Now() + e1 := newEdge("aaaa", "bbbb", "bb", 100, now) + e2 := newEdge("bbbb", "cccc", "cc", 50, now) + srv := makeTestServer(makeTestGraph(e1, e2)) + + rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph?min_count=1&min_score=0") + var resp NeighborGraphResponse + json.Unmarshal(rr.Body.Bytes(), &resp) + + if len(resp.Edges) != 2 { + t.Fatalf("expected 2 edges, got %d", len(resp.Edges)) + } + if resp.Stats.TotalNodes != 3 { + t.Errorf("expected 3 nodes, got %d", resp.Stats.TotalNodes) + } + if resp.Stats.TotalEdges != 2 { + t.Errorf("expected 2 total edges, got %d", resp.Stats.TotalEdges) + } +} + +func TestNeighborGraphAPI_MinCountDefault(t *testing.T) { + now := time.Now() + e1 := newEdge("aaaa", "bbbb", "bb", 100, now) // passes default min_count=5 + e2 := newEdge("aaaa", "cccc", "cc", 2, now) // fails default min_count=5 + srv := makeTestServer(makeTestGraph(e1, e2)) + + rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph") + var resp NeighborGraphResponse + json.Unmarshal(rr.Body.Bytes(), &resp) + + if len(resp.Edges) != 1 { + t.Fatalf("expected 1 edge with default min_count=5, got %d", len(resp.Edges)) + } +} + +func TestNeighborGraphAPI_AmbiguousEdgesCount(t *testing.T) { + now := time.Now() + e1 := newEdge("aaaa", "bbbb", "bb", 100, now) + e2 := newAmbiguousEdge("aaaa", "c0", []string{"c0de01", "c0de02"}, 50, now) + srv := makeTestServer(makeTestGraph(e1, e2)) + + rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph?min_count=1&min_score=0") + var resp NeighborGraphResponse + json.Unmarshal(rr.Body.Bytes(), &resp) + + if resp.Stats.AmbiguousEdges != 1 { + t.Errorf("expected 1 ambiguous edge, got %d", resp.Stats.AmbiguousEdges) + } +} + +func TestNeighborGraphAPI_RegionFilter(t *testing.T) { + now := time.Now() + // Edge with observer "obs-sjc" — would match region SJC if we had region resolution. + // Without a store, region filtering returns nothing (no observers match). + e1 := newEdge("aaaa", "bbbb", "bb", 100, now) + srv := makeTestServer(makeTestGraph(e1)) + // No store → region filter has no observers → filters everything out. + rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph?region=SJC&min_count=1&min_score=0") + var resp NeighborGraphResponse + json.Unmarshal(rr.Body.Bytes(), &resp) + + // With no store, regionObs is nil so filter is skipped → all edges returned. + // Actually: region="" when store is nil → regionObs stays nil → no filtering. + // Wait, we set region=SJC and store is nil → resolveRegionObservers won't be called + // because s.store is nil. So regionObs is nil → filter not applied. + // Let's just check it doesn't crash. + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } +} + +func TestNeighborGraphAPI_ResponseShape(t *testing.T) { + now := time.Now() + e := newEdge("aaaa", "bbbb", "bb", 100, now) + srv := makeTestServer(makeTestGraph(e)) + + rr := serveRequest(srv, "GET", "/api/analytics/neighbor-graph?min_count=1&min_score=0") + var raw map[string]interface{} + if err := json.Unmarshal(rr.Body.Bytes(), &raw); err != nil { + t.Fatalf("bad JSON: %v", err) + } + + // Verify top-level keys. + for _, key := range []string{"nodes", "edges", "stats"} { + if _, ok := raw[key]; !ok { + t.Errorf("missing key %q in response", key) + } + } + + // Verify stats keys. + stats := raw["stats"].(map[string]interface{}) + for _, key := range []string{"total_nodes", "total_edges", "ambiguous_edges", "avg_cluster_size"} { + if _, ok := stats[key]; !ok { + t.Errorf("missing stats key %q", key) + } + } +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 85ba5da6..6570ccf2 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -38,6 +38,10 @@ type Server struct { statsMu sync.Mutex statsCache *StatsResponse statsCachedAt time.Time + + // Neighbor affinity graph (lazy-built, cached with TTL) + neighborMu sync.Mutex + neighborGraph *NeighborGraph } // PerfStats tracks request performance. @@ -128,6 +132,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) { r.HandleFunc("/api/nodes/{pubkey}/health", s.handleNodeHealth).Methods("GET") r.HandleFunc("/api/nodes/{pubkey}/paths", s.handleNodePaths).Methods("GET") r.HandleFunc("/api/nodes/{pubkey}/analytics", s.handleNodeAnalytics).Methods("GET") + r.HandleFunc("/api/nodes/{pubkey}/neighbors", s.handleNodeNeighbors).Methods("GET") r.HandleFunc("/api/nodes/{pubkey}", s.handleNodeDetail).Methods("GET") r.HandleFunc("/api/nodes", s.handleNodes).Methods("GET") @@ -140,6 +145,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) { r.HandleFunc("/api/analytics/hash-collisions", s.handleAnalyticsHashCollisions).Methods("GET") r.HandleFunc("/api/analytics/subpaths", s.handleAnalyticsSubpaths).Methods("GET") r.HandleFunc("/api/analytics/subpath-detail", s.handleAnalyticsSubpathDetail).Methods("GET") + r.HandleFunc("/api/analytics/neighbor-graph", s.handleNeighborGraph).Methods("GET") // Other endpoints r.HandleFunc("/api/resolve-hops", s.handleResolveHops).Methods("GET")