Skip to content

fix(query): compare terms by what they are, not how they're encoded - #2010

Open
aaj3f wants to merge 34 commits into
mainfrom
fix/optional-encoded-binding
Open

aaj3f wants to merge 34 commits into
mainfrom
fix/optional-encoded-binding

Conversation

@aaj3f

@aaj3f aaj3f commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

This is a lot more than #1973's slowdown needs, and that's deliberate. One IRI reaches operators in several internal forms, and each place that compared terms decided equality its own way. We've been fixing that a site at a time (#1443, #1681), #1320 is the same gap as a slowdown, and OPTIONAL, DISTINCT, GROUP BY, MINUS, EXISTS, VALUES and joins still return wrong answers. So rather than patch one more site, every surface calls one canonical comparison, four copies of one lookup become one, and values that only make sense inside one graph never leave it: one place to reason about and tune. Fwiw, it's one of five PRs taking this approach, with #2006, #2007, #2008 and #2009.

On an indexed ledger, and in some shapes with novelty pending, ordinary queries return wrong results today, silently: OPTIONAL matches dropped, DISTINCT duplicates, GROUP BY groups split in two, a MINUS that removes nothing, an EXISTS that finds nothing, a join that repeats every match, property paths paired with their whole closure, and a SPARQL UPDATE whose WHERE then deletes nothing. Across two graphs of one ledger, a correlated OPTIONAL even fails with an internal error, and a big number or a vector can print as another graph's value. The full list is just below.

Fixes #1973
Fixes #1320

Wrong results this fixes

Reproduced on main (61b836e9a), indexed unless noted. Each case is pinned by a test with a hand-written expectation, and the test file run against main's sources fails on every one.

Surface Reproducer Main returns
OPTIONAL, predicate value correlated as a subject ex:s1 ?p ?o . OPTIONAL { ?p rdfs:label ?l } no labels
OPTIONAL, predicate value correlated as an object ex:s1 ?p ?o . OPTIONAL { ?x ex:mentions ?p } no ?x
OPTIONAL, object ref correlated as a predicate ex:s4 ex:usesPredicate ?q . OPTIONAL { ex:s1 ?q ?v } no ?v
OPTIONAL after a batched join (novelty pending) ?d a ex:Doc ; ex:title "Doc 1" . ?s ex:derivedFrom ?d . OPTIONAL { ?c ex:subjectOf ?s }, and the Cypher OPTIONAL MATCH twin every ?c dropped
OPTIONAL, both slots correlated after batched joins, IRIs minted in novelty … ?c ex:subjectOf ?s . OPTIONAL { ?c ?p ?s } every ?p dropped
DISTINCT / COUNT(DISTINCT) SELECT DISTINCT ?x { { ex:s1 ?x ?o } UNION { ?x rdfs:label ?l } } each predicate twice; count 4 instead of 2
GROUP BY the same pattern, GROUP BY ?x groups split in two
MINUS ex:s1 ?p ?o MINUS { ?p rdfs:label ?l } removes nothing
MINUS / COUNT(DISTINCT), novelty pending chunks from a batched join, one minted in novelty and one a blank node, MINUS { ?s ex:text ?t } both kept; count 7 instead of 5
FILTER EXISTS / NOT EXISTS ex:s1 ?p ?o FILTER EXISTS { ?p rdfs:label ?l } (and JSON-LD exists / not-exists) EXISTS keeps nothing, NOT EXISTS keeps everything
trailing VALUES { ex:s1 ?p ?o } VALUES ?p { ex:text } no rows
SPARQL UPDATE WHERE DELETE { ?p rdfs:label ?l } WHERE { ex:s1 ?p ?o . OPTIONAL { ?p rdfs:label ?l } } deletes nothing
OPTIONAL correlated on its subject, FROM <g1> FROM <g2> of one ledger ?c ex:subjectOf ?s . OPTIONAL { ?s ex:derivedFrom ?d } internal error
join, FROM <g1> FROM <g2> of one ledger ?s ex:derivedFrom ex:doc1 . ?c ex:subjectOf ?s every match three times
arena-backed literals (big numbers) in two graphs of one ledger, whose arena handles collide: g1's 1.5 and g2's 9.5 under one predicate are both handle 0 projection ?x ex:amount ?v g2's 9.5 printed as 1.5: its handle decoded through g1's arena
join ?x ex:amount ?v . ?y ex:amount ?v all 9 pairs: every ?x with every ?y
OPTIONAL { ?y ex:amount ?v } pairs x1 with y1, and misses x2 with y1
MINUS { ex:y1 ex:amount ?v } removes x1 (its 1.5 shares y1's handle) and keeps x2
GROUP BY ?v three groups, 1.5 printed twice, instead of 1.5 once and 9.5 twice
vectors and big numbers crossing a GRAPH scope; one of each per graph, every one handle 0 of its arena SELECT ?s ?e { GRAPH <g2> { ?s ex:emb ?e } } g2's vector printed as the default graph's
?a ex:emb ?e . GRAPH <g2> { ?b ex:emb ?e } a false pair (x1, y1)
ex:x1 ex:emb ?e . OPTIONAL { GRAPH <g2> { ?b ex:emb ?e } }, and its big-number twin binds y1: x1's handle decoded through g2's arena
FROM <g2> FROM <g3> FROM NAMED <g3>, GRAPH <g3> { ?s ex:emb ?e } g3's vector printed as g2's
the same dataset, GRAPH <g3> { ?s ex:emb ?e } ?t ex:emb ?e; and with <g3> the first FROM, for vectors and for big numbers a false pair (z1, y1)
a same-ledger SERVICE inside a GRAPH scope, reading another graph GRAPH <g3> { ?s ex:emb ?e SERVICE <fluree:ledger:…> { ?t ex:emb ?e } } a false pair (z1, y1)
property-path endpoint bound as a predicate ex:s1 ?p ?o . ?p ex:parentProp+ ?super every predicate paired with every closure object
property-path endpoint minted in novelty ?s ex:derivedFrom ?d . ?s ex:next+ ?n with ?s minted in novelty the full closure for that row
BIND onto a bound variable (JSON-LD) {"@id": "?s", …}, ["bind", "?s", "(iri \"…/s2\")"] no rows

Why

On an indexed ledger the scan emits terms in encoded form, and one IRI has two encoded forms: EncodedPid where a pattern reaches it as a predicate, EncodedSid where it is a subject or object. Decoded producers (VALUES, BIND, scans over pending novelty) carry it as Sid, and the batched lanes bind a subject minted since the last index by its novelty id. Binding's PartialEq compares those forms structurally and answers false across them, and several surfaces compared or keyed bindings without first bringing them to one form. The result was the silent wrong answers above.

The same gap made a single-triple OPTIONAL correlated on its object scan the whole predicate per required row (#1973). That is the ~13 s DELETE … WHERE { … OPTIONAL { ?c schema:subjectOf ?s } } reported against solo.

This fixes a condition #1967 (00920483c) introduced. #1967 changed the late-materialization gate from "overlay epoch is 0" to "no live novelty", which is the right rule: it restored fast paths a long-running server had lost after its first write. It also moved those servers onto the encoded lane, the state where these comparisons fail. Before #1967 (ffeeec554) the bugs showed only on a freshly loaded ledger; after it they show in a server's steady state too. Some shapes need no index state at all: the batched join lanes bind encoded ids even with novelty pending.

What changed

  • One canonicalization for term equality: TermDicts. A query resolves a decoded term to its id in one place. That place checks the persisted dictionary first and the novelty dictionary second, the order every lane assigns ids in. The canonical id of an IRI is its subject id when it has one, persisted or novelty, else its persisted predicate id. A blank node follows the same rule; it is stored as a subject. Every surface that keys or compares terms goes through it:

    • normalize_for_key (DISTINCT, GROUP BY, MINUS, EXISTS, subquery keys);
    • the group-aggregate keys and COUNT(DISTINCT);
    • the hash join's join_key;
    • OPTIONAL's unify check (same_term, its normalization now built once per operator; an encoded subject against a decoded one compares by decoding the encoded side through the view's memo);
    • the nested-loop join's check on a slot it could not bind;
    • VALUES compatibility;
    • BIND onto an already-bound variable (the inline BIND check the tests pin, and the BIND operator's, which no tested shape reaches);
    • the sameTerm fast path. It now compares canonical ids, and hands a miss to the decoding comparison instead of answering false.

    The four copies of the "persisted, then novelty" subject lookup are consolidated into it: OPTIONAL's subject lane, the join's batched key, the property join and the annotation enumeration lane.

  • One substitution for every correlated scan: substitute_binding. It decodes an encoded value into any slot. The join already did this; OPTIONAL's per-row lookup now calls the same function instead of its own copy, which handled only decoded values and an encoded subject. With several graphs of one ledger active (FROM <g1> FROM <g2>) there is no graph view, so values decode through a view of the ledger's store (CorrelationView). That view declines an arena-backed literal, but none reaches it: those arrive decoded (below).

  • Arena-backed literals stay inside their graph. A big number's or a vector's encoded key is a handle into an arena scoped to one graph and predicate, counted from 0. A handle is now decoded, through the graph that bound it, wherever it would otherwise reach a context that reads another graph or none:

    • A scan that is one member of a union of graphs (FROM <g1> FROM <g2>) binds those literals decoded.
    • A GRAPH scope decodes the handles crossing it in both directions (ArenaCrossing): those its rows carry out, through its own graph, and those the seeding row carries in, through the outer graph. So does a same-ledger SERVICE that reads another graph than its parent. Before, the exit decoded big numbers only, and only when the two graph ids differed. They match when a union's first graph is the scope's own, although the union decodes in no graph. On the way in, nothing was decoded.
    • Scans of a single graph, and scopes over the same single graph as their parent, are unchanged. Each scan's open event records whether it decodes (arena_literals_decoded), and the tests check both.
    • The other producers bind decoded values (history scans, VALUES, BIND, graph sources) or decline a context that spans graphs (the batched lanes and the fast paths).
    • same_term refuses to compare an arena-backed literal without a graph view to decode it: it returns an internal error, plus a debug assertion, rather than answer either way by handle. The keyed normalizers assert the same in debug builds. Member decoding and the crossings make both unreachable, and with member decoding disabled the assertion fires.
  • No slot is left free to match every row. The nested-loop join now acts on the substitution outcome:

    • a value that can never fill its slot yields no right rows;
    • every variable the right batch still binds that the left row carries must be the same term on both sides.

    The join's old unify instructions were built from right-side variables, which exclude every left variable, so they were always empty and are removed. OPTIONAL leaves an undecodable value to its own unify check.

  • The scan cache key follows the substitution. Every value substitution binds is keyed by its identity, in every position.

  • OPTIONAL correlated on its object answers with a batched OPST probe (?s … OPTIONAL { ?c <p> ?s }), stamped optional_object_probe.

    • It shares the join's bound-object lane (for_each_object_probe_match), novelty merge included, and the join's admission gates.
    • It coalesces at most the join lane's own window, 100,000 driving rows, not the 512K cap of the other coalescing lanes.
    • It declines to per-row lookups for literal, unbound or unknown objects.
  • When the row binds the subject, an encoded IRI, string or date object is left to the unify check, because the subject seek is already narrow. A numeric object is still substituted.

    • The shared substitution matches numerics by value across numeric datatypes (1 and "1"^^xsd:long); that is the join's long-standing rule. The unify check compares terms.
    • On main OPTIONAL followed the value rule only on the novelty lane, where the value arrives decoded, and compared the encoded value of the indexed lane as a term. It now follows the value rule in every index state; see Decision for review.
  • Property-path endpoints resolve an EncodedPid, and resolve an EncodedSid through the novelty-aware view. Without that, the row fell into the full-closure branch and was paired with every closure pair. GRAPH ?g extraction gets the same EncodedPid arm for totality.

  • perf: EXISTS semijoin cache probe declines EncodedSid rows to the generic per-row path #1320. The EXISTS-in-an-expression semijoin cache now probes EncodedSid directly. Before, every row on an indexed ledger declined the cache and ran its EXISTS body as a seeded subplan.

  • OPTIONAL with a left-bound variable in object position: quadratic CPU and ~170 KB of memory per required row (OOM) #1973's memory side. OPTIONAL's per-row path returned a batch per required row with columns sized for 1,000 rows. A batch less than half full is now shrunk to its rows.

  • Cost of the canonical form. The details are folded just below.

Cost of the canonical form, in detail
  • Per predicate the store memoizes, on that predicate's first use, the subject id of its IRI (one relaxed atomic load per key after that). There is no whole-table resolve, so the first query after an index publish pays one dictionary lookup per predicate it meets.
  • A decoded IRI asks the predicate table first. An IRI outside every predicate's namespace is rejected with a bit test. So is one whose name length, modulo 64, matches no predicate in its namespace. Only the rest are hashed.
  • The novelty dictionary is probed only after a persisted miss and only when it holds entries. The probe no longer allocates for names up to 190 bytes; longer names still allocate the key.
  • Whether a persisted predicate's IRI is a novelty subject is found on that predicate's first key, per operator: one novelty probe per predicate the query meets.

Decision for review

How OPTIONAL correlates a numeric value. OPTIONAL now uses the substitution the inner join uses. That substitution matches a numeric by value across numeric datatypes: OPTIONAL { ?s ?p ?n } with ?n = 1 (xsd:integer) also matches "1"^^xsd:long.

Consequences:

  • Agreement and disagreement, by state. OPTIONAL now agrees with the inner join in every index state. It disagrees, in every index state, with EXISTS, MINUS, trailing VALUES, DISTINCT and BIND, which compare terms. On main's indexed lane it was the reverse: there OPTIONAL compared the encoded value as a term, and with novelty pending it already matched by value.
  • Rationale. OPTIONAL is a left join. It should extend every row the inner join would match, and with this rule it does.
  • sameTerm. sameTerm(1, "1"^^xsd:long) is true on main and on this branch. That is a pre-existing deviation from the spec, unchanged here.
  • Follow-up. One engine-wide compatibility rule, the same for joins, OPTIONAL and the term-comparing surfaces.

As maintainer I favor this rule, for consistency with the inner join, but it's still offered to reviewers: please ratify or push back. numeric_object_correlated_with_a_bound_subject_matches_by_value pins it in both index states.

Performance

On #1973's own shapes the change is large. On the reported 1M-triple ledger the SELECT with the OPTIONAL goes from 1.166 s on main to 0.012 s in a server's steady state, and the same OPTIONAL over every chunk, on an indexed ledger, from 76.2 s and 11,547 MiB to 0.387 s and 433 MiB. #1973's synthetic goes from 1.79 s and 3,860 MB to 0.058 s and 58 MB. Those ran on a shared, loaded machine, so the tables are folded below with the rest of the local numbers.

Quiet box. EC2 c7i.4xlarge, the fat-LTO bench profile, base and head in interleaved rounds; a change counts as a win or a loss only when the base and head ranges don't overlap and the median delta exceeds the bench's budget (5% at small). At the final head, 83900eff1, all 9 scenarios are stable against main. The largest moves are unbound_filter_operand +1.4% and single_triple_probe +0.7%, both slower in all 3 rounds but under the budget; minus_count is −3.0% (faster in all 3), q9 −0.1%, and the rest are within ±1%. Against the head before Claude's review, 5c7cb1f99, every scenario is within ±2.5%. The +1.8% to +6.3% an earlier run measured at fdc49a7ca, before the perf pass, is gone.

Instructions and cycles. Per-iteration perf stat on the same box (x86) puts instructions at parity with main on 5 of the 7 keyed scenarios (−0.8% to +0.4%), and minus_count 3.4% below it (−2.1% cycles). not_exists_after_optional and unbound_filter_operand run equal or fewer instructions at +1.9% and +2.4% cycles, which is code placement, not added work. object_correlated is real work: +1.8% instructions and +1.3% cycles, the batched object lane's overhead per required row (2 allocations per required row, already there at 5c7cb1f99), on the very shape this PR takes from 1.166 s to 0.012 s above. That's a known small cost, and trimming it is a possible follow-up.

The quiet-box tables, and the earlier run at fdc49a7ca

The final head against main: GitHub's merge of this PR, 0a5464749, whose tree is 83900eff1's, against main at 61b836e9a. Three rounds, with the three binaries (main, this PR and 5c7cb1f99) each run once in every position:

bench scenario scale base median [range] head median [range] Δ head>base (paired rounds) budget verdict
query_hot_bsbm q9/small small 1.011 ms [1.006 ms–1.017 ms] 1.010 ms [1.005 ms–1.014 ms] -0.1% 1/3 5% stable
query_hot_negation_count minus_count/small small 1.166 ms [1.166 ms–1.177 ms] 1.131 ms [1.122 ms–1.144 ms] -3.0% 0/3 5% stable
query_hot_negation_count not_exists_after_optional/small small 19.721 ms [19.594 ms–19.890 ms] 19.531 ms [19.527 ms–19.648 ms] -1.0% 0/3 5% stable
query_hot_optional multi_pattern_hash_join/small small 2.963 ms [2.932 ms–2.984 ms] 2.976 ms [2.968 ms–2.990 ms] +0.4% 3/3 5% stable
query_hot_optional object_correlated/small small 4.701 ms [4.658 ms–5.377 ms] 4.700 ms [4.664 ms–4.852 ms] -0.0% 1/3 5% stable
query_hot_optional single_triple_probe/small small 410.73 µs [409.02 µs–411.35 µs] 413.74 µs [412.45 µs–419.56 µs] +0.7% 3/3 5% stable
query_hot_optional unbound_filter_operand/small small 3.000 ms [2.992 ms–3.007 ms] 3.042 ms [3.022 ms–3.044 ms] +1.4% 3/3 5% stable
query_hot_values_star two_values/small small 84.20 µs [84.15 µs–84.50 µs] 83.84 µs [83.42 µs–84.06 µs] -0.4% 0/3 5% stable
query_hot_whole_graph_agg count/whole_graph/small small 58.39 µs [57.83 µs–58.96 µs] 58.44 µs [58.06 µs–58.86 µs] +0.1% 1/3 5% stable

Per-iteration instructions and cycles: perf stat over two run lengths, the difference divided by the measured iterations, 3 alternations. "pre-review" is the head before Claude's review:

scenario binary instructions/iter, median [range] cycles/iter, median [range] IPC Δ instr vs base Δ cycles vs base n
minus_count base (main) 14,056,251 [14,054,040–14,057,948] 4,250,774 [4,245,500–4,746,374] 3.307 +0.00% +0.00% 3
minus_count #2010 13,580,277 [13,578,143–13,580,555] 4,163,809 [4,063,004–4,202,832] 3.262 -3.39% -2.05% 3
minus_count pre-review 5c7cb1f 13,806,148 [13,799,996–13,807,107] 4,103,558 [4,103,342–4,193,060] 3.364 -1.78% -3.46% 3
multi_pattern_hash_join base (main) 33,718,285 [33,698,031–33,720,240] 11,202,914 [11,053,351–11,284,952] 3.010 +0.00% +0.00% 3
multi_pattern_hash_join #2010 33,443,433 [33,437,064–33,446,563] 11,072,105 [11,014,537–11,149,603] 3.021 -0.82% -1.17% 3
multi_pattern_hash_join pre-review 5c7cb1f 33,603,376 [33,589,460–33,613,597] 11,178,696 [10,969,140–12,845,706] 3.006 -0.34% -0.22% 3
not_exists_after_optional base (main) 159,599,097 [159,562,437–159,616,465] 72,637,323 [72,185,729–73,259,777] 2.197 +0.00% +0.00% 3
not_exists_after_optional #2010 159,063,331 [158,927,110–159,085,793] 73,981,332 [72,207,168–74,230,892] 2.150 -0.34% +1.85% 3
not_exists_after_optional pre-review 5c7cb1f 158,484,499 [158,405,650–158,523,962] 73,178,374 [72,646,178–73,888,955] 2.166 -0.70% +0.74% 3
object_correlated base (main) 39,736,952 [39,696,502–39,787,040] 17,433,902 [17,272,920–17,581,983] 2.279 +0.00% +0.00% 3
object_correlated #2010 40,443,406 [39,954,365–40,552,635] 17,665,652 [17,422,829–17,818,500] 2.289 +1.78% +1.33% 3
object_correlated pre-review 5c7cb1f 40,351,873 [39,776,244–40,675,221] 17,819,817 [17,559,025–18,081,411] 2.264 +1.55% +2.21% 3
q9 base (main) 10,816,503 [10,805,670–10,818,547] 3,850,854 [3,790,320–3,862,001] 2.809 +0.00% +0.00% 3
q9 #2010 10,737,965 [10,733,048–10,738,372] 3,765,708 [3,724,753–3,799,938] 2.852 -0.73% -2.21% 3
q9 pre-review 5c7cb1f 10,739,773 [10,738,909–10,746,837] 3,810,618 [3,700,986–3,834,061] 2.818 -0.71% -1.04% 3
single_triple_probe base (main) 4,676,301 [4,674,448–4,679,796] 1,546,333 [1,529,473–1,557,845] 3.024 +0.00% +0.00% 3
single_triple_probe #2010 4,695,081 [4,690,031–4,695,878] 1,557,709 [1,552,088–1,570,142] 3.014 +0.40% +0.74% 3
single_triple_probe pre-review 5c7cb1f 4,694,528 [4,687,712–4,694,621] 1,568,870 [1,538,235–1,579,820] 2.992 +0.39% +1.46% 3
unbound_filter_operand base (main) 36,766,967 [36,748,798–36,769,088] 11,207,556 [11,144,156–11,287,649] 3.281 +0.00% +0.00% 3
unbound_filter_operand #2010 36,695,012 [36,679,915–36,700,338] 11,472,355 [11,278,097–11,488,475] 3.199 -0.20% +2.36% 3
unbound_filter_operand pre-review 5c7cb1f 36,778,695 [36,768,958–36,779,102] 11,398,917 [11,379,002–11,468,504] 3.227 +0.03% +1.71% 3

The earlier run, at fdc49a7ca, before the perf pass. All 9 scenarios were stable by the rule, but the head was slower in all 3 rounds on 6 of them: bsbm q9 +6.3% (over budget on its median, but its ranges overlapped), unbound_filter_operand +3.4%, minus_count +3.3%, not_exists_after_optional +3.3%, multi_pattern_hash_join +2.3% and object_correlated +1.8%. single_triple_probe was +1.4% (slower in 2 of 3), and values_star and whole_graph_agg were flat. The head before Claude's review had been flat on q9, minus_count, not_exists_after_optional and multi_pattern_hash_join in an earlier run on the same box, so the rework had cost about 2–6% in time on the keyed OPTIONAL, MINUS/EXISTS and GROUP BY paths. At that point q9 was within 0.1% of main in instructions (at 9643dc75a), so instruction counts alone couldn't settle the time cost.

The perf pass, f99928dd4, takes the rework's added per-row work off the keyed paths. Counted in instructions per query on the benches' own fixtures (the table below), it's at parity with 5c7cb1f99 on all seven keyed scenarios: five take fewer instructions than it, the other two are within the measurement spread of it (+0.05% and +0.2%), and several are now below main too (minus_count −5.9%, q9 −1.5%). Allocations per query match 5c7cb1f99.

Instructions per query on the criterion scenarios. These are counted on the benches' own fixtures and queries: dev-fast build, Apple M4, eight processes per build, medians, process-to-process spread ±0.2–0.8%. Allocations per query match this PR's first head (5c7cb1f99, before Claude's review).

Scenario vs main vs this PR's first head
q9 −1.5% −1.5%
minus_count −5.9% −2.4%
not_exists_after_optional −2.1% −1.3%
multi_pattern_hash_join −0.7% −0.3%
unbound_filter_operand −0.9% +0.05%
single_triple_probe +0.3% −0.5%
object_correlated +1.3% +0.2%

The object_correlated gap to main, and its 2 extra allocations per required row, were in that first head too.

Arena values crossing a GRAPH scope. Decoding at a GRAPH scope's exit now also materializes vectors (big numbers were already decoded there), and a seeding row's vectors and big numbers are decoded on the way in. The cost is confined to arena-backed bindings that cross between two different graphs; a projected one was decoded for output anyway. Not measured.

The local numbers, with their method (a shared, loaded machine)

dev-fast builds of main before #1967 (ffeeec554), main (61b836e9a) and this branch, on a shared macOS machine (load 8–19, other sessions building). Medians. "Paired" means both builds served side by side with repetitions alternated, and every query's full sorted result compared between the two before timing.

The reported shape. 1M triples: 300 documents × 640 chunks, plus 23,100 schema:subjectOf links. The WHERE yields 3,230 rows. Server over HTTP, 2 rounds × 3 queries:

State Query before #1967 main this PR
index adopted after a write (server steady state) SELECT with the OPTIONAL 0.023 s 1.166 s 0.012 s
COUNT with the OPTIONAL 0.007 s 1.126 s 0.003 s
the UPDATE 0.031 s 1.157 s 0.027 s
loaded fresh, indexed SELECT with the OPTIONAL 1.162 s 1.168 s 0.011 s
COUNT with the OPTIONAL 1.122 s 1.142 s 0.003 s
the UPDATE¹ 1.322 s 1.335 s 0.191 s
novelty pending SELECT with the OPTIONAL 0.023 s 0.023 s 0.013 s
COUNT with the OPTIONAL 0.008 s 0.007 s 0.005 s
the UPDATE 0.024 s 0.025 s 0.025 s
any the same SELECT without the OPTIONAL 0.010 s 0.010 s 0.010 s

¹ Includes the server's first write after loading, about 0.15 s on every build.

#1973's synthetic. 20,000 concepts, 5,000 skos:broader triples, 24,500 rows. The server is restarted for each run, and RSS is the server process's peak:

before #1967 main this PR
JSON-LD reverse OPTIONAL 1.79 s / 3,820 MB 1.79 s / 3,860 MB 0.058 s / 58 MB
SPARQL spelling 1.77 s / 3,871 MB 1.81 s / 3,873 MB 0.077 s / 88 MB

The same OPTIONAL over every chunk (192,000 driving rows, 193,356 result rows), restarted server, 2 runs:

State main this PR
indexed 76.2 s / 11,547 MiB 0.387 s / 433 MiB
novelty pending 1.46 s / 6,669 MiB 0.398 s / 719 MiB

The lane's window was chosen against two measured extremes: answering each required batch as it came, and coalescing the whole driving side up to the 512K cap. They were within 6% of each other on time and 30–40 MiB apart on peak RSS. The 100,000-row window bounds the buffering at the join lane's own flush size.

OPTIONAL shapes, paired (1M ledger, 7 rounds):

Query indexed: main → PR novelty pending: main → PR
one document's chunks with their ?p ?o, OPTIONAL ?c subjectOf ?s (rows) 1,249 → 11.9 ms 25.7 → 12.4 ms
the same, COUNT 1,134 → 3.7 ms 7.1 → 5.5 ms
every chunk, COUNT 68,312 → 37.1 ms 668 → 203 ms
subject-correlated OPTIONAL ?s schema:text ?t over every link, COUNT 11.8 → 11.3 ms 27.4 → 27.1 ms
both slots correlated OPTIONAL { ?s ?x ?d } over every chunk, COUNT 787 → 799 ms 1,093 → 1,245 ms²
a literal-correlated OPTIONAL ?c schema:name ?t, one document 72.8 → 5.1 ms 146 → 5.1 ms

² Main's answer is wrong here: it binds ?x on 0 of the 192,000 rows, this PR on all 192,000 (every chunk has prov:wasDerivedFrom ?d). The extra time is the work of matching: the batched joins bind ?s and ?d encoded while the OPTIONAL's scan decodes, and each pair is compared across forms.

Keyed surfaces, paired (1M ledger, 9 rounds). Change vs main without / with FLUREE_DISABLE_QUERY_FAST_PATHS:

Query indexed novelty pending
GROUP BY ?p (1M rows) +1.1% / +2.1% −79.3% / −79.9%
DISTINCT ?p (1M rows) +7.0% / +3.3% −79.5% / −79.4%
GROUP BY ?s (1M rows, 200k groups) −0.3% / +0.4% −0.2% / +0.1%
DISTINCT ?o (1M rows) −0.2% / +1.5% −0.7% / +1.2%
MINUS keyed on subjects +0.9% / +3.7% +0.2% / +1.1%
GROUP BY ?type +1.3% / +2.1% +2.2% / +3.2%

Loaded box. This machine's run-to-run noise is about 20%, so the indexed-lane deltas of +1–4% (and +7% on DISTINCT ?p in one configuration) are within it; across three earlier builds of this branch the same queries ranged from −1% to +7%. The cost they would reflect is per predicate-valued key: one relaxed atomic load to map a predicate binding to its subject id when its IRI is also a subject. With novelty pending, predicate keys no longer pay a subject-dictionary lookup per row, so those queries run about 5× faster.

Novelty-predicate resolution on a ledger with 5,001 predicates, novelty pending, paired (resolved for every predicate per operator → per predicate met): DISTINCT ?p over one subject (26 predicates met) 0.600 → 0.538 ms; GROUP BY ?p over one subject 0.449 → 0.421 ms; GROUP BY ?p over every triple, which meets every predicate either way, 8.22 → 8.37 ms (41 rounds). The per-operator cost removed is about 0.06–0.08 ms per 5,000 predicates, and it grew linearly with the predicate count.

First query after a restart (the predicate memo's first use), fresh server per run, 5 runs: GROUP BY ?p 0.057 s on main, 0.059 s here; DISTINCT ?p 0.054 s, 0.055 s.

Not re-run on this machine at this head: the criterion benches (query_hot_optional, query_hot_values_star, query_hot_negation_count, query_hot_whole_graph_agg, query_overlay_matrix, query_hot_bsbm). The quiet box ran nine of their scenarios at the final head (above), and all 36 at the head before Claude's review.

Before ready

  • The keyed-path cost: instruction parity with 5c7cb1f99 reached (f99928dd4), and time stable against main on the quiet box at the final head (83900eff1), both under Performance.

Tests

Every case in the test file checks a hand-written expectation: the OPTIONAL and equality cases in three index states (loaded fresh, the server steady state after an index adoption, and an unrelated write pending); the novelty shapes where a batched lane binds a novelty id or a blank node while a scan decodes the same term; several graphs of one ledger; property-path endpoints; where the new OPTIONAL lane must and must not fire; the numeric rule; BIND onto a bound variable; and #1320's seeded-subplan count. Each fix was also shown red without it and then restored. The case list and that table are folded:

The test file, case by case

All in fluree-db-api/tests/it_query_optional_encoded_bindings.rs (in grp_query). Every case checks a hand-written expectation.

  • Three index states for the OPTIONAL and equality cases, as before:
    • loaded fresh (epoch 0);
    • index adopted after a write (novelty drained, epoch ≠ 0, the server steady state);
    • an unrelated write pending.
    • Positions and forms covered:
    • JSON-LD twins, two SPARQL UPDATE WHEREs (the reported delete, and a cross-position delete), and Cypher OPTIONAL MATCH plus a count over it.
    • The equality surfaces: DISTINCT, COUNT(DISTINCT), GROUP BY, MINUS, EXISTS / NOT EXISTS, trailing VALUES, joins. These have JSON-LD twins.
  • Novelty that touches the queried predicates. These are the shapes where a batched lane binds a novelty id or a blank node while a scan decodes the same term:
    • a concept minted in novelty, through OPTIONAL's object lane, over MINUS, DISTINCT, COUNT(DISTINCT), GROUP BY, sameTerm, = and a trailing VALUES, with JSON-LD MINUS and selectDistinct twins;
    • a persisted blank-node concept through the same lane;
    • a novelty-minted chunk and a blank-node chunk through the join's lane;
    • both OPTIONAL slots correlated after batched joins, with the IRIs minted in novelty.
  • Several graphs of one ledger (FROM <g1> FROM <g2>):
    • OPTIONAL correlated on its object and on its subject, with each lookup required to be bound;
    • a join on an encoded IRI;
    • DISTINCT across both graphs;
    • arena-backed literals whose handles collide across the two graphs, under projection, a join, an OPTIONAL, MINUS and GROUP BY. The member scans must decode them, and a single-graph scan must not.
  • Arena values crossing a GRAPH scope, on a fixture with one vector and one big number per graph, every one handle 0 of its arena:
    • out of the scope: a vector projected out of GRAPH <g2> (with a JSON-LD twin), and a default-graph vector joined against a GRAPH scope;
    • into it: a default-graph vector and big number carried in by an OPTIONAL (with a JSON-LD twin);
    • inside a union: a vector projected out of GRAPH <g3>, and joined with the union;
    • inside a union whose first graph is the scope's own: the vector and the big-number joins;
    • a same-ledger SERVICE inside GRAPH <g3> that reads g2: a vector projected out of it, and one carried into it.
  • Path endpoints: a predicate binding as a property-path endpoint (indexed and novelty), a novelty-minted subject as an endpoint, and a predicate binding as a GRAPH ?g name.
  • Where the lane must fire and where it must not. Every OPTIONAL case states it, against the optional_object_probe stamp on the same ledger:
    • it fires for ref objects;
    • it declines every window of literal objects;
    • it declines the windows it cannot probe in a mixed stream;
    • it is never consulted when the subject is also correlated.
    • The JSON-LD, UPDATE and Cypher twins assert the stamp too.
    • Further cases: the lane's novelty merge (a pending retract of a base link plus pending asserts); an UPDATE and a JSON-LD transaction filled from a novelty-minted ?c; and a non-root policy, which leaves the lane on when it cannot touch the predicate and keeps it out when it can.
  • The numeric rule: a numeric object correlated beside a bound subject matches by value across numeric datatypes, with the index fresh and with novelty pending (see Decision for review).
  • BIND onto an already-bound subject (a constant IRI, after an OPTIONAL, and built from a joined value) keeps the row when the computed IRI is the same term.
  • perf: EXISTS semijoin cache probe declines EncodedSid rows to the generic per-row path #1320: counts the seeded EXISTS subplans a query runs: 3 before, 0 after.
Shown red without their fix, then restored

Shown red without their fix, then restored (needles grepped after each restore):

Test or check How it was shown red Result without the fix
The four novelty-touching tests The canonicalization removed (this branch's sources before it) 13 assertion failures
The two-graph test The ledger-level view disabled and the join's unify forced to pass 3 failures: both OPTIONAL lookups ran with the slot free, and the join returned every match three times
The colliding arena literals Sources without the member decoding (this branch before it) 11 failures: all five surfaces wrong (projection prints 9.5 as 1.5) and every decoding check
The arena guard Member decoding disabled The debug assertion fires: an arena handle reached a key with no graph view
The six GRAPH-scope tests This branch's sources before the crossing All six fail: the wrong vectors printed, the false pairs, the entry cases binding y1, and the debug assertion on the three union joins
The same six The exit widened to vectors only, still keyed on the graph ids differing, with nothing decoded on the way in Three still fail: the vector and big-number joins with a union led by the scope's graph hit the assertion, and every entry case binds y1
The SERVICE test The SERVICE without the crossing, the GRAPH scope's in place Both cases: g2's vector projected out prints g3's, and a g3 vector carried in matches g2's
Path endpoints The path fix reverted Full-closure cross products, indexed and novelty
The lane-routing assertions object_probe_column forced to None 9 tests fail, 51 routing assertions
The merge test The lane's novelty merge dropped The retracted link kept, the asserted links missing
The policy test The probe-lane admission's policy gate removed The lane fires and returns the links the policy hides
The numeric test The numeric rule reverted The xsd:long match missing
The BIND test The inline BIND check reverted No rows, every shape, both encoded states. The BIND operator's check, reverted alone, fails nothing: no tested shape reaches it

Run against main's sources, the whole file fails: all 25 tests. The result-level failures are the rows of Wrong results this fixes; the rest are routing assertions for a lane main does not have, and the #1320 subplan counts.

Not changed here (follow-ups)

  • A key type that makes a cross-form comparison impossible to write. Binding's PartialEq stays form-sensitive by design, since it has no store. Every keyed surface now canonicalizes through TermDicts, but nothing stops a new surface from hashing raw bindings. Its design, sites and effort are folded below this list.
    • The canonicalization it would wrap is the one function this PR introduces.
  • An IRI no dictionary holds keeps its decoded form, so its Sid and Iri forms (a VALUES constant and a BIND(IRI(…)), say) still key apart. This is pre-existing and needs a namespace-table canonical Sid for unknown IRIs.
  • Probe lanes inside an UPDATE's WHERE with novelty pending. A transaction evaluates its WHERE over a one-member dataset, and the shared probe-lane admission declines any dataset there once novelty is pending. So the reported delete runs per-row lookups in that state, bound ones, as fast as main. Admitting a one-member dataset is a change to every batched lane and belongs in its own PR. The tests pin the current routing.
  • The batched probe lanes accept EncodedSid / Sid keys only. An EncodedPid row declines to the per-row path: correct, but not batched.
  • object_correlated: the batched object lane's per-required-row overhead (2 allocations per required row, +1.8% instructions on the quiet box) could be trimmed.
The TermKey follow-up: design, sites and effort
  • Design. A TermKey newtype whose only constructor is TermKey::of(&Binding, &EqualityNorm). Then either remove Hash from Binding, or add a disallowed_types lint on maps and sets keyed by Binding / Vec<Binding>. Every keyed site must then name it, and a raw-form site cannot be written.
  • Sites. About 18 files: DISTINCT, GROUP BY, MINUS, the dataset dedup, VALUES, the hash join's IRI arms, the group-aggregate key builders, semijoin, subquery, the range semijoin, the membership join, the annotation probe, OPTIONAL's partition and unify, the encoded fast paths in eval/rdf.rs and eval/compare.rs, and BIND.
  • Effort. Roughly 800–1,500 changed lines, plus a perf pass on DISTINCT, GROUP BY and the hash join, since key construction must stay allocation-free for encoded forms.

Overlap with #2006

git merge-tree --write-tree of this branch and #2006 at its head c67dc3516:

Semantic seams for whichever PR merges second:

Solo impact

Query results change on indexed ledgers, and on ledgers with novelty pending; these are corrections:

  • OPTIONAL rows that silently lacked a value now get it. This applies where the OPTIONAL's triple meets a predicate binding or an object ref used as a predicate. With novelty pending, it also applies where the value comes from a batched join, or the IRIs were minted since the last index.
  • One term met in two forms is one term. DISTINCT, COUNT(DISTINCT), GROUP BY, MINUS, EXISTS, NOT EXISTS, trailing VALUES, sameTerm and BIND treat one IRI as one term whether it is met as a predicate, as a subject, decoded, or by a novelty id. So: fewer duplicate rows, merged groups, and a MINUS or NOT EXISTS that removes what it should.
  • A SPARQL UPDATE or JSON-LD transaction whose WHERE has those shapes now deletes or inserts what it matches.
  • A query over several graphs of one ledger (FROM <g1> FROM <g2>) with a correlated OPTIONAL no longer fails with an internal error, and its joins no longer repeat each match. Its big-number and vector values are compared, grouped and printed by value, where one graph's value could stand in for another's.
  • Big numbers and vectors crossing a GRAPH scope keep their own graph's value, in both directions; so do those crossing a same-ledger SERVICE that reads another graph. Before, a vector projected out of GRAPH <g> could print another graph's vector, and a value carried in or out could match another graph's value.
  • Property paths whose endpoint comes from a predicate binding, or from a subject minted since the last index, no longer pair the row with the whole closure.
  • OPTIONAL correlates a numeric value by value across numeric datatypes in every index state (see Decision for review). On main this held only with novelty pending.

No API, wire format or index format changes. The store gains a per-predicate memo, filled on each predicate's first use. A transaction's WHERE with novelty pending still runs the OPTIONAL row by row (see the follow-ups), so the reported delete in that state is as fast as on main, not faster.

aaj3f added 30 commits October 1, 2026 10:10
…dex states

Pins the answers a single-triple OPTIONAL and the equality surfaces must
give whichever form a row carries a term in, on a ledger that is
- loaded fresh with the index covering every commit (overlay epoch 0),
- written through a cached handle that then adopted an index (novelty
  drained, epoch nonzero: a long-running server after #1967), or
- carrying novelty the index does not cover.

Each case checks a hand-written expectation in all three states, and that
the OPTIONAL's lookup ran with its correlated slot bound (no scan of the
triple with that slot left a variable). SPARQL, JSON-LD, the WHERE of a
SPARQL UPDATE, and Cypher's OPTIONAL MATCH.

Red on main: on the encoded lane the OPTIONAL drops matches whose
correlated value is an encoded predicate (`?p rdfs:label ?l` after
`ex:s1 ?p ?o`) or an encoded subject used as a predicate, and scans the
whole predicate for an encoded object. DISTINCT, GROUP BY, MINUS, EXISTS,
NOT EXISTS and a trailing VALUES treat one IRI met as a predicate and as a
subject as two terms. With novelty pending, an OPTIONAL after a batched
join drops every match.
…triple

A single-triple OPTIONAL substituted a correlated value into its pattern
only when the value was decoded, plus an encoded subject in the subject
slot. Any other encoded value (late-materialized: an indexed ledger with
no live novelty, or a batched join lane at any time) was left a variable.
The OPTIONAL then scanned the whole predicate and left the correlation to
`unify_check`, which compares with `==`:

- an IRI reached as a predicate (`EncodedPid`) and met as a subject or
  object (`EncodedSid`), or the reverse, compares unequal, so the match
  was silently dropped (`ex:s1 ?p ?o . OPTIONAL { ?p rdfs:label ?l }`
  never found a label);
- an encoded object from a batched join against the scan's decoded
  object (novelty pending) compares unequal too, dropping every match;
- where the forms agreed the answer was right but cost rows x the
  predicate's size, and the scan was cached under the one key every row
  shared (#1973: a 13 s delete).

The nested-loop join already decoded every encoded form into every slot.
Its per-slot substitution is now a shared function both operators call,
so the two copies cannot drift again; the join's behaviour is unchanged.
OPTIONAL uses it, answers no match without a scan when a value cannot
fill its slot (a literal as a subject), and refuses an encoded value it
cannot decode rather than leave the slot free.

The scan cache key follows: every value substitution binds keys by its
identity in every position (encoded values by their encoded id), so two
rows share a cached scan only when they would run the same lookup. The
unit test that pinned an encoded object keying as free now pins the
opposite.
The scan encodes one IRI twice: as `EncodedPid` where a pattern reaches
it as a predicate, as `EncodedSid` where it is a subject or object.
Decoded producers carry it as `Sid`/`Iri`. `normalize_for_key` mapped
decoded IRIs to `EncodedSid` but left `EncodedPid` alone, and the hash
join's `join_key` kept its own copy of the rule, so on the encoded lane
one IRI met as a predicate and as a subject was two terms to DISTINCT,
GROUP BY, MINUS, EXISTS / NOT EXISTS, a trailing VALUES and hash joins:
duplicate rows, split groups, MINUS removing nothing, EXISTS finding
nothing. (`Iri` against `EncodedSid` also keyed apart in the hash join.)

One rule now, `canonical_iri_id`: an IRI keys by its persisted subject
id when it has one, else by its predicate id. `encoded_equivalent` and
`join_key` both go through it. The store memoizes each predicate's
subject id on first use, so a key over `EncodedPid` values pays a load
per row; an all-`EncodedSid` key and a decoded key with a subject id
take the same path as before.

OPTIONAL's `unify_check` compares with `same_term`: equal or same-form
values answer as before, and only a pair of different forms is
canonicalized, through the same normalization.
…ST probe

`?s ... OPTIONAL { ?c <p> ?s }` correlates on the OPTIONAL triple's
object. With the substitution fix each required row ran its own bound
lookup; now the whole required batch is answered by one sorted pass over
OPST, binding the optional-only subject, like the subject-keyed probe
does for `OPTIONAL { ?s <p> ?o }`. The required side is coalesced into
that one probe when the lane is admitted.

The OPST walk is the nested-loop join's bound-object lane, extracted
into `for_each_object_probe_match` and shared rather than copied: one
leaf read per touched leaf, a binary search per object inside each
leaflet, and the same novelty merge (`ObjectProbeOps`). The join's lane
calls it with its existing emit path; its rows and fuel are unchanged.

The OPTIONAL lane is admitted under the same gates as the join's
(single ledger, encoded output allowed, a mergeable overlay, no history
range or policy-filtered predicate) and declines to the per-row lookups
when a row's object is not a ref with an id (unbound, a literal, an IRI
the dictionaries do not hold). It stamps `optional_object_probe` so a
test pins that it answered.
An EXISTS inside an expression (`FILTER(EXISTS {...} || ...)`) is
answered from a cached subject set of its predicate when the index has
no live novelty. The probe took only a decoded `Sid`, but in exactly
that state the scan emits subjects encoded, so every row declined and
ran the EXISTS body as a seeded subplan. An `EncodedSid` carries the
persisted subject id the cache holds, so it now probes directly.

The test counts the seeded subplans a query runs (a nested-loop join
opened per declined row): three before, none after, on the fresh and
the adopted-index states.
The per-row path returns a batch as soon as one required row's matches
are drained, so each holds a row or a few, but every column was
allocated with `batch_size` (1,000) slots. Any consumer that buffers the
output kept all of them: #1973 measured 160-171 KB per required row, a
43 GB OOM on a 61k-row vocabulary page. Columns of a batch under half
full are now shrunk to their rows before the batch is built.

The batched lanes still fill whole batches; this only changes the
small ones.
Adds COUNT(DISTINCT), JSON-LD not-exists, groupBy and values, and a
Cypher count over OPTIONAL MATCH to the mixed-form cases, all with
hand-written expectations in the three index states. Also pins the
surfaces that already treated the forms as one term (inner joins on a
predicate binding or on an object used as a predicate, FILTER = and
sameTerm, a leading VALUES, subquery and group joins, a multi-pattern
OPTIONAL), so the canonical form cannot regress them.
Measured on a 1M-triple ledger (warm server, dev-fast), the canonical
form first cost +9% on GROUP BY / DISTINCT over predicate bindings on
the indexed lane, and +31% with novelty pending, where predicate keys
are decoded `Sid`s: each missed the subject dictionary and then built
its IRI string to ask the predicate dictionary.

- `predicate_subject_id` resolves every predicate on first use (up to
  4,096 of them; lazily per predicate above that) and records whether
  any predicate IRI is a subject at all. Usually none is, so the
  per-row call is an inlined load and a branch.
- A decoded IRI asks the predicate table first: a namespace bit test,
  then a hash probe on the `Sid`, no string building. A decoded
  predicate key no longer pays a subject-dictionary lookup per row at
  all, so GROUP BY / DISTINCT over predicates with novelty pending run
  about 5x faster than on main (300 ms -> 60 ms on the 1M ledger). An
  IRI in no predicate's namespace pays one bit test before its subject
  lookup.
… bound

`?s <p> ?t . OPTIONAL { ?s <q> ?t }` correlates on both subject and
object. The substitution fix decoded the row's encoded object into the
scan as well, a dictionary lookup per row that only narrowed a lookup
already narrowed to the subject; the `query_hot_optional`
object_correlated bench measured it at +3.5%. When the row binds the
subject, an encoded object now stays free in the scan and the
correlation is enforced by `unify_check`, whose `same_term` treats the
encoded and decoded forms of one term as one. The cache key follows the
same per-row decision, so a row keys its object as free exactly when its
scan leaves it free.

Tests add the shape, plus one where the unify must match an object
across forms: with novelty pending the batched joins emit the subject
and the correlated object encoded while the OPTIONAL's scan decodes.
…arms

`normalize_for_key` and `binding_to_group_key_normalized` now ask
`encoded_iri_canonical` first: an `EncodedSid` returns at once (it is
canonical already), and an `EncodedPid` costs the store's per-predicate
load, before the NUM_BIG check and the decoded arms of
`encoded_equivalent`.

Paired measurement on the 1M-triple ledger (main and fix servers warm
side by side, 15 alternating repetitions per query): GROUP BY / DISTINCT
over subjects and objects within +/-1% of main on both lanes; GROUP BY
over predicates +0.7%, DISTINCT over predicates +2.5-2.9% on the indexed
lane (about 1 ns per row); GROUP BY / DISTINCT over predicates with
novelty pending -79%.
… nodes

Equality surfaces resolved a decoded term to its id through the persisted
dictionaries alone, and `encoded_equivalent` refused blank nodes while the
hash join resolved them. With novelty pending a plain scan decodes every
term, but the batched lanes bind a subject minted since the last index by
its novelty id and a persisted blank node by its subject id. The two forms
of one term then keyed apart: MINUS kept rows it must remove, DISTINCT and
GROUP BY split one term in two, COUNT(DISTINCT) overcounted, and sameTerm
and a trailing VALUES dropped matches. The OPTIONAL bound-object lane made
this reachable from the plainest reverse OPTIONAL.

`TermDicts` is now the one place a query resolves a decoded term to its
id: the persisted dictionary first, then the novelty dictionary, the order
every lane assigns ids in. The canonical IRI id is the subject id when the
IRI has one (persisted or novelty), else the persisted predicate id.
Blank nodes follow the same rule on every surface. A decoded IRI asks the
subject dictionary first again, so a decoded subject key costs the one
lookup it cost before predicates had ids here; the novelty dictionary is
probed only after a persisted miss, only when it holds entries, and the
probe no longer allocates.

Every surface goes through it: `normalize_for_key` (DISTINCT, GROUP BY,
MINUS, EXISTS), the group-aggregate keys and COUNT(DISTINCT), the hash
join's `join_key`, OPTIONAL's unify (`same_term`, its normalization now
built once per operator), VALUES compatibility, BIND onto a bound
variable, and the sameTerm fast path, which compares canonical ids and
hands a miss to the decoding comparison instead of answering false. The
four copies of the persisted-then-novelty subject lookup (OPTIONAL, the
nested-loop join's batched key, the property join, the annotation
enumeration lane) now call it.
…nd the join

A dataset of several graphs of one ledger (`FROM <g1> FROM <g2>`) has no
graph view, while each graph's scan still binds encoded ids. OPTIONAL then
refused an encoded correlation value with an internal error, and the
nested-loop join left the slot free with nothing to correlate it: its
unify instructions were built from right-side variables that exclude
every left variable, so they were always empty, and a value left free
matched every row of the slot (each match repeated once per left row).
The join also ignored a value that can never fill its slot (a grouped,
list or map value as an object) and matched the free slot instead.

`CorrelationView` is what a correlated scan decodes through: the active
graph's view, or, with several graphs of one ledger active, a view over
the ledger's store, through which the ids a ledger shares across its
graphs (subject, predicate, string dictionary) and inline literals
decode. An arena-backed literal handle (NUM_BIG, vector) names a value
only within its own graph and stays free.

The nested-loop join now acts on the substitution outcome: a value that
cannot fill its slot yields no right rows, and every variable the right
batch still binds that the left row carries (a slot substitution left
free) must be the same term on both sides (`same_term`). OPTIONAL leaves
such a slot free for its unify check instead of raising an error. The
dead unify-instruction construction in the join is removed.
…ints

A correlated property-path endpoint resolved `EncodedSid` through the
persisted dictionary only and had no `EncodedPid` arm. A predicate bound
in predicate position (`ex:s1 ?p ?o . ?p ex:parentProp+ ?super`), or a
subject a batched join bound by its novelty id, resolved to nothing, and
the row fell into the full-closure branch, pairing it with every closure
pair. The endpoint now resolves an `EncodedSid` through the novelty-aware
graph view and an `EncodedPid` through the predicate dictionary.

`GRAPH ?g`'s binding extraction gets the same `EncodedPid` arm so it stays
total across binding kinds. No plan is known to reach it encoded: the
`ex:s2 ?g ?o . GRAPH ?g { ... }` case the test pins was already right.
…t must not

Every OPTIONAL case now states what the lane must do, so a lane that stops
firing, or fires where it must not, fails on the same ledger as its twin:
it fires for ref-valued object correlations, declines every window of
literal objects, declines the windows it cannot probe in a mixed stream,
and is never consulted when the OPTIONAL also correlates its subject. The
JSON-LD, UPDATE and Cypher twins assert the lane's stamp rather than any
bound lookup. Inside an UPDATE's WHERE with novelty pending the lane
declines at the shared probe-lane admission (a transaction's WHERE runs
over a one-member dataset), and the test pins that too.

New cases: the lane's novelty merge (a pending retract of a base link and
pending asserts), an UPDATE template and a JSON-LD transaction filled from
a `?c` minted in novelty, and a non-root policy that leaves the lane on
when it cannot touch the predicate and keeps it out when it can.
…ect is bound

With the subject bound, OPTIONAL left an encoded object to its unify check
instead of decoding it into the scan. For a numeric that changed the
match: a substituted numeric matches by value across numeric datatypes
(`1` and `"1"^^xsd:long`), the inner join's rule, and the unify check
compares terms, so the answer depended on whether the subject happened to
be bound. Numerics are substituted, so OPTIONAL matches a numeric the way
the join does in every index state. Main matched it by value only on the
novelty lane, where the value arrives decoded, and as a term on the
indexed lane. Strings, dates and IRIs, for which the two matches agree,
stay with the unify check and keep its saving.

The OPTIONAL subject lane also checks the eager-materialization gate the
bound-object lane checks. No caller reaches it eagerly today.
…lazy memo

Keyed surfaces over decoded predicates, and the first query after an index
publish, paid for the canonical form:

- A decoded IRI asked the subject dictionary first, so a decoded predicate
  key (novelty pending: `GROUP BY ?p`, `DISTINCT ?p`) paid a subject lookup
  that misses, then a predicate probe, then a novelty probe per row. The
  predicate table answers first again, now behind a per-namespace mask of
  its predicates' name lengths, so an IRI outside every predicate's
  namespace or of a length no predicate there has costs a bit test, not a
  hash. Which persisted predicates are novelty subjects is found once per
  operator rather than probed per key.
- The predicate-to-subject memo resolved every predicate on first use, a
  dictionary lookup per predicate the index holds inside the first query
  after a publish. Each predicate now resolves on its own first use, and
  the per-key read is one relaxed atomic load.
- `EqualityNorm` reads which novelty layers hold entries once, not on every
  per-row `dicts()`.
- OPTIONAL's bound-object lane coalesced up to the 512K-row cap of the
  hash-join lanes before probing. It now coalesces at most the window the
  join's own bound-object lane flushes at (100,000 rows); main answered
  these OPTIONALs row by row and buffered nothing.
- A per-row output batch is shrunk to its rows only when it is under an
  eighth full, not under half.
The BIND operator's clobber check kept a row only when the bound value and
the computed one were structurally equal. A pattern binds a subject encoded
on an indexed ledger while an expression computes it decoded, so the check
dropped a row whose computed IRI is the bound term. It now uses
`same_term`, as the inline BIND check does since the canonicalization
commit. The JSON-LD shapes the tests pin all reach the inline check; no
query shape found reaches the operator's check with a bound target, so this
keeps the two paths on one rule.
… does not

The performance notes claimed every equality surface keyed an IRI by one
canonical form. That was not true for novelty-minted ids, blank nodes,
the sameTerm fast path, property-path endpoints or BIND. It is now, and
the notes say so, along with the two cases still outside the rule (an IRI
no dictionary holds, an arena-backed literal with no graph view) and the
OPTIONAL lanes' correlation rules.
…BY and DISTINCT

The group-aggregate key builders and DISTINCT built the per-row view of the operator's normalization on every row, twice per row in GROUP BY. They now read it once per batch.
…g it

same_term canonicalized a mixed pair by encoding the decoded side, a
reverse dictionary lookup per pair. The commonest mixed pair, a batched
lane's encoded subject against a scan's decoded one with novelty pending,
now decodes the encoded side through the graph view, which memoizes it per
subject.

On the 1M-triple bench ledger with novelty pending, an OPTIONAL correlated
on subject and object over 192,000 rows (`?s schema:about ?t . ?s
prov:wasDerivedFrom ?d . OPTIONAL { ?s ?x ?d }`) went from 1,448 ms to
1,245 ms (paired runs on a loaded box). Main answers it in 1,093 ms but
binds `?x` on none of the rows; this branch binds it on all of them.
The numeric-object case runs with the index fresh and with novelty pending: main matched the decoded value of the novelty lane by value and the encoded one of the indexed lane as a term.
A big number's or a vector's encoded key is a handle into an arena scoped
to one graph and predicate, counted from 0. A dataset of several graphs of
one ledger (`FROM <g1> FROM <g2>`) bound those handles in each member scan
and compared, joined, grouped and projected them in a context with no
graph to decode them in, so g1's 1.5 and g2's 9.5 under one predicate,
both handle 0, were one value: a join paired the wrong rows, MINUS removed
the wrong ones, GROUP BY merged them, and projection decoded g2's handle
through g1's arena and printed 1.5 for 9.5.

A scan whose context is one member of a union of graphs (`with_graph_ref`
under a parent with several active graphs) now binds those literals
decoded, so a graph-scoped handle never leaves its graph. Scans of a single
graph are unchanged; the scan's open event records which ones decode
(`arena_literals_decoded`).

`same_term` refuses, as an internal error, to compare an arena-backed
literal with no graph view to decode it, rather than certify equality or
inequality by handle; the nested-loop join's check no longer compares such
handles structurally; and the keyed normalizers assert, in debug builds,
that no arena handle reaches them unviewed. Member decoding makes all of
these unreachable.
Amounts in both graphs of a two-graph dataset share arena handles. Projection, a join, an OPTIONAL, MINUS and GROUP BY over them, with the member scans required to decode, and a single-graph scan required not to.
…e's first key

With novelty pending, the first predicate key an operator canonicalized probed the novelty dictionary for every persisted predicate, a per-operator cost that grows with the ledger's predicate count. Each predicate is now probed on its own first key, into a per-operator slot table read with one relaxed load after that.
…ctions

A GRAPH scope's scans bind big numbers and vectors as handles into its own
graph's arenas. Its exit decoded big numbers only, and only when the
scope's graph id differed from the outer context's. So:

- a vector left the scope as a handle. Outside, it was printed or matched
  as another graph's vector, or it reached a union of graphs, where
  comparing it is an internal error;
- in a union whose first graph is the scope's own, the ids match although
  the union decodes in no graph, so big numbers left undecoded too;
- a row seeding the scope carried the outer graph's handles in, and the
  scope decoded them through its own arenas.

ArenaCrossing decodes a handle crossing in either direction through the
graph that bound it, unless both sides read one single graph. It replaces
the two copies of the big-number exit.
One vector and one big number per graph, each handle 0 of its own arena,
so every crossing that decodes through the wrong graph changes the answer:

- a vector projected out of GRAPH, alone and inside a union, and a
  default-graph vector joined against a GRAPH scope (with a JSON-LD twin);
- a GRAPH-scope vector joined with a union, and the vector and big-number
  joins with a union whose first graph is the scope's own;
- a default-graph vector and big number carried into a GRAPH scope by an
  OPTIONAL (with a JSON-LD twin).
A same-ledger SERVICE reads the dataset's first graph of that ledger, which
is another graph when the SERVICE sits inside a GRAPH scope. Its body then
crosses graphs as a GRAPH scope does. A seeding row's vector or big number
was decoded through the target's arenas, and the body's own handles were
decoded downstream through the scope's. It now uses the same ArenaCrossing.
aaj3f added 2 commits October 1, 2026 21:58
Inside GRAPH <g3>, a SERVICE on the same ledger reads the dataset's default
graph g2. A vector projected out of it keeps g2's value, and a g3 vector
carried into it matches nothing in g2.
@aaj3f aaj3f added bug Something isn't working as expected area:sparql SPARQL/Turtle/TriG/JSON-LD parsing, lowering, UPDATE semantics, W3C conformance area:query Query execution, planning, fast paths, overlay, result formatting performance Performance improvement (release notes: Performance) labels Oct 2, 2026
aaj3f added 2 commits October 1, 2026 23:34
…aths

Instructions per query against the pre-review head 5c7cb1f found the
rework adding per-row work in five places, each now out of the hot loop:

- The scan wrapped every late-materialized object binding in a filter for
  graph-union members. It now decides by object type, and only when the
  scan is a union member, before building a binding.
- MINUS rebuilt its equality normalization on every probe row. It now reads
  it once per batch and answers an encoded subject before normalizing.
- The key normalizers built their dictionaries before looking at the
  binding. An encoded subject is its own key and now returns first.
- OPTIONAL's subject probes and the join's batched lane built the term
  dictionaries for every row, even for an encoded subject, whose id needs
  none. They now read them only for a decoded subject.
- OPTIONAL kept the full-size columns of output batches between an eighth
  and a half full, where the pre-review head shrank them. Not shrinking
  cost more instructions downstream on the hash-join lane (+0.7% median,
  and bimodal across processes), so the threshold is back to half.
… a no-op

The batched R2RML GRAPH path seeds the whole parent batch without decoding
its arena handles. That is sound only because it runs without a dataset,
where the source scope inherits the outer graph id. A debug assertion now
pins the crossing as a no-op there.
@aaj3f
aaj3f requested review from bplatz and zonotope October 2, 2026 16:22
@aaj3f
aaj3f marked this pull request as ready for review October 2, 2026 16:22
/// (a big number's or a vector's) names its value only within this graph,
/// so a scan here decodes those literals rather than binding the handle:
/// the rows leave for a context with no single graph to decode them in.
pub graph_union_member: bool,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This overlaps with #2004 (d38608e79), which landed on main after this branch's base. Main already decodes members of a multi-graph union of one ledger as they scan: DatasetOperator::decode_members sets eager_materialization on each member context. And ScopeExit in graph.rs decodes every encoded binding a GRAPH scope hands to a union. After a rebase, context.rs and dataset_operator.rs merge cleanly, so both mechanisms run (graph.rs conflicts).

I'd keep main's member decode and drop graph_union_member (with the arena_literals_decoded stamp) and CorrelationView::Ledger. Decoding only the arena kinds still sends every other encoded value into a context with no graph view, and several surfaces decode through ctx.graph_view():

The "Wrong results" table and the "the whole file fails on main" check need re-running against current main. Several of the FROM <g1> FROM <g2> rows should already pass there: the internal error, the tripled join, and the colliding arena handles under projection, join, OPTIONAL, MINUS and GROUP BY.

// The seeding row's arena handles (big numbers, vectors) name values
// in the outer graph, and the rows leaving carry handles into this
// graph's arenas: each is decoded through its own graph on the way.
let crossing = crate::object_binding::ArenaCrossing::between(ctx, &graph_ctx);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Main has nothing for this direction, or for the SERVICE crossing, so these are worth keeping. On rebase I'd fold enter into main's ScopeExit (which now decodes everything on exit into a union) rather than carry ArenaCrossing beside it.

// arena crossing is a no-op both ways.
let crossing = crate::object_binding::ArenaCrossing::between(ctx, &graph_ctx);
debug_assert!(
crossing.is_noop(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On main this branch is now taken when ctx.explicit_dataset().is_none(), which includes the implicit union-default-graph dataset from #2004. So "this path runs without a dataset" no longer holds. It's probably still a no-op, since a union has no graph view to enter through, but the comment and the assertion need re-checking against that context.

bound into the scan, whichever form the row carries it in: an encoded subject,
predicate or literal is decoded into its slot, so no row scans the whole
predicate. With several graphs of one ledger active there is no graph view, and
values decode through a view of the ledger's store instead; only an

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This goes stale if main's member decode stays (see the comment on context.rs): values from several graphs of one ledger arrive decoded.

let Some(o_id) =
resolve_subject_id(required_batch.get_by_col(row, object_left_col), ctx)?
else {
return declined();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One row this can't key declines the whole window: a literal, an unbound value, an EncodedPid, or an IRI no dictionary holds. The operator then answers one row per-row and calls build_batch again from the next row (:2983), which walks the same prefix to the same failing row. Every row before it goes per-row and the prefix is re-walked each time: about j²/2 row visits, in a window of up to 100k rows.

Example: ?s ex:link ?o OPTIONAL { ?c ex:cites ?o } where ex:link is occasionally a string, or ?o comes from an earlier OPTIONAL and is sometimes unbound. Main didn't coalesce this shape, so this is new.

The join handles the same case per row (join.rs ~1567-1625). Suggest answering the rows that can be keyed in the lane and sending only the failing row per-row (or advancing current_required_row only past the rows answered). An IRI no dictionary holds can be answered "no match" inside the lane. Every lane test is pure-ref, so a mixed-window test would pin it.

/// bindings, `false` for two of one form (other than NUM_BIG), `None` for a
/// pair of forms that must be canonicalized to compare.
#[inline]
fn one_form_answer(a: &Binding, b: &Binding) -> Option<bool> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Vectors need the same exemption as NUM_BIG here. Vector arenas are per predicate and don't dedupe, and Binding::eq ignores p_id for VECTOR_ID. So handle 0 under ex:embA equals handle 0 under ex:embB, and one vector stored on two subjects gets two handles that compare unequal.

Suggest is_arena_encoded here and in both normalizers (normalize_for_key_cow, binding_to_group_key_normalized), plus p_id in Binding's eq/hash for VECTOR_ID. The keyed-surface side predates this PR (#1957), but the OPTIONAL routing in object_left_to_unify makes it newly reachable.

Binding::EncodedLit { o_kind, .. } => ![
ObjKind::NUM_INT.as_u8(),
ObjKind::NUM_F64.as_u8(),
ObjKind::NUM_BIG.as_u8(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A VECTOR_ID object goes to same_term here, which compares handles (see the comment on one_form_answer). ?s ex:embA ?e OPTIONAL { ?s ?p ?e } binds ?p = ex:embB when s got handle 0 in both arenas, and equal vectors under different handles miss. The inner join decodes by value and gets both right, so "OPTIONAL agrees with the inner join" doesn't hold for vectors. At minimum, add VECTOR_ID to this exemption.

Binding::EncodedPid { p_id } => binary_store
.and_then(|st| st.resolve_predicate_iri(*p_id))
.and_then(|iri| db_for_encode.encode_iri(iri)),
_ => None,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A bound value that lands here is treated as unbound, and the row gets the whole closure. That covers a literal, an Iri that won't encode, an EncodedSid whose resolve fails (the .ok() above swallows it), or no graph view. Example: ?s ex:age ?a . ?a ex:next+ ?n pairs every row with every ex:next pair, where the answer should be no rows.

It's pre-existing, but it's the same class as the table row this PR fixes. Suggest that a bound endpoint that can't be resolved yields no rows, and that a decode failure errors.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:query Query execution, planning, fast paths, overlay, result formatting area:sparql SPARQL/Turtle/TriG/JSON-LD parsing, lowering, UPDATE semantics, W3C conformance bug Something isn't working as expected performance Performance improvement (release notes: Performance)

Projects

None yet

2 participants