You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Ranking a traversal-introduced binding depends on declaration order #789
Ranking a binding that a traversal introduces depends on the order the match block declares its bindings. With the ranked binding declared first, the query answers; declared after the binding it is reached from, it fails:
query q() { match { $p: Person { name: "p1" } $p knows $d } return { $d.slug } order { bm25($d.body, "needle") } limit 1 }
Engine v1 (the default): search-ordered query produced rows without its 'd._score' ranking column.
The same failure was reported twice from real graph work: a passage search scoped by a matter and its revisions, and a semantic search over the neighbours of one node selected by its key.
Mechanism
The compiler's lowering roots each traversal-connected component at its first-declared binding (crates/omnigraph-compiler/src/ir/lower.rs, scan_root, which prefers a searched binding only inside a correlated block). Only the root gets a scan, and ranking runs on a scan, so a ranked binding that is not the root has nowhere to run. Issue #750's filter half was fixed on engine v2 by #760; this is its ranking half.
Expected
Declaration order is not semantics. The ranked binding roots its component on both engines, so these queries answer regardless of order. On engine v2 the planner may also start from a selective other end (one key-selected node) and rank only the rows the traversal reaches. Only rrf arms on two bindings of one component stay refused, as a typed bad request. Tracked in RFC 0047 (#791, rollout step 4).
When a query ranks a node that a traversal reaches, the result depends on the order of the match clauses. If the ranked node is not the first node in its traversal group, the query fails. Engine v2 refuses it at planning. The frozen engine v1 fails at execution. Declaration order must not change the result. RFC 0047, rollout step 4, already gives the fix: the ranked binding becomes the root of its traversal group.
Reproduction verdict: reproduced on both engines, with two differences from the report
Environment
main at 864495a863bf92a7f4084dce6b0d47743b112b2a (clean worktree, fetched 2026-10-16).
Linux 6.12 x86_64, rustc 1.97.1, Lance 11.0.0 (pinned), local filesystem storage, debug build, in-process GQT runner.
Differences from the report
The report says that engine v1 is the default. This is no longer true. Since refactor: make v2 the only production query engine #795, set engine = v1 is refused: unknown value 'v1' for setting 'engine'; expected one of v2 (settings.rs). Engine v1 is now the frozen reference engine. I ran it through Session::with_read_executor(ReferenceEngine), which is the same path that GQT's --- expect same as v1 uses.
Fixture. The report gives no schema or data. I used the smallest fixture that fits the reporter's query. d3 has a higher BM25 score than d1, but only p2 reaches d3. So a result that ignores the traversal shows up as wrong.
Minimal failing test on main (the reproduction proof). I saved this as crates/omnigraph-gqt/cases/v2/planner/issue_789_ranked_destination_roots_its_component.gqt. It uses the reporter's exact query:
# issue: 789
# red_on: 2026-10-16, main 864495a8: step 1 is refused at planning with P001 instead of answering `d1`.
--- runner / --- schema / --- seed: the fixture above (without `embedding`)
--- query
query q() { match { $p: Person { name: "p1" } $p knows $d } return { $d.slug } order { bm25($d.body, "needle") } limit 1 }
--- expect ordered
{"d.slug":"d1"}
--- expect shape
d.slug: String
--- expect same as v1
$ cargo test -p omnigraph-gqt --locked --test gq_logic_tests issue_789
FAIL issue_789_ranked_destination_roots_its_component 0.58s
"step 1 (query): query failed: plan error: P001: `bm25()` orders `$d`, a traversal destination; engine v2 ranks only the binding a traversal starts from; ..."
test result: FAILED. 0 passed; 1 failed
There is no fix yet, so the after-fix half of the test does not apply. With a fix, this case must become green with no change to its expectations.
Both engines, same store. I used a scratch libtest in omnigraph-gqt that opens the seeded case store and runs each query on v2 and on the reference engine (cargo test -p omnigraph-gqt --locked --lib scratch_issue_789 -- --nocapture). I also pinned the same outcomes in a green probe .gqt case.
Query shape
Engine v2
Engine v1 (reference)
Reporter's query, bm25, $p declared first, $d not declared
P001: bm25() orders $d, a traversal destination
search-ordered query produced rows without its 'd._score' ranking column
Same, nearest($d.embedding, [1,0]) (the "neighbours of one key-selected node" report)
P001: nearest() orders $d ...
... without its 'd._distance' ranking column
$p first, then $d: Doc declared second
P001
(not run)
rrf(nearest($d…), bm25($d…)), both arms on $d, $p first
P001: nearest() orders $d ...
[{"d.slug":"d1"}]
$d: Doc declared first (bm25, nearest, rrf)
[{"d.slug":"d1"}]
[{"d.slug":"d1"}]
The v2 diagnostic is now: { kind: Plan, code: "P001", stage: plan, expression: bm25($d.body, "needle"), fix: "declare $dfirst inmatch, so the ranking starts the traversal" }. That fix is the workaround, and it works. But it makes the caller do what the language can do itself.
In the reporter's query, $d has no declaration. Only the traversal introduces it. So $d is never a root candidate: the IR is NodeScan $p then Expand $p -> $d. Reordering the declarations alone cannot fix this case.
Engine v2 ranks only a root scan. The rank step finds that the scan of $d is a dependent scan or a hash-join build, and it returns P001 (optimizer.rs L2546-L2586).
The defect is in the shared compiler, not in Lance and not in an engine executor. The reference engine reads the same QueryIR. So one compiler fix changes both engines, and the frozen v1 files (frozen.rs pins) stay unchanged.
Recommended fix (correct by design): RFC 0047 step 4 in the compiler
In scan_root, at the top level, root the group that holds that variable at that variable.
If the ranked variable has no declaration, emit a NodeScan of its checked node type. The type is in scope.bindings (BoundVariable::Node) and in ResolvedTraversal::{src,dst}_type. Whether a variable is declared must not change the plan's meaning.
Defer every other binding of the group. The existing deferred_filters path moves their inline filters ({ name: "p1" }) onto the Expand that introduces them. The traversal loop already makes a reverse Expand when only the destination is bound (L529-L555).
Move the refusal of rrf() arms on two bindings of one group from planner P005 to a compiler type error, as the RFC says. Keep the planner check as a loud internal check, so that a later lowering regression cannot return wrong rows silently.
Engine v2 needs no change for correctness. A top-level query can no longer reach the P001 arm of rank.
Tests (extend the existing owners):
The .gqt case above, plus steps for nearest, single-binding rrf (this closes bug: rrf order on a traversed binding fails on engine v2 #762 too), multi-hop and reverse traversals, an undirected traversal, a source with no edges (empty result), and two paths to one document (multiplicity). Add --- expect same as v1 where v1 is correct, and --- expect plan that names the ranked root.
A nearest step in which the global nearest document is not reachable. My fixture gives d1 and d3 the same vector, so it does not test this. This step proves that the ranked root uses the prefilter gate and the overfetch ladder, and not a top-k that a later filter cuts.
A compiler lowering unit test for the IR shape: a ranked undeclared variable gets a NodeScan, and the source's filter moves onto the reverse Expand.
Change the expectations of the tests that pin today's P001 refusal for this shape: step 4 (nearest_on_a_traversal_destination) of cases/v2/planner/search_order_on_an_unranked_binding_and_a_destination.gqt, the nearest_destination part of engine_v2_plan_replay.rs::a_search_order_on_a_traversal_destination_is_a_typed_bad_request_issue_786, and ranked_var_is_expand_dst in rrf_prefilter_gate.rs::shape_fence_covers_all_fallback_rows. Keep their rrf two-binding refusal parts.
Edge cases and costs
The cost moves. A ranked root scans the whole type. For BM25 the cost depends on all matches in the type, not on the neighbours of p1. For a key-selected source with few edges, that is slower than "expand first, then rank". The result stays correct. RFC 0047 step 8 is the performance follow-up for this.
The step 8 reversal must keep BM25 statistics independent of the traversal filter. See the Lance items below. Today the "Full-text eligibility placement" fence in docs/dev/lance.md controls this.
An rrf() with arms on two bindings of one group cannot root both. It stays refused, as the issue says.
Several groups with no traversal between them (cross products) do not change.
Alternatives
Option
Result
Liability
Keep the refusal and improve the fix text (the state since #786)
No wrong rows
Declaration order stays semantics. RFC 0047 rejected this ("declaration order is not semantics"). Agents spend one repair turn each time.
Reorder the clauses as text before type checking
Works for declared bindings only
It must invent a declaration for $d. It adds semantics from text order. Invariant 9 rejects it.
Planner-only fix in v2 (rank a full Doc scan as a hash-join build)
v2 answers
v1 still fails, so expect same as v1 cannot guard it. The root choice then lives in two places. This is step 8's area, not a first fix.
Root rule in the compiler (recommended)
Both engines answer, with one rule
Small and reversible ("the root rule is a lowering choice"). No storage, wire or format change.
Software liability. The recommended change adds one input to lowering: the ranked variable. The root becomes a function of the pattern and the leading order key, not of the text order. After five more changes of this kind (cost-based roots, selective-key roots, more ranking functions), the root decision must have one owner. The compiler rule is the semantic default. A planner reversal (step 8) must be a validated rewrite of that default, not a second rule. The open RFC #996 says that it "does not reorder joins or change which binding roots a pattern", so it does not conflict. The quality that this fix gives lower priority is efficiency for key-selected sources. That is acceptable, because correctness comes before performance, and step 8 is tracked.
Lance upstream assessment
OmniGraph pins Lance 11.0.0. The latest stable release is v13.0.0 (2026-10-07), and 14.0.0 is in beta (v14.0.0-beta.19). The defect is in OmniGraph's lowering. No Lance release, PR or roadmap item fixes it, and the fix needs no Lance change.
These Lance items affect the follow-up (the ranked-root cost and step 8's traversal-first ranking), not the fix:
lance#9058 (open): FTS corpus statistics depend on the query filter and on index coverage. A traversal prefilter on BM25 can change scores. This is why the eligibility-placement fence exists. A partial fix, lance#9243, is open. The related merges lance#7905 and lance#9444 are in v13.0.0, not in v11 or v12. They address combined_fields (BM25F). #9058 says that the filter and coverage forms are still open for single-column match too.
lance#1596 and lance#3778 (open): IVF search with a very selective prefilter can miss results. OmniGraph already uses a probe ladder and an exact rescan (the "KNN probe budget" fence). These items apply when a nearest root gets a traversal prefilter.
lance#8849 (open), with lance#8880 and lance#9098 (open), and lance#9282 (open): the cost of building a prefilter on stable-row-id datasets, which graph tables are, grows with the dataset size. This affects the cost of a prefiltered ranked root, not its correctness.
RFC 0047 (#791, merged): §"The ranked binding roots its component", rollout step 4 (this issue) and step 8 (the performance reversal).
RFC 0048 (#793, draft): "graph-defined populations". A ranking selects within the reached population and is never a global top list that a later filter cuts. It names the RFC 0047 ranked-root rule as the mechanism.
#996 (open RFC): request-aware plan cost. It keeps the root choice as it is.
No open PR changes scan_root or references this issue.
Limitations
The issue does not include the reporter's schema, data or real graphs. I used a constructed fixture that fits the query. The two real-world shapes (passages under a matter, and the neighbours of a key-selected node) match the bm25 and nearest rows of the table.
I ran only local filesystem storage, a debug build and the in-process runner. I did not run S3, Azure or the served path.
I reached engine v1 through the reference-engine door, because set engine = v1 is gone.
I did not write or test a fix. The fix assessment comes from reading the code at the SHA above.
I checked Lance items with GitHub search and the linked threads. That check is not complete.
Activity
ragnorc commentedon Oct 16, 2026
Triage: reproduction and fix assessment
Summary
When a query ranks a node that a traversal reaches, the result depends on the order of the
matchclauses. If the ranked node is not the first node in its traversal group, the query fails. Engine v2 refuses it at planning. The frozen engine v1 fails at execution. Declaration order must not change the result. RFC 0047, rollout step 4, already gives the fix: the ranked binding becomes the root of its traversal group.Reproduction verdict: reproduced on both engines, with two differences from the report
Environment
mainat864495a863bf92a7f4084dce6b0d47743b112b2a(clean worktree, fetched 2026-10-16).Differences from the report
set engine = v1is refused:unknown value 'v1' for setting 'engine'; expected one of v2(settings.rs). Engine v1 is now the frozen reference engine. I ran it throughSession::with_read_executor(ReferenceEngine), which is the same path that GQT's--- expect same as v1uses.kind: Plan, codeP001), and its fix text changed (see below).Fixture. The report gives no schema or data. I used the smallest fixture that fits the reporter's query.
d3has a higher BM25 score thand1, but onlyp2reachesd3. So a result that ignores the traversal shows up as wrong.Minimal failing test on
main(the reproduction proof). I saved this ascrates/omnigraph-gqt/cases/v2/planner/issue_789_ranked_destination_roots_its_component.gqt. It uses the reporter's exact query:There is no fix yet, so the after-fix half of the test does not apply. With a fix, this case must become green with no change to its expectations.
Both engines, same store. I used a scratch libtest in
omnigraph-gqtthat opens the seeded case store and runs each query on v2 and on the reference engine (cargo test -p omnigraph-gqt --locked --lib scratch_issue_789 -- --nocapture). I also pinned the same outcomes in a green probe.gqtcase.bm25,$pdeclared first,$dnot declaredP001: bm25() orders $d, a traversal destinationsearch-ordered query produced rows without its 'd._score' ranking columnnearest($d.embedding, [1,0])(the "neighbours of one key-selected node" report)P001: nearest() orders $d ...... without its 'd._distance' ranking column$pfirst, then$d: Docdeclared secondP001rrf(nearest($d…), bm25($d…)), both arms on$d,$pfirstP001: nearest() orders $d ...[{"d.slug":"d1"}]$d: Docdeclared first (bm25, nearest, rrf)[{"d.slug":"d1"}][{"d.slug":"d1"}]The v2 diagnostic is now:
{ kind: Plan, code: "P001", stage: plan, expression: bm25($d.body, "needle"), fix: "declare$dfirst inmatch, so the ranking starts the traversal" }. That fix is the workaround, and it works. But it makes the caller do what the language can do itself.Root cause
NodeScan. The group walk starts only from declared bindings, in declaration order (lower.rsL358-L434).scan_rootpicks the first one, or a searched binding only inside a correlated block (performance: a correlated anti-join over a traversal materializes the product — quadratic, then Arrow offset overflow at ~8k rows #763) (L857-L870). It does not know the order key.$dhas no declaration. Only the traversal introduces it. So$dis never a root candidate: the IR isNodeScan $pthenExpand $p -> $d. Reordering the declarations alone cannot fix this case.rankstep finds that the scan of$dis a dependent scan or a hash-join build, and it returnsP001(optimizer.rsL2546-L2586).NodeScan. So the score column of$ddoes not exist, and the query fails at execution. Engine v1 does answer therrfrow (the bug: rrf order on a traversed binding fails on engine v2 #762 shape). This agrees with the v1 table in bug: rrf order on a traversed binding fails on engine v2 #762's retest comment. I did not trace the v1 fusion path that gives this result.The defect is in the shared compiler, not in Lance and not in an engine executor. The reference engine reads the same
QueryIR. So one compiler fix changes both engines, and the frozen v1 files (frozen.rspins) stay unchanged.Recommended fix (correct by design): RFC 0047 step 4 in the compiler
lower_queryto the top-levellower_clauses. This is the variable ofnearestorbm25, or of anrrf()whose two arms rank the same variable. Correlated blocks have no order, so they keep the performance: a correlated anti-join over a traversal materializes the product — quadratic, then Arrow offset overflow at ~8k rows #763 rule.scan_root, at the top level, root the group that holds that variable at that variable.NodeScanof its checked node type. The type is inscope.bindings(BoundVariable::Node) and inResolvedTraversal::{src,dst}_type. Whether a variable is declared must not change the plan's meaning.deferred_filterspath moves their inline filters ({ name: "p1" }) onto theExpandthat introduces them. The traversal loop already makes a reverseExpandwhen only the destination is bound (L529-L555).rrf()arms on two bindings of one group from plannerP005to a compiler type error, as the RFC says. Keep the planner check as a loud internal check, so that a later lowering regression cannot return wrong rows silently.P001arm ofrank.Tests (extend the existing owners):
.gqtcase above, plus steps fornearest, single-bindingrrf(this closes bug: rrf order on a traversed binding fails on engine v2 #762 too), multi-hop and reverse traversals, an undirected traversal, a source with no edges (empty result), and two paths to one document (multiplicity). Add--- expect same as v1where v1 is correct, and--- expect planthat names the ranked root.neareststep in which the global nearest document is not reachable. My fixture givesd1andd3the same vector, so it does not test this. This step proves that the ranked root uses the prefilter gate and the overfetch ladder, and not a top-k that a later filter cuts.NodeScan, and the source's filter moves onto the reverseExpand.P001refusal for this shape: step 4 (nearest_on_a_traversal_destination) ofcases/v2/planner/search_order_on_an_unranked_binding_and_a_destination.gqt, thenearest_destinationpart ofengine_v2_plan_replay.rs::a_search_order_on_a_traversal_destination_is_a_typed_bad_request_issue_786, andranked_var_is_expand_dstinrrf_prefilter_gate.rs::shape_fence_covers_all_fallback_rows. Keep theirrrftwo-binding refusal parts.Edge cases and costs
p1. For a key-selected source with few edges, that is slower than "expand first, then rank". The result stays correct. RFC 0047 step 8 is the performance follow-up for this.docs/dev/lance.mdcontrols this.rrf()with arms on two bindings of one group cannot root both. It stays refused, as the issue says.Alternatives
$d. It adds semantics from text order. Invariant 9 rejects it.Docscan as a hash-join build)expect same as v1cannot guard it. The root choice then lives in two places. This is step 8's area, not a first fix.Software liability. The recommended change adds one input to lowering: the ranked variable. The root becomes a function of the pattern and the leading order key, not of the text order. After five more changes of this kind (cost-based roots, selective-key roots, more ranking functions), the root decision must have one owner. The compiler rule is the semantic default. A planner reversal (step 8) must be a validated rewrite of that default, not a second rule. The open RFC #996 says that it "does not reorder joins or change which binding roots a pattern", so it does not conflict. The quality that this fix gives lower priority is efficiency for key-selected sources. That is acceptable, because correctness comes before performance, and step 8 is tracked.
Lance upstream assessment
combined_fields(BM25F). #9058 says that the filter and coverage forms are still open for single-columnmatchtoo.nearestroot gets a traversal prefilter.Related work
cases/v2/planner/issue_750_destination_search_membership.gqtguards it.rrf()form of this defect, with both arms on one traversal-reached binding. It has the same root cause and is a narrower scope, so the same fix should close it. Ranking a traversal-introduced binding depends on declaration order #789 is the broader issue, and bug: rrf order on a traversed binding fails on engine v2 #762 does not supersede it.scan_rootor references this issue.Limitations
bm25andnearestrows of the table.set engine = v1is gone.