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 <noreply@anthropic.com>
This commit is contained in:
dborup
2026-07-24 13:02:06 +02:00
co-authored by Claude Sonnet 5
parent e38651fad0
commit 7fe3dd99fe
2 changed files with 97 additions and 0 deletions
+20
View File
@@ -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 {
+77
View File
@@ -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)