fix(engine): structurally cap cross-type Expand at one hop

A cross-type edge cannot chain (e.g. a Company is not a WorksAt source), so a
variable-length traversal over one is structurally single-hop. Both traversal
paths now enforce this by capping max hops at 1 when from_type != to_type,
instead of relying on the hop-2 scan returning empty.

That reliance was a correctness hole on the indexed path: it interns every
endpoint string into one dense id space, so a cross-type id-string collision (a
Person and a Company sharing an id) let hop 2 de-intern a destination id back to
the colliding source-type id and match its edges, emitting rows the CSR path
never produces. With the cap the cross-type second-hop scan never runs, so the
shared interner can no longer alias across types. Turns the regression test
green (indexed == csr == ["shared"]).
This commit is contained in:
Ragnor Comerford 2026-06-09 13:06:05 +02:00
parent ec38e2f9c7
commit f6a0e53737
No known key found for this signature in database

View file

@ -1121,6 +1121,14 @@ async fn execute_expand_indexed(
let (key_col, opp_col) = endpoint_columns(direction);
let max = max_hops.unwrap_or(min_hops.max(1));
// Cross-type edges cannot chain (a Company is not a `WorksAt` source), so a
// variable-length traversal over one is structurally single-hop. Enforce it
// here instead of relying on the hop-2 scan returning empty: this BFS interns
// every endpoint string into ONE dense id space, so a cross-type id-string
// collision (a Person and a Company sharing an id) would otherwise let hop 2
// de-intern a destination id back to the colliding source-type id and match
// its edges, emitting rows the CSR path never produces.
let max = if same_type { max } else { max.min(1) };
// Per-source BFS state in DENSE id space: intern node ids to u32 once via a
// per-traversal interner so visited/seen/frontier/neighbor-map avoid string
@ -1365,6 +1373,9 @@ async fn execute_expand_csr(
let max = max_hops.unwrap_or(min_hops.max(1));
let same_type = src_type_name == dst_type_name;
// Cross-type edges cannot chain; a variable-length traversal over one is
// structurally single-hop (mirrors the indexed path's guarantee).
let max = if same_type { max } else { max.min(1) };
// BFS to collect (src_row_idx, dst_dense) pairs with per-source dedup.
// Dense u32 ids stay in hand through BFS, dedup, and align — we only