Skip to content

Wave 4: resolve the eight deferred follow-ups from the #1462 review (#1467-#1474) - #1483

Open
aaj3f wants to merge 11 commits into
mainfrom
burndown/wave-4
Open

Wave 4: resolve the eight deferred follow-ups from the #1462 review (#1467-#1474)#1483
aaj3f wants to merge 11 commits into
mainfrom
burndown/wave-4

Conversation

@aaj3f

@aaj3f aaj3f commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Wave 4: resolve the eight deferred follow-ups from the #1462 review

Stacked on #1462 (burndown/wave-3). Resolves all eight issues filed out of that review: closes #1467, closes #1468, closes #1469, closes #1470, closes #1471, closes #1472, closes #1473, closes #1474.

Restacked twice onto the moving #1462 head (post-review-fixes, then post-origin/main-merge). The lint-chore cherry-pick deduplicated away on the second restack exactly as planned — main now provides the original 2c5162014 through #1462's merge — so the branch is 8 issue commits + 3 review-response commits (see the new section below), with the toolchain-pin commit standing alone (its message records the history).

Each issue got a code-verified audit before design (issue claims checked against current code; three issue texts had stale refs or wrong framing — corrected below), an adversarially-reviewed plan, and an adversarially-reviewed implementation. One commit per issue; the two chore commits at the tip are a coupled pair (see #1474 below) — don't bisect between them.

What each commit does

fix(transact): reification-aware f:reifiesGraph re-homing for COPY/MOVE/ADD (#1467) — Whole-graph Transfer now re-homes annotation bundles instead of rejecting them: named→named rewrites each f:reifiesGraph object to the dest graph Sid, named→default drops the flake, default→named synthesizes one. The wave-3 fail-loud guard is gone, replaced by a round-trip acceptance matrix (COPY/MOVE/ADD × all three graph-direction cases × JSON-LD @annotation + SPARQL {| |} survival, decode with no GraphMismatch, source intact for COPY / gone for MOVE). One new fail-loud case discovered during plan review ships with it: ADD where the same explicit reifier @id reifies a different edge in source and dest would silently merge bundles and drop both annotations — that is now detected and rejected. clear_dest uses a symmetric rehomed-contents model, so COPY onto an identical annotation leaves the already-correct dest anchor in place rather than churning retract+assert at the same t.

fix(query): carry language tag through lit_to_comparable (#1468) — Stored lang-tagged literals now reach FILTER evaluation as TypedLiteral{LangTag} instead of bare String, so stored "x"@en = "x"@fr is false and "x"@en = "x"@en is true (§17.4.1.7), matching what the constant path already did. The string builtins that must stay tag-transparent go through a narrow string-arg helper that accepts plain and lang-tagged strings only — deliberately not as_str(), which would have silently accepted IRIs and foreign-datatype literals where the spec requires type errors; regression tests pin IRI/foreign-dt args as type errors at every swept site. Builtins that were already tag-correct off the Binding (CONCAT, UCASE/LCASE, STRBEFORE/STRAFTER, LANG) are untouched.

fix(query): tag encoded xsd:float and error on encoded lang EBV (#1470) — The EncodedLit (binary-store) decode arm now consults dt_id: a stored xsd:float becomes ComparableValue::Float, so ?f + ?f on an indexed float yields xsd:float (was xsd:double). The EBV arm errors on lang_id != 0, matching the constant/Lit path. The EBV half is a consistency fix, not a W3C-conformance claim — RDF 1.0's plain-literal reading of §17.2.2 would make lang-tagged EBV truthy, RDF 1.1's makes it an error; the two engine paths disagreeing with each other was the actual bug. Tests run against indexed fixtures (memory-backed ledgers never exercise this path).

fix(query): three-valued error propagation in IN / NOT IN (#1471) — Completes the O2 work from #1462 (value-equality shipped there; this ships the §17.4.1.9 error rule). The audit found the issue text undercounted: there were two swallow sites per function — EqOutcome::TypeError falling into a catch-all, and demotable eval errors — in both eval_in and eval_not_in. Now a definitive match still short-circuits, but no-match-with-≥1-errored-comparison is a type error (row-drop in FILTER, unbound in BIND, catchable by COALESCE), mirroring eval_or's three-valued pattern. 1 IN (1.0) stays true.

fix(testsuite): tighten UpdateEvaluationTest empty-result guard (#1472) — The manifest parser now exposes predicate-presence on the result node, so a present but content-empty mf:result (e.g. an unresolvable ut:graphData) is treated as a parse problem instead of "expected empty store" — closing a false-pass risk. Bare [] still means expected-empty. No W3C test outcome changes.

fix(query): default-graph FROM union is a set, not a bag (#1469) — Per §13.2 the dataset default graph is the RDF merge (a set) of the FROM graphs. The audit found the bag-union bug in two places, not one: DatasetOperator::next_batch and the drain_count COUNT(*) fast path. Both now dedup at (s,p,o) triple identity across default-graph members — armed only when the default union has ≥2 members, so no-FROM, single-FROM, and FROM-NAMED-only paths take exactly the prior code path (fused fast paths and pruned emit masks intact). Implementation review caught that ground/all-constant BGPs (empty solution schema) escaped the dedup and double-counted; the deduper now collapses the empty mapping to at most one solution per scan, with regression tests that fail without it. A debug_assert ties dedup arming to full variable-identity emission so a future dataset path can't silently over-dedup. History-mode unions are excluded (assert/retract rows must not merge). Known, pre-existing limitation (unchanged by this PR): property-path and SERVICE operators build their own scans and bypass DatasetOperator, so they are not deduped over a multi-default union.

refactor(api): parse SPARQL once on the within-ledger dataset path (#1473) — The audit confirmed (and slightly worsened) the issue's count: every SPARQL query parsed ≥2×, the FROM/dataset route up to 3×. All four SPARQL entry methods now parse+validate once and thread the AST through dataset-clause resolution and lowering. Behavior identical (validation-error messages included); a cfg(test) parse counter pins parse-once on both the FROM and no-FROM paths. The boxed-future Send boundary is preserved.

chore: rustfmt & clippy fixes for new rust 1.97(historical; no longer on the branch) This was a cherry-pick of main's 2c5162014 (author preserved; patch-id verified identical), needed because wave-3 branched before it landed on main. It deduplicated away when the stack restacked onto the post-merge #1462 head, as predicted here.

chore(ci): pin rust-toolchain 1.97.0, honor pin in CI, deny warnings (#1474)rust-toolchain.toml pins 1.97.0 (with rustfmt+clippy components); the five CI toolchain steps get explicit @1.97.0 refs (dtolnay/rust-toolchain@stable ignores the toml — the floating @stable was the root cause of the surprise lint-debt this issue was filed about); the main clippy job now runs -D warnings. (The formerly-coupled cherry-pick has deduplicated away — see above — so this commit now stands alone.) One knock-on worth knowing: release.yml installs Rust via bare rustup (latest stable), but the new root rust-toolchain.toml now transitively pins release builds to 1.97.0 too — previously they floated with stable. No conflict (cargo-dist ships as a prebuilt binary), just a behavior change to be aware of.

Review-response commits (from the pre-review of this PR)

Three commits at the tip resolve the pre-review's findings on this PR's own work:

  • fix(query): re-tag encoded language-tagged literals so indexed =/!= are tag-aware — the pre-review's one blocker: query: datatype-aware =/!= for stored language-tagged literals (§17.4.1.7) #1468's tag carry stopped at the Binding::Lit path, so stored-lang =/!=/IN were tag-aware on the memory path and tag-blind on the indexed (EncodedLit) path — the production-typical one ("x"@en = "x"@fr compared equal; constant-vs-stored FILTER(?l = "x"@fr) returned zero rows). The decode arm now re-tags from lang_id, symmetric to query: xsd:float first-class on the EncodedLit (binary-store) path #1470's FLOAT re-tag beside it; indexed twins (seed_indexed, range_provider-asserted) cover =/!=/IN/constant-vs-stored on both surfaces and fail without the fix.
  • fix(query): set-merge multi-USING WHERE unions; string_arg accepts xsd:string; parse-once pinned end-to-end — the UPDATE-WHERE path now arms the query: within-ledger FROM default-graph union should be a set-merge, not a bag #1469 multi_default_graph planning flag for USING <g1> USING <g2> (the operator-level dedup already self-armed, so this closes the emit_is_full soundness-coupling gap and declines bag-cardinality fast paths, with an end-to-end set-semantics test); string_arg and str_arg_and_lang now share one accept-set (an explicit xsd:string is a simple literal — CONTAINS and STRBEFORE previously disagreed on STRDT(?x, xsd:string)); and the query: G2 within-ledger FROM path re-parses SPARQL up to 3× (cold-path) #1473 parse-once guarantee is pinned END-TO-END through the entry methods, not just the primitives. Also corrects testsuite harness: tighten the UpdateEvaluationTest empty-result guard #1472's motivating example in the code comments (an IRI ut:graphData object resolves at parse time — the guard's real cases are a nested graphData missing rdfs:label, or non-content predicates).
  • test(sparql): pin the review's untested re-homing and union-dedup scenarios — six coverage adds, all confirming inspected-only behavior: ADD same-edge/same-reifier succeeds (the collision guard's false-positive boundary), named→default MOVE/ADD, multi-hop COPY a→b→c, the synthesized default→named anchor surviving the durable-index seal, multi-FROM + OFFSET/LIMIT paging the deduplicated sequence, and a var-var join where both scans carry the cross-member duplicate (1×1, not 2×2). Plus two doc notes: the N3 schema-clobber note covers COPY/MOVE TO DEFAULT, and history mode is documented as the one place a multi-FROM union stays a bag.

Issue-text corrections found during audit

Verification

  • Full workspace test suite, testsuite-sparql W3C harness (36 suites), cargo fmt --all --check, and CI-exact cargo clippy --all --all-features --all-targets -- -D warnings all green on the final tip (clippy verified cold after cargo clean -p on every crate in the lint table, ruling out the warm-cache false-negative).
  • Net W3C register delta: zero, by design — none of these fixes green a registered W3C test as scoped (the greenable candidates all have second blockers, documented per-issue). The conformance value ships as new unit/integration tests, including JSON-LD parity twins for every SPARQL-expressible semantics change (shared-IR rule).
  • No measured hot-path regression: the dominant eval arms are byte-unchanged (one added integer compare on the encoded decode/EBV arms); the query: within-ledger FROM default-graph union should be a set-merge, not a bag #1469 dedup is statically skipped for every single-graph shape.

@aaj3f
aaj3f force-pushed the burndown/wave-4 branch from 5049b34 to df3674e Compare July 13, 2026 22:40
aaj3f added 8 commits July 13, 2026 20:12
…VE/ADD (#1467)

The graph-management Transfer arm re-homed a graph by rewriting each flake's
flake-level `g`, but an edge annotation also encodes its edge's graph in the
`f:reifiesGraph` OBJECT. Rewriting `g` without the object desynced the two, so
`EdgeKey::from_reifies_facts` returned `GraphMismatch` and both readers (JSON-LD
hydration + attachment indexer) silently dropped the annotation. Wave-3 blocked
this with a fail-loud guard; this replaces the guard with a correct re-home.

Three cases, keyed off the destination and whether the source is the default
graph:
- named->named: rewrite the anchor OBJECT (and `g`) to the dest graph.
- named->default: DROP the anchor (the default graph carries none).
- default->named: SYNTHESIZE one anchor per reifier subject naming the dest.

clear_dest now keys on the RE-HOMED source contents so COPY/MOVE stay symmetric
(no same-`t` retract+re-assert of an anchor that already names the dest graph).

ADD (clear_dest == false) can still merge two bundles that share an explicit
reifier `@id` but reify different edges in source and dest, producing a
`Duplicate` that drops both annotations. That case is now detected and rejected
loudly; COPY/MOVE are immune because clear_dest retracts the colliding dest
bundle first. Blank-minted reifiers never collide.

Tests replace the wave-3 rejection with a survival matrix: COPY/MOVE/ADD across
named->named / named->default / default->named, each asserting a clean
`from_reifies_facts` decode with the anchor naming the expected graph, source
intact for COPY and gone for MOVE, plus the ADD collision error, COPY/MOVE
non-triggering on the same setup, the same-`t` churn (COPY onto an identical
annotation), and an indexed round-trip proving both graph anchors survive the
seal-time decode in the durable arena.
A stored language-tagged literal was decoded by `lit_to_comparable` to a
bare `ComparableValue::String`, dropping the tag. On the eval/FILTER path
this made `"x"@en = "x"@fr` collapse to equal (both became `String("x")`),
disagreeing with the constant form which lowers to a lang `TypedLiteral`
and compares tag-aware. The `EqKind::Lang`/`lang_parts` machinery already
handled lang literals correctly but never received stored ones.

Route the stored `LangTag` arm to `ComparableValue::TypedLiteral{LangTag}`
(reusing the existing variant — no new one, avoiding the exhaustiveness
trap; the tag needs no snapshot). To keep the string builtins that accept
a language-tagged argument (§17.4.3) transparent to the tag rather than
erroring on the new variant, add a narrow `ComparableValue::string_arg`
choke-point (same accept-set as CONCAT: plain / xsd:string / lang, but NOT
an IRI or a foreign-datatype literal) and route CONTAINS, STRSTARTS,
STRENDS, REGEX, REPLACE, SUBSTR, and LANGMATCHES through it. Sites that
already read the tag off the binding (CONCAT, UCASE/LCASE, STRBEFORE/
STRAFTER, LANG), normalize via `unwrap_typed_literal` (cast.rs), or already
match `TypedLiteral{String}` (geo, temporal, DATATYPE, to_binding) are
unaffected; STRDT/STRLANG/IRI correctly keep rejecting a lang literal.

Full tag propagation through the builtins (§17.4.3, `UCASE("x"@en)`=
`"FOO"@en`) stays deferred. Net W3C register delta: zero.
Two eval-path inconsistencies between the late-materialized
`Binding::EncodedLit` path and the Lit/constant path:

- An xsd:float is folded to `FlakeValue::Double` at decode (the NUM_F64
  fast path in `context.rs`), so `datatype(?f + ?f)` came back xsd:double
  on the encoded path while the Lit path (`lit_to_comparable`) keeps the
  float tag. Consult the in-scope `dt_id` in the EncodedLit decode arm and
  re-tag FLOAT as `ComparableValue::Float` — one integer compare on the hot
  arm; the numeric / xsd:string fast path stays untouched.

- A language-tagged literal decoded to a bare string and read string-truthy
  in the EBV path, whereas the Lit path (`lit_effective_bool`) raises a type
  error. Check `lang_id` before decoding and error, so an encoded lang
  literal has no effective boolean value (§17.2.2). This is a consistency
  fix aligning the encoded path with the Lit/constant path, not a
  W3C-conformance claim (RDF 1.0's plain-literal reading is contested; no
  register test pins it).

Foreign-datatype carry (register eq-4) and constant-side typed-literal
carry (open-eq-05/06) stay deferred. Tests query a reindexed, novelty-free
ledger so scans late-materialize to `EncodedLit` (a trailing insert would
silently drop them to the Lit path — §5.4); verified they fail without the
fix. Net W3C register delta: zero.
Per SPARQL 1.1 §17.4.1.9, `E IN (E1..En)` is `(E=E1) || … || (E=En)`
under OR three-valued logic: true if any element matches by value
equality; otherwise an error if any comparison errored; otherwise false.
NOT IN is its negation (NOT of an error is an error).

eval_in / eval_not_in previously swallowed both error sources — an
EqOutcome::TypeError from rdf_term_equal (incomparable datatypes, the
primary case) fell into the Ok(Some(_)|None) catch-all, and a demotable
element-eval error (e.g. 1/0) was dropped — so a no-match result read as
false (IN) / true (NOT IN) instead of a type error. Mirror eval_or's
pending_error accumulation: a definitive match still short-circuits and
discards any pending error; otherwise a pending error is returned. The
synthesized in-list type error is ComparisonError::TypeMismatch, matching
=/!= and demoting to unbound in Extend / false in FILTER (catchable by
COALESCE). The shipped value-equality behavior (67eca54) is unchanged.

Adds eval_in/eval_not_in unit tests and SPARQL + JSON-LD parity
integration tests (BIND/BOUND, FILTER, and COALESCE observing contexts,
since ASK+FILTER hides error from false). No W3C register delta.
A present blank `mf:result` whose predicates none resolved to content
— e.g. `[ ut:graphData <unresolvable> ]` where get_graph_data yields
nothing — had result_present == true and was mis-treated as an
"expected empty store", passing trivially against a coincidentally-empty
store. The existing guard only caught an ENTIRELY-absent result.

The manifest parser now records result_has_predicates (whether a blank
result node carried ANY outgoing predicate), and the guard bails when a
present blank result carried predicates but none resolved to
ut:data/ut:graphData/ut:result content. A bare `mf:result []` (zero
predicates — the deliberate empty-store expectation used by DROP ALL /
update-silent) has result_has_predicates == false and still passes, so
the real W3C corpus is unchanged (net-zero live impact).

Adds manifest-parser unit tests over a hand-built graph covering the bare
`[]`, present-but-content-empty, and IRI-result shapes.
Per SPARQL §13.2 the default graph of a dataset is the RDF merge (a set)
of its FROM graphs, so a triple present in two members must be emitted
once and COUNT(*) must count it once. The within-ledger/cross-ledger
DatasetOperator concatenated per-member output (next_batch) and summed
per-member cardinalities (drain_count) — a bag union.

Deduplicate emitted triples across members, active only for a current-mode
>= 2-member default union (named scopes always resolve to one member;
history rows keep per-event assert/retract metadata the dedup key ignores).
The dedup reuses DistinctOperator's EqualityNorm hashing so encoded and
decoded twins of a value collapse, and runs after provenance stamping so
the cross-ledger path keys on IRI-level values. drain_count falls back to
counting the deduplicated next_batch stream for that case.

Because a pruned emit column would collapse two distinct triples into one
row, thread a multi_default_graph flag through PlanningContext: the planner
forces EmitMask::ALL on the first scan (the only prunable-mask scan) and
declines the fused count/aggregate fast paths (which assume bag cardinality
over the union). A default graph named twice collapses to one member at
dataset-build time, keeping FROM <g> FROM <g> on the single-graph fast path.

FROM NAMED / GRAPH ?g semantics are unchanged (named members stay
per-graph). Net W3C register delta: zero.
…1473)

Every SPARQL entry method re-lexed the query string: query_with_options
built the within-ledger dataset (parse #1) and then lowered it (parse #2);
the FROM-default single-ledger route parsed a third time inside the dataset
impl to test for a dataset clause. That is 2x for all SPARQL and up to 3x
on the FROM path.

Parse + validate once, then reuse the AST for both dataset-clause resolution
and IR lowering. helpers gains lower_sparql_ast (inject-prefixes-then-lower
from an owned AST), inject_default_prefixes, and sparql_ast_has_dataset;
parse_sparql_to_ir is now a thin parse-then-lower wrapper. build_within_ledger
_dataset_from_ast resolves the clause from an already-parsed AST. The four
SPARQL entry methods (query_with_options, query_tracked_with_options,
query_tracked_with_r2rml_options, query_view_with_r2rml_options) parse once
and thread the owned AST into the dataset path via new prepared entries on
query_dataset_with_options / query_dataset_tracked_with_options (their public
signatures, with many external callers, stay unchanged).

Behavior is identical. The boxed-future boundary that breaks the mutual-
recursion Send auto-trait cycle is preserved: only owned data (the AST) is
threaded through it, never opaque async recursion. A cfg(test) parse counter
plus helpers unit tests assert the single-parse guarantee by construction;
the existing dataset/tracked/r2rml suites and the W3C testsuite cover
behavioral equivalence. No register change.
…1474)

Add rust-toolchain.toml pinning channel 1.97.0 so local dev and CI build
on a deterministic toolchain. dtolnay/rust-toolchain@stable ignores the
file, so pin the ref itself (@1.97.0) on all four ci.yml toolchain steps
and the bench.yml step. Flip -D warnings onto the main clippy job now that
the workspace is lint-clean and the pin means lints only change on a
deliberate bump.

(History note: this commit originally landed paired with a cherry-pick
of main's 2c51620 lint chore; that cherry-pick deduplicated away — as
planned — when the stack rebased onto the post-merge burndown/wave-3,
which now carries the original via origin/main. The two rustfmt hunks
for r2rml/rewrite.rs and sort.rs it left unformatted are likewise
already clean on the merged base.)
aaj3f added 3 commits July 13, 2026 21:05
…re tag-aware

#1468 carried the language tag through lit_to_comparable, but the
sibling EncodedLit decode arm — the default path for a reindexed,
novelty-free ledger — still collapsed a stored lang literal to a bare
String (FlakeValue cannot carry the tag), so stored-lang `=`/`!=`/IN
were tag-aware on the memory/Lit path and tag-blind on the indexed path:
`"x"@en = "x"@fr` compared equal, and a constant-vs-stored
`FILTER(?l = "x"@fr)` returned zero rows. The engine was also internally
inconsistent on one ledger — JOIN/DISTINCT hash lang_id and were already
lang-aware where FILTER equality was not.

Re-tag from the in-scope lang_id as a lang TypedLiteral — symmetric to
the FLOAT re-tag beside it (#1470) and the lang_id check in
binding_effective_bool. One integer compare on the hot decode arm; the
meta decode runs only for lang-tagged rows.

Indexed twins (seed_indexed, range_provider-asserted) cover stored-vs-
stored `=`/`!=`, constant-vs-stored `=`, IN, and the JSON-LD pair;
verified they fail without the fix.
…d:string; parse-once pinned end-to-end

Three review follow-ups on the wave-4 work:

- The UPDATE-WHERE path builds its own multi-member default union for
  `USING <g1> USING <g2>` without setting the #1469 multi_default_graph
  planning flag. The OPERATOR-level set-merge self-arms on >= 2 members,
  so end-to-end set semantics already held for the shapes we could
  construct (verified: a shared triple binds one WHERE solution with or
  without the flag) — but arming was out of contract with the
  emit_is_full soundness coupling, which requires the plan to force
  EmitMask::ALL whenever a multi-default scan can arm dedup (a future
  pruned-mask plan on this path would over-dedup in release builds), and
  the fused count/aggregate fast paths assume bag cardinality.
  execute_where_streaming now arms the flag from the dataset shape,
  giving every caller of the primitive the same planning contract as the
  query path; single-graph datasets are byte-identical. The new test
  pins the end-to-end §13.2 set semantics on the UPDATE path (one minted
  template blank for a triple present in both USING graphs).

- string_arg rejected TypedLiteral{Explicit(xsd:string)} while its
  sibling str_arg_and_lang accepts it, so STRBEFORE(STRDT(x, xsd:string), y)
  worked where CONTAINS type-errored. The two choke-points now share one
  accept-set (an explicit xsd:string is a simple literal); foreign
  datatypes stay type errors.

- The #1473 parse-once guarantee was asserted only on the primitives;
  a lib test now drives the ENTRY methods (buffered no-FROM, buffered
  within-ledger FROM, tracked) and pins sparql_parse_count == 1
  end-to-end.

Also corrects the #1472 guard's motivating example in both comments: an
IRI ut:graphData object resolves at parse time regardless of file
existence (it fails later at load), so the guard's real cases are a
nested graphData missing its rdfs:label or a result carrying only
non-content predicates.
…narios

Six coverage adds from the #1483 review, all confirming behavior that
had only been verified by inspection:

- ADD with the SAME reifier reifying the SAME edge in source and dest
  succeeds (the collision guard's false-positive boundary — equal edge
  signatures dedup cleanly; only genuinely-divergent reifiers error).
- named→default MOVE and ADD drop the f:reifiesGraph anchor like the
  already-covered COPY; MOVE retracts the source, ADD keeps it.
- Multi-hop COPY a→b→c re-homes the anchor at each hop — all three
  graphs hold an independently-decodable annotation naming its own graph.
- default→named at the DURABLE-INDEX layer: the SYNTHESIZED anchor
  (`COPY DEFAULT TO <g>` — the source never had one) survives the
  attachment indexer's seal-time from_reifies_facts decode; exactly one
  anchor row / one distinct reified graph in the arena.
- Multi-FROM + OFFSET/LIMIT: the §13.2 set-merge sits below the slice,
  so paging is stable over the deduplicated sequence.
- A var-var two-pattern join where both patterns carry the cross-member
  duplicate on the shared subject joins 1×1, not the bag's 2×2.

Docs: the N3 schema-clobber note now covers COPY/MOVE TO DEFAULT (same
clear_dest retraction), and with_multi_default_graph documents the one
place a multi-FROM union stays a bag (history mode, per-event rows).
Base automatically changed from burndown/wave-3 to main July 14, 2026 02:44

@bplatz bplatz left a comment

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.

Approving. The branch is green on everything I could measure locally: cargo clippy --all --all-features --all-targets -- -D warnings is clean, cargo nextest over fluree-db-api / fluree-db-query / fluree-db-transact with --all-features passes 4822 tests, and the full testsuite-sparql W3C run is 36/36 suites green with no stale register entries.

Nothing below blocks the merge — none of it is a reproduced failure. Please triage which of these you think are worth fixing here versus deferring or closing out; happy either way.


1. BatchDeduper turns a streaming path into an unbounded-memory one

dataset_operator.rs:200-270

Every FROM <a> FROM <b> query now accumulates a Vec<Binding> signature per distinct row into a HashMap that lives for the whole scan. SELECT * FROM <g1> FROM <g2> WHERE { ?s ?p ?o } goes from bounded-memory streaming to O(distinct triples) resident, with full Binding clones as keys and no fuel or memory accounting. drain_count compounds it: COUNT(*) over a union abandons the per-member count-only shortcut and materializes the entire distinct set.

The comment notes that hashing mirrors DistinctOperator, which is fair, but DISTINCT is user-requested and visible in the query text; this is implicit on any multi-FROM. Options, cheapest first: store a u64/u128 hash rather than cloned bindings; charge fuel per insertion; document the cliff the way scan_graph_flakes documents its own (stage.rs:1058-1063).

2. The emit-mask / dedup coupling is enforced only by debug_assert!

dataset_operator.rs:518

PlanningContext::multi_default_graph forces EmitMask::ALL in make_first_scan only, but DatasetOperator::scan is built at seven sites. Five are safe — optional.rs:434, where_plan.rs:2599 and fast_group_count_firsts.rs (x2) hard-code ALL, and operator_tree.rs:2450's pruned mask sits behind the fused fast paths this PR declines. Two take a planner-computed mask that can prune:

  • join.rs:1435/1457make_right_scan(self.right_emit, ...), fed straight from emit_mask_for_triple
  • property_join.rs:983 — the existence-only { s: true, p: false, o: false } branch, which fails emit_is_full() by construction

I probed 11 query shapes over 2-member unions (plain join, LIMIT, DISTINCT, ORDER BY, COUNT, three-pattern, OPTIONAL, UNION, and property-join existence-only) with open() instrumented, and every one reported emit_full = true and returned the correct set-merged multiset. So this is latent, not live. But the assert compiles out in release, so if a future plan shape does prune, the symptom is silently wrong cardinality in production and a panic only under test.

Cheapest hardening if you want it: apply the same if planning.multi_default_graph { EmitMask::ALL } in build_scan_or_join's Some(left) branch — one edit, covers both remaining sites — and/or promote the debug_assert! to a returned error.

3. The encoded lang re-tag cannot see overlay / ephemeral lang ids

eval.rs:196-209

The re-tag resolves through ctx.binary_store, the persisted BinaryIndexStore. dict_overlay::decode_meta is the one that handles assign_lang_id()-allocated ephemeral ids; the persisted store's language_tags.get(lang_id - 1) simply misses, returns lang: None, and the value falls through to the old tag-blind String. That would put "x"@en = "x"@fr back to true for a novelty-resident lang literal sitting on top of an indexed ledger — the exact #1468 bug, reappearing as a function of materialization.

extract_lang_tag (string.rs:63-71) has the identical shape, so if this is reachable it is one shared fix. A test that stages a language-tagged literal after indexing would settle it.

4. Smaller items

  • Per-row double allocation on the lang re-tag. decode_meta clones the tag String, then Arc::from(tag) allocates again — two allocations per lang-tagged row per expression evaluation, on what the comment calls the hot decode arm. A lang_id -> Arc<str> cache on ExecutionContext would make it free and also cover extract_lang_tag.
  • drain_count's dedup branch silently forgoes count-only. Worth surfacing in plan_details so EXPLAIN says why.
  • temporal_mode.rs:122 — an orphaned sentence fragment is left in the doc comment (/// full per-event stream rather than a deduplicated set.).
  • view/query.rs:836validate_sparql_for_view still carries the inline match &ast.body that sparql_ast_has_dataset was extracted to replace, and its error string is now duplicated verbatim in query_r2rml_with_options. Three copies of one rule.
  • stage.rs:1391Vec::with_capacity(src_flakes.len() + 1) is under-sized for the default -> named synthesis case (one extra per reifier). Cosmetic.
  • No docs/ changes. Candidates: docs/design/query-execution.md for the §13.2 set-merge and its cost, dev-setup/contributing for the toolchain pin and a bump policy, and CLAUDE.md itself — its "CI runs" line now omits -D warnings, and its "Pre-existing Warnings" section is stale now that clippy --all-features is clean.

5. One question

COPY <g1> TO <g2> with an explicit reifier @id now leaves the same reifier subject carrying f:reifiesSubject / f:reifiesPredicate / f:reifiesObject in two graphs. The new ADD guard exists precisely because two bundles on one subject make EdgeKey::from_reifies_facts return Duplicate and drop both. transfer_named_to_named_survives_reindex proves the attachment indexer is graph-scoped — is the JSON-LD hydration reader too, or can it ever assemble a bundle by subject across graphs?


The test work here is unusually strong. emit_is_full() as a self-describing soundness check on the builder trait, the zero-column ground-pattern case (seen_empty) that is easy to miss entirely, the OFFSET/LIMIT test pinning that dedup sits below the slice, the reindex-level gate proving the re-homed anchor survives into the durable arena, and the thread-local parse counter asserting parse-once through the real entry methods rather than the primitives. The IN / NOT IN pending-error handling is right, including a definitive match dominating a pending error. And pairing the toolchain pin with -D warnings in the same commit is the correct order to do those two things in.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants