Repository navigation
fix(query): place BIND and FILTER after the triple that binds their variable - #1995
Conversation
…riable has run
A variable can be listed in the upstream schema yet unbound on some rows:
an UNDEF cell in a VALUES table, an unmatched OPTIONAL, or a per-row seed.
The planner read "in the schema" as "bound", so a BIND or FILTER reading
such a variable could run before the triple that actually binds it.
`BIND(STR(?payer) AS ?name)` came back unbound, and a FILTER reading
`?payer` dropped every row. A range FILTER on another triple exposed it by
moving that triple to the front of the join chain.
Placement is decided in three places, and each now waits until the
variable is settled (bound on every row, or no remaining pattern can
bind it):
- reorder: BIND waits on its variables' binders, as FILTER and EXISTS
already did. A seed variable counts as settled only if the seed binds
it on every row (`reorder_patterns_with_seed`).
- join chain: `build_sequential_join_block` keeps a BIND or FILTER off
a step while a later triple can still bind one of its variables,
including the inline ops, deferred binds/filters and range-semijoin
folds.
- seeds: `Operator::bound_in_every_row` lets seed operators report the
variables bound in their rows, so per-row UNION, OPTIONAL, GRAPH and
EXISTS plans get the exact set. Subquery EXPLAIN plans its placeholder
row as bound, like the rows it stands for. Operators that cannot tell
keep the previous assumption.
A VALUES column that is UNDEF in every row is also dropped before
planning when another pattern binds its variable, so `VALUES ?x { UNDEF }`
plans like the query without it.
…D targets "Which variables does this pattern bind on every row" was answered by `must_bind_vars` in the planner and a second copy in the join-chain planner that differed only on BIND, and the "no UNDEF in this VALUES column" test was written three times. - `must_bind_vars` takes `BindTargets`: `Bound` for subquery correlation and the existing barrier and binder sites, `MayBeUnbound` for join-chain placement. Every caller now states which it means. - `values_bound_in_every_row` and `values_column_all_undef` in `ir::pattern` replace the three inline VALUES column tests. - `collect_guaranteed_vars` was plain `produced_vars` despite its name; it is now `produced_vars_of`. No behavior change.
…every row The reorder took two seed sets and used them inconsistently: the binder attachment used the variables the seed binds on every row, while the VALUES-after-OPTIONAL barrier (#1690) and the left-join order barrier (#1924) still counted every seed schema variable as bound. In a per-row UNION, OPTIONAL or EXISTS plan whose seed row leaves a variable unbound, the OPTIONAL can still introduce it, yet the barrier did not fire: a triple reading it was hoisted ahead of the OPTIONAL and every pair it produced survived the left join. `SeedVars` now carries both sets, and each use picks one: the schema for join availability and cost, `bound_in_every_row` for binders and both barriers. `reorder_patterns` keeps its signature as the all-bound case.
Placement checks passed "bound" and "unsettled" as two loose sets through six helpers, and the range-semijoin fold restated the check inline. `Settled` holds both, and `admits` / `admits_var` are the only readiness tests. The join chain keeps one `Settled` and refreshes its unsettled part at each step; the end-of-block and property-join paths start from `Settled::all`, with nothing left to bind. No behavior change.
`build_property_join_block` applied the BINDs and FILTERs it could and discarded the rest (`let (child, _, _) = apply_deferred_patterns(..)`). A FILTER containing EXISTS is never inlined, and one reading a variable nothing binds never becomes ready, so on an anchored star of three or more triples both were silently dropped: the query returned the rows the FILTER rejects. The sequential join chain applies them at the end of the block. Both block builders now end with `apply_all_remaining`.
aaj3f
left a comment
There was a problem hiding this comment.
@bplatz the property-join fix alone makes this worth it (that star was silently returning the rows its FILTER rejects, in both SPARQL and JSON-LD), and the consolidation lands at the right altitude. Two things I'd want in before merging: holding a FILTER on a BIND's target until after the join that uses it costs about 6.6× the fuel on a computed-key shape, and UNWIND still has the same bug this fixes for BIND. Claude-assisted review below:
✅ Approving to unblock you — with 2 things that need to be addressed before this merges.
- A perf regression on a computed-key shape (
fluree-db-query/src/execute/where_plan.rs:1856). A FILTER that reads a BIND's target is now held until after a later triple in the same block that joins on that target.?s ex:num ?num . BIND(IRI(CONCAT(…, STR(?num))) AS ?t) ?t ex:val ?v . FILTER(STRENDS(STR(?t), "77"))goes from 1.07 to 7.01 fuel (~9 → ~19.5 ms warm) against the merge-base, with the same plan and the same rows. The fix path is inline: either a guarded early copy (!BOUND(?v) || filter) or treating applied BIND targets as settled in the join chain, plus a fuel assertion so it can't slip back. - UNWIND has the same bug, and it's reachable from JSON-LD (
fluree-db-query/src/planner.rs:1520).["unwind", "?x", "(list (str ?payer))"]after aVALUESwith an UNDEF cell returns?xnull on 5 of 7 rows at this head. That part is pre-existing (the merge-base returns the same nulls for the UNWIND and the BIND form alike, and this PR fixes the BIND one). AddingPattern::Unwind { .. }towaits_for_bindersfixes it, and the query crate's lib tests stay green. That's the body's "UNWIND gets no binder gating" follow-up, and I'd fold it in here.
Beyond those, I think this is really well done. Settled, must_bind_vars(…, BindTargets) making every caller say what it means, and bound_in_every_row defaulting to the old assumption are all at the right altitude. The plan-diff method in the body is carefully built too. I checked the core claims: all ten new integration tests pass at this head, and all ten fail when I put fluree-db-query/src back to the merge-base. I also reverted three mechanisms separately (the property-join discard, SeedOperator::bound_in_every_row, and the all-UNDEF column drop), and each one took down exactly the tests it should.
On the column drop specifically, I compared eleven shapes at merge-base and head. They were SELECT *, multi-column, a binder only inside OPTIONAL, MINUS-only, UNDEF UNDEF multiplicity, VALUES inside OPTIONAL, UNION or a sub-SELECT, VALUES written after the triple, GROUP BY/ORDER BY, and an empty table. Rows and head.vars match on all eleven.
One more is pre-existing, so it's not on you: the OPTIONAL + {| ex:since ?since |} annotation lane returns wrong rows on main too. The details and the repro are inline on operator.rs:85. Folding it in or filing it is your call.
The rest of the body's follow-ups all look genuinely separate to me:
- Subquery join keys: I confirmed
self_produced_vars(subquery.rs:978) andmust_bind_varsdisagree. Unifying them moves hash-key selection, so it's a design call of its own. - Poisoned vs Unbound: I confirmed
normalize_binding(cypher_seq.rs:1711) collapses both toUnbound. I didn't try to reproduce a wrong answer. How the Cypher row table should carry null into VALUES is a decision bigger than this PR. For placement, countingPoisonedas bound invalues_bound_in_every_rowis right, since nothing can re-bind it. - The walker gaps in
collect_var_stats, and the runtimeset_fast_paths_disabled/set_var("FLUREE_CYCLIC_BGP")isolation issue: both confirmed in code, and both unrelated to this fix's mechanism.
Adherence to repo commitments:
- Patterns/abstractions: ✔ It extends the shared machinery instead of adding a parallel one: one readiness type, one must-bind analysis with an explicit
BindTargets, and a trait method whoseNonedefault preserves the old plan. SPARQL and JSON-LD share the fix, with twins for both the reported shape and the property-join star. The reorder (Bound) and the join chain (MayBeUnbound) still disagree on BIND targets, which is documented. - Performance (speed first, memory second): 🔴 The query engine is touched. The new analyses are plan-time only, with no per-row operator work. But holding a FILTER on a BIND target past the triple that joins on it costs 6.6× the fuel on the shape above, and no bench covers it.
- Deployment targets: ✔ n/a. This is pure planning logic in
fluree-db-query, reached by every host (solo's Lambdas and standalone binary viafluree-db-api,fluree-db-server, wasm32). It adds no threads, clock, filesystem or process-lifetime cache, andwasm32/wasm-smokeare green. - Testing:
⚠️ There are 2 planner unit tests plus 10 integration tests, wired throughgrp_query_sparqland run by name in CI. All 10 fail against the merge-base query crate, and each of the three mechanisms I mutated went red. The BIND-target placement change (top-level and nested) and UNWIND have no coverage. - Conventions: ✔ Five commits, one per concern, each with a full body. fmt and clippy are green, and no user-facing docs are affected.
Verified locally at f8fe559c5: cargo test -p fluree-db-query --lib (1641 passed); cargo test -p fluree-db-api --test grp_query_sparql -- it_values_undef_placement it_property_join_filters (10/10, and 10/10 red with the merge-base query crate); three mutation checks; and fuel/row probes at merge-base vs head. It also composes cleanly: on a trial merge of current main + #1988 + #1990 + this PR, the ten new tests pass and the query crate's lib suite is green (1644).
Approving now so you can merge without waiting on another pass from me. Just be sure the perf fix and the UNWIND arm are in before you do.
| } | ||
| hash_planner.before_step(tp, &settled.bound); | ||
| guaranteed.extend(tp.produced_vars()); | ||
| settled.unsettled = unsettled_vars(&triples[k + 1..], &folded[k + 1..], &guaranteed); |
There was a problem hiding this comment.
🔴 Must address before merge: a FILTER on a BIND's target now waits until after the join that uses that target as a key, which costs about 6.6× the fuel on a computed-key shape I measured.
unsettled_vars marks every variable a later triple of the block produces as unsettled unless it's in guaranteed, and a BIND's target never enters guaranteed (9c6c58ccb makes that explicit with BindTargets::MayBeUnbound). So in ?s ex:num ?num . BIND(IRI(CONCAT("http://example.org/ns/t", STR(?num))) AS ?t) ?t ex:val ?v . FILTER(STRENDS(STR(?t), "77")) the BIND still inlines at the ex:num step, but the FILTER on ?t can't go with it anymore. ?t stays unsettled until ?t ex:val ?v has run, so every driving row does its subject lookup first and the FILTER throws ~99% of them away afterwards. On main the FILTER ran right after the BIND, ahead of the lookup.
I ran exactly that on an indexed ledger with 3,000 ex:num subjects and 6,000 ex:val subjects (the FILTER keeps 30). Both sides use the same plan shape (ex:num scan, then a subject-driven NLJ on ex:val) and return the same 30 rows. But fuel goes from 1.07 with the query crate at the merge-base to 7.01 at this head, and warm wall time from ~9 ms to ~19.5 ms. The cost scales with driving rows × (1 − selectivity). The plan-diff in the body couldn't see this because nothing in the corpus has this shape. I also think the "Behavior changes" bullet understates it a bit: "Previously only a top-level BIND was excluded" is relative to aa685685f. On main no BIND target was held at all, top-level or nested.
I see two reasonable ways out, and which one is your call:
- Keep the correctness and get the pruning back with a guarded early copy. When the only thing holding a FILTER is unsettled variables, inline
!BOUND(?v) || <filter>at the early step (only whenexpression_is_duplication_safe) and keep the original pending for the late step. On a row where?vis bound no later triple can change it, so dropping there is exact; where it's unbound the copy istrueand the late FILTER decides. This also gives the bound rows of the VALUES-UNDEF, OPTIONAL and per-row-seed cases their early pruning back. - Count an applied BIND's target as settled in the join chain, matching the
BindTargets::Boundthe reorder already uses. That restoresmain's placement and gives up only the case where the BIND errors and a later triple rebinds its target, whichmainalso got wrong.
Either way, I'd add a fuel assertion on this shape (fuel is deterministic, which it_annotation_filter_pushdown already relies on) plus a result test for the BIND-error row, to pin whichever semantics you pick. There's no test on the BIND side of this change today, top-level or nested.
| matches!( | ||
| p, | ||
| Pattern::Filter(_) | Pattern::Exists(_) | Pattern::NotExists(_) | ||
| Pattern::Filter(_) | Pattern::Exists(_) | Pattern::NotExists(_) | Pattern::Bind { .. } |
There was a problem hiding this comment.
🔴 Must address before merge: UNWIND has the same bug this PR fixes for BIND, and it's reachable from JSON-LD. I'd fold the "UNWIND gets no binder gating" follow-up into this PR.
UNWIND is placed by its dependencies exactly the way BIND is (PatternEstimate::Deferred, with required_vars from list.referenced_vars()), but it isn't in waits_for_binders. So its list expression runs as soon as a VALUES with an UNDEF cell puts the variable in scope, ahead of the triple that actually binds it. It isn't Cypher-only either: JSON-LD has ["unwind", "?var", expr].
I used the same five claims it_values_undef_placement seeds and this where-clause: ["values", ["?payer", [null, {"@type": "@id", "@value": "ex:payer1"}]]], {"@id": "?c", "ex:payer": "?payer", "ex:patientAge": "?a"}, ["filter", "(> ?a 0)"], ["unwind", "?x", "(list (str ?payer))"]. At this head it returns seven rows, five of them with ?x null (every row that came through the UNDEF cell). Swapping the last clause for ["bind", "?x", "(str ?payer)"] returns all seven correctly. That's this PR's fix doing its job; UNWIND is the arm it doesn't reach. To be clear, it isn't new: with the merge-base query crate the BIND form returns those same five nulls too, so this PR fixes one of the two.
Adding Pattern::Unwind { .. } to the matcher made that query return all seven rows correctly, and cargo test -p fluree-db-query --lib still passes 1641/1641 with it. This is the form rustfmt settles on:
matches!(
p,
Pattern::Filter(_)
| Pattern::Exists(_)
| Pattern::NotExists(_)
| Pattern::Bind { .. }
| Pattern::Unwind { .. }
)It's the same bug class, a one-line fix, and a silent wrong answer on a non-Cypher surface. So I'd rather see it land here, with a JSON-LD twin next to jsonld_bind_reads_the_value_its_triple_binds_after_an_undef_cell, than go to the backlog.
| /// its rows. A plan seeded by this operator holds a BIND or FILTER reading | ||
| /// any other schema variable until the triples that could still bind it | ||
| /// have run. `None` plans every schema variable as bound. | ||
| fn bound_in_every_row(&self) -> Option<Vec<VarId>> { |
There was a problem hiding this comment.
🟠 Should address (pre-existing, not introduced here): the default-graph edge-annotation lane the body calls out as opaque is reachable, and it returns wrong rows today.
DefaultGraphSourceOperator plans its chain on top of whatever operator precedes it (build_where_operators_seeded(Some(child), …) in default_graph_source.rs). That child answers None here, so everything in its schema is planned as bound on every row. sink_filters_into_annotation_chains also copies a FILTER into the chain whenever the chain's triples bind all of its variables.
Here's a concrete case. The data is ex:a1 ex:name "A1" ; ex:knows ex:b1 {| ex:since 2021 |} (no ex:joined), a2 (joined 2021, since 2021) and a3 (joined 1990, since 1990). The query SELECT ?a ?b ?since WHERE { ?a ex:name ?n . OPTIONAL { ?a ex:joined ?since } ?a ex:knows ?b {| ex:since ?since |} . } returns a1 with ?since unbound, when it should be 2021 from the annotation. Adding FILTER(?since > 2000) drops a1 entirely. I get identical answers with the query crate at the merge-base, so this PR didn't cause it. And since the no-FILTER query loses the binding too, I think there's more going on in that lane than placement.
The where-plan loop already knows guaranteed at the point it builds the wrapper, so passing that into the wrapper's inner build as the seed's bound_in_every_row may well cover the FILTER half. The lost binding probably needs its own look. I'm fine with either folding it in, if it turns out to be one mechanism, or an issue with this repro. It's a different lane from the rest of this PR, so that one's your call. Commenting here because fluree-db-query/src/default_graph_source.rs is not in this diff.
There was a problem hiding this comment.
Filed as #1999. Only the FILTER half reproduced; without the FILTER, a1 comes back with 2021.
| let mut bound = bound_vars_from_operator(&operator); | ||
| if let Some(child) = operator.take() { | ||
| let (child, _, _) = apply_deferred_patterns( | ||
| operator = Some(apply_all_remaining( |
There was a problem hiding this comment.
👍 This is a real catch. I put the old let (child, _, _) = apply_deferred_patterns(..) back, and all three it_property_join_filters tests go red returning c1, c3, c5 (the rows the FILTER rejects), SPARQL and JSON-LD alike. Ending every block builder in apply_all_remaining is the right place to close it.
The reorder defers UNWIND and places it by its dependencies, as it does a BIND, but attach_filter_binders left it out of the patterns that wait on binders. A VALUES UNDEF cell put the variable in scope, so the list expression ran before the triple that binds it, and every row that came through the UNDEF cell unwound a null. JSON-LD reaches it with ["unwind", "?x", "(list (str ?payer))"].
…riple A FILTER reading a variable that a later triple of the block can still bind waits for that triple, because a row may reach it with the variable unbound: a BIND that errored, a VALUES UNDEF cell, an OPTIONAL, a per-row seed. A BIND's target is always such a variable, so in ?s ex:num ?num . BIND(IRI(CONCAT(..., STR(?num))) AS ?t) ?t ex:val ?v . FILTER(STRENDS(STR(?t), "77")) every driving row paid for its ex:val lookup before the FILTER dropped it, about 3x the fuel of the same FILTER written on ?num. Where the only thing holding such a FILTER is those unsettled variables and the expression is safe to evaluate twice, the step now also runs !BOUND(?v) || ... || filter. A join cannot change a variable a row already binds, so the copy drops only rows the FILTER drops; a row with one unbound passes, and the FILTER decides after the triple. It is placed once per FILTER. Tests: equal fuel for the FILTER on ?t and on ?num over an index; a row whose BIND errored keeps the targets the later triple binds.
Summary
A BIND or FILTER could run before the triple that binds its variable, whenever that variable was listed in the upstream schema but unbound on some rows. Three things leave a variable in that state: an
UNDEFcell in a VALUES table, an unmatched OPTIONAL, and a per-row seed. The planner treated "in the schema" as "bound".This came from a dashboard report on
ccv-claims:?namecame back unbound.?payer(for exampleFILTER(BOUND(?payer))) dropped every row.?a > 0) moves?c ex:patientAge ?aahead of?c ex:payer ?payer. The BIND or FILTER was then inlined into the first join step, before?payerwas bound.FLUREE_DISABLE_QUERY_FAST_PATHSreturned the same wrong rows, so the bug is in the plan both lanes share.The same readiness logic had been reimplemented in several places, so this PR also consolidates it. The review turned up two more bugs of the same kind, and both are fixed here.
Changes
One commit per concern, in this order:
fix(query): hold BIND and FILTER until the triple that binds their variable has run.attach_filter_binders).build_sequential_join_blockkeeps a BIND or FILTER off a step while a triple not yet joined can still bind one of its variables. This covers inline ops, deferred BINDs/FILTERs and range-semijoin folds.Operator::bound_in_every_rowreports which variables a seed binds on every row. It is exact forSeedOperator,BatchSeedOperator,BatchReplayOperatorand the OPTIONALMaterializedSeedOperator.UNDEFin every row is dropped before planning when another pattern binds its variable.VALUES ?x { UNDEF }then plans exactly like the query without it.refactor(query): one must-bind analysis.must_bind_vars(pattern, BindTargets)makes every caller say whether a BIND target counts as bound on every row. An erroring expression leaves it unbound.values_bound_in_every_row/values_column_all_undefreplace three copies of the same VALUES column test.collect_guaranteed_vars(which was plainproduced_vars) is renamedproduced_vars_of.fix(query): OPTIONAL barriers settle only on seed variables bound on every row.SeedVars { schema, bound_in_every_row }feeds the reorder.bound_in_every_rowinstead of the full seed schema.refactor(query): one readiness type.Settled { bound, unsettled }withadmitsreplaces a pair of loose sets that was threaded through six helpers, plus an inline copy in the range-semijoin fold.fix(query): a property-join star applies every FILTER collected with it.build_property_join_blockused to discard the BINDs and FILTERs it could not inline:let (child, _, _) = apply_deferred_patterns(..).FILTER(?a > 14 || EXISTS {…})andFILTER(?unbound = 1)were silently dropped, so the query returned the rows the FILTER rejects. This reproduces onmainin both SPARQL and JSON-LD.apply_all_remaining.fix(query): UNWIND waits for the triple that binds its variable. The reorder places UNWIND by its dependencies, as it does BIND, but UNWIND was missing fromwaits_for_binders. After a VALUESUNDEFcell, JSON-LD["unwind", "?x", "(list (str ?payer))"]unwound a null on every row that came through the cell. This reproduces onmain.perf(query): prune with a guarded copy of a FILTER held for a later triple.?s ex:num ?num . BIND(IRI(CONCAT(…, STR(?num))) AS ?t) ?t ex:val ?v . FILTER(STRENDS(STR(?t), "77"))every row paid for itsex:vallookup before the FILTER dropped it: about 3× the fuel of the same FILTER written on?num.expression_is_duplication_safe), the step also runs!BOUND(?v) || … || filter, once per FILTER. A join can't change a variable a row already binds, so the copy drops only rows the FILTER drops. A row where the variable is unbound passes, and the FILTER decides after the triple.UNDEF, OPTIONAL and per-row-seed shapes.Behavior changes to be aware of
main, no BIND target was held.BIND(STR(?payer) AS ?name)written before the triple. This matches how BIND was already placed by its dependencies whenever no VALUES was involved.BindTargets::Bound, as before. The known BIND-then-OPTIONAL hole documented onmust_bind_varsis unchanged. Tightening it changes placement and should get its own tests.Nonefrombound_in_every_row, for example the default-graph edge-annotation lane. They keep the previous assumption that every schema variable is bound. The annotation lane returns wrong rows because of it, onmaintoo (see Follow-ups).Query-plan verification
The consolidation commits were meant to be plan-neutral, and the fix commits were meant to change plans only in the broken shapes. I checked this across the test corpus by diffing physical plans between the commit before the consolidation (
aa685685f) and this head (f8fe559c5).Method (temporary instrumentation, not committed):
build_where_operators_seeded_with_neededlogged every where-plan, including per-row sub-plans:hash(patterns, seed schema, seed bound_in_every_row, needed vars, group-by, dedup flag, required vars)→describe()JSON.describe()does not show where BIND/FILTER are inlined, so the patch also addedinline_opsto the plan details ofNestedLoopJoinOperator,PropertyJoinOperator,ScanDatasetBuilderandBinaryScanOperator, andexprtoBindOperator/FilterOperator. None of those files differ between the two commits, so the identical patch applied to both.cargo test -p fluree-db-apiplus thetestsuite-sparqlW3C suite, run twice on the head (to measure noise) and once on the base.Results:
FAST_PATHS_DISABLEDflag is flipped at runtime byit_differential_fastpath, which shares thegrp_querybinary with thexl-beta-*GRAPH tests.driving-est) differs, and it swaps between two keysit_cyclic_bgp_*setFLUREE_CYCLIC_BGP=0viastd::env::set_varmid-test.Limits:
required_where_varsreaches the planner in hash order, and nested sub-plans get filed under different parent keys.Tests
New tests. Each one fails without its fix; I checked this by reverting each mechanism in turn, and against
main.it_values_undef_placement.rs(grp_query_sparql):{ UNDEF ex:payer1 }table, in novelty and indexed ledgers. It asserts the range-bounded triple drives the chain, so the test cannot pass vacuously.BOUND,STRSTARTS).UNDEFcell.it_property_join_filters.rs(grp_query_sparql): FILTER with EXISTS, and FILTER on a variable nothing binds, on a property-join star. Each asserts thePropertyJoinOperatorprecondition. Includes a JSON-LD twin.left_join_barrier_ignores_a_seed_var_unbound_on_some_rows, andvalues_barrier_ignores_a_seed_var_unbound_on_some_rows, which asserts both directions. Join-chain unit tests:filter_on_an_unsettled_var_runs_a_guarded_copy_onceandfilter_that_differs_per_evaluation_gets_no_guarded_copy.Suites, on this head with
mainmerged in (#1966, #1988, #1990):cargo test -p fluree-db-query --lib: 1647 passed.cargo test -p fluree-db-api: 3,902 passed, 0 failed.it_limit_stops_work::distinct_limit_stops_a_chain_after_one_small_probeis a pre-existing flake that passed this run: it fails 1 in 4 parallel runs withmain's query crate, passes in isolation, and its thread-local tracing capture sometimes sees no events.it_annotation_filter_pushdown::annotation_body_threshold_reduces_scan_work_on_both_surfacesandit_bounded_overlay_translation::warm_whole_product_short_circuits_and_respects_epoch. The latter also failed on the base commit.testsuite-sparql: green; no register entry changed state.cargo fmt --allandcargo clippy --all-targets --no-depsare clean forfluree-db-queryandfluree-db-api.Follow-ups (not in this PR)
self_produced_vars(triples and paths only) and the planner'smust_bind_varsdecide "does the body bind this on every row" differently. Unifying them changes hash-key selection and needs its own tests.PoisonedtoUnboundwhen it threads rows into VALUES (cypher_seq.rsnormalize_binding). That is confirmed in code; no wrong answer has been reproduced yet.collect_var_statshas no arm for SERVICE or the search adapters, and SPARQLcollect_bound_variablesomits the GRAPH?gand annotations.Nonefrombound_in_every_row, so afterOPTIONAL { ?a ex:joined ?since }, a FILTER on?sincedrops the rows where only?a ex:knows ?b {| ex:since ?since |}binds it. This predates this PR. Follow-up: FILTER over an OPTIONAL variable drops rows an RDF-star annotation binds #1999FAST_PATHS_DISABLEDand callset_var("FLUREE_CYCLIC_BGP")at runtime, which changes plans for other tests running in parallel in the same binary.Partially addresses #1986: seed variables are now settled only when the seed binds them on every row. Cost-based placement of MINUS/EXISTS/NOT EXISTS remains.