From 7fe3dd99feb5c839a2e219e33da5cff26d3a4024 Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 24 Jul 2026 13:02:06 +0200 Subject: [PATCH] fix: neighbor_edges now records interior hop-to-hop adjacency, not just the two endpoints resolvePathWithContext (path_resolver.go) anchors each hop's disambiguation on the previously-resolved hop via a neighbor_edges adjacency lookup. But buildAndPersistNeighborEdges only ever wrote two edge shapes -- originator<->hop0 (ADVERTs only) and observer<->lastHop -- never anything between consecutive hops in the middle of a path. So the anchor lookup for hop 1+ was always querying a table that structurally could never contain that pair (unless it coincidentally matched some other packet's endpoints), which is why a resolved_path would reliably stop after hop 0 regardless of how many hops the packet actually traveled. Now also emits an edge for each consecutive pair of hops within the path itself (when both resolve unambiguously), independent of isAdvert/from_pubkey since it's relational between the hops, not tied to origin/observer identity. This feeds the exact adjacency data the anchor resolver already knows how to consume -- no changes needed there. Coverage improves gradually rather than all at once, since an interior pair must itself already be unambiguous to seed an edge. Co-Authored-By: Claude Sonnet 5 --- cmd/ingestor/neighbor_builder.go | 20 +++++++ cmd/ingestor/neighbor_builder_test.go | 77 +++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/cmd/ingestor/neighbor_builder.go b/cmd/ingestor/neighbor_builder.go index ff5782fe..7010c2fe 100644 --- a/cmd/ingestor/neighbor_builder.go +++ b/cmd/ingestor/neighbor_builder.go @@ -228,6 +228,26 @@ func (s *Store) buildAndPersistNeighborEdges() (int, error) { edges = append(edges, canonEdge(observerPK, resolved, ts)) } } + // Interior hop-to-hop edges: each consecutive pair of repeaters + // in the path must have been in range of each other to relay the + // packet along. Unlike the two endpoint edges above, this isn't + // gated on isAdvert or which side is the "true" origin/observer + // -- it's purely relational between resolved hops in the path + // itself. Without it, resolvePathWithContext's anchor-on- + // previous-hop lookup (path_resolver.go) has no adjacency data + // for any interior hop, so a multi-hop path only ever resolves + // its first hop in practice (#1547 follow-up). + for i := 0; i+1 < len(path); i++ { + resolvedA, okA := resolvePrefix(prefixIdx, path[i]) + if !okA { + continue + } + resolvedB, okB := resolvePrefix(prefixIdx, path[i+1]) + if !okB || resolvedA == resolvedB { + continue + } + edges = append(edges, canonEdge(resolvedA, resolvedB, ts)) + } } if len(edges) == 0 { diff --git a/cmd/ingestor/neighbor_builder_test.go b/cmd/ingestor/neighbor_builder_test.go index 65b63be7..6cfefe7e 100644 --- a/cmd/ingestor/neighbor_builder_test.go +++ b/cmd/ingestor/neighbor_builder_test.go @@ -83,5 +83,82 @@ func TestNeighborEdgesBuilderUpsertsFromObservations(t *testing.T) { } } +// TestNeighborEdgesBuilderInteriorHopEdges is the #1547 follow-up fix: +// consecutive hops WITHIN a path (not just the two endpoints) must also +// produce neighbor_edges rows. resolvePathWithContext's anchor-on- +// previous-hop lookup (path_resolver.go) needs adjacency data for +// interior hops to resolve anything past hop 0 in practice -- before +// this fix, neighbor_edges only ever recorded originator↔hop0 and +// observer↔lastHop, so a multi-hop path could never resolve its middle. +func TestNeighborEdgesBuilderInteriorHopEdges(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "build.db") + + store, err := OpenStore(dbPath) + if err != nil { + t.Fatalf("OpenStore: %v", err) + } + defer store.Close() + + // Three repeaters along the path, each with a unique 2-hex prefix. + if _, err := store.db.Exec( + `INSERT INTO nodes (public_key, name) VALUES (?, ?), (?, ?), (?, ?)`, + "bbbbbbbbbb", "hop-b", + "cccccccccc", "hop-c", + "dddddddddd", "hop-d", + ); err != nil { + t.Fatal(err) + } + if _, err := store.db.Exec( + `INSERT INTO observers (id, name) VALUES (?, ?)`, + "obs-1", "observer-1", + ); err != nil { + t.Fatal(err) + } + var obsRowid int64 + if err := store.db.QueryRow(`SELECT rowid FROM observers WHERE id = ?`, "obs-1").Scan(&obsRowid); err != nil { + t.Fatal(err) + } + + // A non-ADVERT (CHAN) transmission, no from_pubkey -- interior edges + // must not depend on isAdvert or a resolvable origin, unlike the two + // endpoint edges. + res, err := store.db.Exec( + `INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + "", "h2", "2026-01-01T00:00:00Z", 0, 5, 0, "{}", + ) + if err != nil { + t.Fatal(err) + } + txID, _ := res.LastInsertId() + + // path = b -> c -> d (three hops). Expect interior edges b<->c and c<->d. + if _, err := store.db.Exec( + `INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp) VALUES (?, ?, ?, ?)`, + txID, obsRowid, `["bb","cc","dd"]`, int64(1735689600), + ); err != nil { + t.Fatal(err) + } + + n, err := store.buildAndPersistNeighborEdges() + if err != nil { + t.Fatalf("buildAndPersistNeighborEdges: %v", err) + } + if n == 0 { + t.Fatal("expected at least 1 edge upserted, got 0") + } + + for _, pair := range [][2]string{{"bbbbbbbbbb", "cccccccccc"}, {"cccccccccc", "dddddddddd"}} { + var got int + if err := store.db.QueryRow(`SELECT COUNT(*) FROM neighbor_edges WHERE node_a = ? AND node_b = ?`, pair[0], pair[1]).Scan(&got); err != nil { + t.Fatal(err) + } + if got != 1 { + t.Errorf("expected interior edge %s<->%s to be persisted; got %d rows", pair[0], pair[1], got) + } + } +} + // (test ends here)