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)