Skip to content

feat(sparql): burn-down Wave 3 — within-ledger FROM (D-3), UPDATE graph-management (D-5/6), equality/EBV/promotion (D-12, #1447), USING+GRAPH over-delete (#1441), triple-term syntax (−145 tests) - #1462

Merged
aaj3f merged 49 commits into
mainfrom
burndown/wave-3
Jul 14, 2026

Conversation

@aaj3f

@aaj3f aaj3f commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Wave-3 SPARQL W3C burn-down — within-ledger datasets, UPDATE graph-management, the equality/EBV/promotion lattice, and SPARQL 1.2 triple-term syntax

This is the capstone wave of the W3C SPARQL test-suite burn-down: seven thematically-coherent PRs integrated onto burndown/wave-2. It greens 146 W3C tests to a suite that is 0-failed / 0-stale in both directions (cd testsuite-sparql && cargo test36 passed), shrinking the skip register from 376 to 197 entry lines (343 → 197 unique tests — the tip register carries zero duplicate entries after the c91f5556c dedup). The wave is perf-neutral: the integrated A/B vs burndown/wave-2 is within the 5% budget on all ten measured scenarios (worst +2.34%). The seven PRs are PR-G2 (within-ledger FROM/FROM NAMED), PR-W1 (nested-group FILTER scope + sub-SELECT correlation), PR-W2BC (SPARQL 1.2 triple-term syntax), PR-W2 (CONSTRUCT blank-node instantiation), PR-U3 (UPDATE graph-management verbs), PR-U6 (USING + explicit-GRAPH delete scoping), and PR-X2 (the equality/EBV/numeric-promotion lattice).

Now based directly on main#1454 (Wave 2) has merged, and origin/main (through the #1450 Iceberg stack) is merged into this branch (d072a95d6), so the PR is conflict-free against main.

Reviewer quick reference

End-user API / contract changes. SPARQL SELECT … FROM <g> FROM NAMED <n> against a single ledger's graphs now returns results instead of being rejected (PR-G2). Seven SPARQL UPDATE graph-management verbs — LOAD/CLEAR/DROP/CREATE/COPY/MOVE/ADD (+ SILENT/INTO) — now parse and execute, and the capability is exposed on the transact-builder surface (Txn::clear_graph/drop_graph/copy_graph/clear_default_graph, GraphTransactBuilder::clear_graph/drop_graph/copy_graph) (PR-U3). SPARQL/JSON-LD =/!=, effective-boolean-value, AND/OR, numeric promotion, CONCAT, and SUM/AVG are now spec-strict value semantics (PR-X2 — DELIBERATE SEMANTICS CHANGE (D-12), 2-reviewer sign-off requested) — this can return fewer rows for queries that relied on the old permissive behaviour; see the migration notes. DELETE … USING <g> WHERE { GRAPH <h> … } now scopes the explicit GRAPH block to the USING NAMED set (deletes fewer rows — a data-loss fix) (PR-U6). A CONSTRUCT whose template has a blank node now emits triples instead of an empty graph (PR-W2). SPARQL 1.2 triple-term syntax (TRIPLE/SUBJECT/PREDICATE/OBJECT/isTRIPLE and bare <<( s p o )>> values) now parses/validates and defers evaluation with a clean not_implemented (PR-W2BC). PR-W1 has no API surface change.

Performance verdict: WITHIN BUDGET. Integrated A/B vs burndown/wave-2, FLUREE_BENCH_PROFILE=full (n=100): all ten scenarios within the 5% budget, worst +2.34% (query q5). The whole wave — including the u3/u6 transact/staging path — is perf-neutral. Full table below.

Production bugs found & fixed (beyond W3C compliance). (1) USING-scoped delete with an explicit GRAPH block over-deleted from the default/other graph — a real data-loss bug, Closes #1441 (PR-U6). (2) Predicate-grouped COUNT mis-tagged xsd:long; SUM/AVG silently averaged over the numeric subset of a mixed group; AND/OR aborted the whole expression on the first operand error; CONCAT silently coerced non-strings — Closes #1447 (PR-X2). (3) A JSON-LD correlated sub-SELECT returned an extra non-conformant row when a correlation variable was bound only via an inner OPTIONAL (PR-W1). (4) PR-U2's cross-operation blank-node scope check over-rejected valid multi-op requests, and the W3C harness rejected a legitimate bare mf:result [] empty-store expectation (both fixed in PR-U3).

What this wave does (per PR)

PR-G2 — within-ledger FROM / FROM NAMED (D-3, Option A). Replaces the single-ledger dataset-clause rejection on the buffered query paths with within-ledger dataset construction: each clause IRI is resolved against the ledger's own graph registry (alias → default graph, registered named-graph IRI → that g_id), assembled into a single-snapshot DataSetDb, and executed through the existing DatasetOperator/dataset_query.rs path. A clause IRI that is not a graph in this ledger is still rejected (cross-ledger goes through the connection path). R2RML + within-ledger FROM is a documented unsupported combination (the R2RML dataset path is non-Send). Also fixes a mutual-async-recursion compile trap the delegation created — query_dataset_with_options gets a concrete boxed return (Pin<Box<dyn Future … + Send>>), dropping cargo check on the crate from 30+ min to ~7s.

PR-W1 — nested-group FILTER scope + sub-SELECT correlation. Two independent evaluation fixes. Family A: a single-child { } group whose child is scope-sensitive (FILTER/BIND/nested Group) stays wrapped as an uncorrelated subquery rather than being flattened into the enclosing scope (so a nested FILTER no longer sees an enclosing-scope variable). Family B: a sub-SELECT correlation variable bound only via OPTIONAL/UNION is reconciled at merge (excluded from the per-row seed so it binds independently, then dropped on a conflicting double-binding) — the compatible-mapping join SPARQL §18.4 requires. Common self-produced-correlation and BGP/OPTIONAL paths (incl. BSBM) stay byte-identical.

PR-W2BC — SPARQL 1.2 triple-term syntax (D-1, accept-then-defer). The lexer emits a single contextual TripleTermFn token for the five function names (not reserved keywords), the expression parser resolves + arity-validates them, and a new parse_triple_term_value accepts <<( s p o )>> in the subject/object/VALUES/BIND positions RDF 1.2 allows; lowering rejects all deferred forms with not_implemented. The §2 negative-suite guardrails (no path/collection/reified-triple inside a term; value-context vs pattern-context subject rules; bare <<( )>> . is not a statement) keep every negative test rejected. No new evaluable capability — this is syntax acceptance only.

PR-W2 — CONSTRUCT per-solution blank-node instantiation. A CONSTRUCT template blank node ([ … ], _:a, or a PR-1 collection cell) previously lowered to a never-bound variable and produced an empty graph. The fix records template blank-node variables at lowering (ConstructTemplate.bnode_vars) and mints a fresh blank node per solution row in the CONSTRUCT output path — shared within a row, distinct across rows — under a reserved cst{n} label prefix provably disjoint from stored (fdb-), BNODE() (b{hex}), and SERVICE blanks (a soundness hardening the isomorphism-based suite cannot catch).

PR-U3 — SPARQL UPDATE graph-management verbs (D-5, D-6). Grammar + AST + parse + lowering to a new GraphMgmtOp Transaction-IR directive + a whole-graph-scan staging primitive (stage_graph_mgmt). A named graph is a reserved GraphId; the execution primitive is a scan of a graph's flakes — clone-as-retraction for CLEAR/DROP, clone-with-rewritten-g for COPY/MOVE/ADD (datatype/lang/list-index ride along verbatim). CLEAR/DROP ALL|NAMED resolve their target set from the registry; COPY/MOVE compose over CLEAR with a set-difference so overlapping triples are never simultaneously asserted and retracted. The dispatch is a single if txn.graph_mgmt.is_some() check at the top of stage(), before any hot-path setup, so ordinary insert/upsert/update staging is byte-identical.

PR-U6 — USING + explicit-GRAPH dataset scoping (class F, #1441). SPARQL 1.1 §3.1.3: when USING/USING NAMED is present it defines the WHERE dataset exactly — the WHERE-visible named graphs are precisely the USING NAMED set (empty under a plain USING <g>). The bug treated an empty USING NAMED set as "no restriction" and registered every named graph, so DELETE { … } USING <g3> WHERE { GRAPH <g2> { … } } matched g2 and over-deleted. The fix restricts the runtime dataset's named graphs to exactly the USING NAMED set whenever any USING/USING NAMED clause is present; the no-USING path (plain DELETE WHERE { GRAPH <g> … }) stays byte-identical. Prepare-time only; SELECT queries never reach it.

PR-X2 — equality / EBV / numeric-promotion lattice (D-12). DELIBERATE SEMANTICS CHANGE — 2-reviewer sign-off requested. Five IR/engine value-semantics fixes on the shared fluree-db-query evaluator: D4 numeric promotion (ComparableValue gains first-class Float(f32) so integer < decimal < float < double holds and xsd:float no longer widens to xsd:double); D-EBV (a fallible, datatype-aware bare-variable effective-boolean-value — numeric-zero/empty-string falsy; lang-tagged/foreign-datatype/IRI/ill-typed/unbound → a type error excluding the row); D5/D7 (datatype-aware three-valued rdf_term_equal for =/!=, with §17.2 three-valued AND/OR); D11 (CONCAT type-errors on a non-string argument); and aggregates (predicate-grouped COUNTxsd:integer; SUM/AVG poison-to-unbound on a non-numeric member). The common int-int/iri-iri fast paths are kept first; the added per-row cost is one well-known-Sid tag-check on the xsd:double and xsd:string literal paths plus the EqOutcome wrapping on =/!=, measured neutral (query_hot_bsbm_bi/f2 −0.51% on X2's own standalone branch-vs-wave-2 n=100 A/B; the whole-wave integrated f2 is +1.80% in the perf table below — both well inside the 5% budget, and the difference between the two is measurement, not an X2 cost, since no other wave-3 PR touches the FILTER/eval hot path f2 exercises).

Decisions actioned in this wave

Per the standing transparency rule (ROADMAP §4), each decision point, the options weighed, and why the adopted option was chosen.

D-1 — SPARQL 1.2 triple-term syntax: accept-then-defer (PR-W2BC). Options: (4) accept + arity-validate the syntax, lower to not_implemented [adopted]; (3) reject-on-principle as a documented divergence; (2) desugar <<( )>> to the reifier model (dominated — a triple term is a value, a reifier is a resource, so isTRIPLE/SUBJECT/term-equality would silently diverge). Chosen Option 4: it makes Fluree parse standard SPARQL 1.2 today at zero engine/perf risk — exactly what a syntax-conformance suite rewards — while honestly deferring evaluation to the Option-1 first-class-value epic. The trade-off (a runtime not_implemented where there used to be a parse error) is named explicitly; failing fast at parse time is Option 3, a genuine either/or the team owns.

D-3 — within-ledger FROM/FROM NAMED datasets (PR-G2). Options: (A) engine within-ledger DataSet reusing the runtime dataset path [adopted]; (B) harness ledger-per-graph with query_connection_sparql (zero engine change). Both green the same 13 tests, so the tie-breakers are product value, reuse, and perf — all favour A. A fixes the actual user-facing gap (a real user can query one ledger's named graphs), extends #1279's within-ledger registry the natural way (a named graph is a g_id), and keeps a single snapshot (no cross-ledger provenance stamping, binary store stays enabled); B only satisfies the harness and spins up N ledgers per query.

D-5 — remote (non-SILENT) LOAD: documented divergence (PR-U3). Options: (a) parse LOAD, execute SILENT as a swallowed no-op, error on non-SILENT [adopted]; (b) add an opt-in HTTP fetch hook into the transact path. Chosen (a): it costs zero W3C coverage (the two eval tests LOAD a non-resolvable source under SILENT and expect the store unchanged; the syntax tests are parse-only), whereas (b) would drag an HTTP client and its failure/security surface into the embedded write path for no test benefit (ingesting a known document already has a first-class path — the insert API).

D-6 — DROP ≡ CLEAR + the empty-named-graph model (PR-U3). First half — DROP is implemented as CLEAR semantics now (the GraphRegistry is additive-only, and the two are indistinguishable to the harness, which compares/lists only non-empty graphs). Second half (a spotlighted product call) — whether a CLEAR'd/CREATE'd zero-triple named graph is enumerable by GRAPH ?g/FROM NAMED: Option A (persist a graph-existence record decoupled from flakes across storage/commit/query, greens bindings#graph + agg-empty-group-count-graph, −2) vs Option B (documented permanent divergence — a graph exists iff a flake carries its g_id) [adopted]. Chosen B: the capability is a query/dataset-model feature orthogonal to the UPDATE verbs, it is the same model fact that makes DROP≡CLEAR correct, and Option A's blast radius (three subsystems, a durable "empty graph" concept Fluree deliberately lacks) is disproportionate to −2 tests. Both tests stay registered with the full rationale.

D-12 — SPARQL strict-type-error mode vs unified strictness (PR-X2). Options: (a) a per-surface strictness flag threaded through evaluation; (b) one unified strict value-semantics path shared by all surfaces [adopted for equality/EBV/promotion]; (c) leave lenient. Chosen (b): equality/EBV/promotion are value semantics, correct on both the SPARQL and JSON-LD surfaces, and the parity tests assert the strict behaviour on both. Deliberate JSON-LD extensions are preserved by reading the binding directly rather than the surface (the lenient From<&Binding> EBVs stay for Cypher structural truthiness; PR-X1 already actioned the D2b DATATYPE/LANG @id carve-out).

D-11 (lexical-form preservation) is NOT actioned this wave — its tests (group01, the sameTerm/distinct/dawg-str lexical set) remain registered and are owned by the future PR-X3.

Second-round review fixes (after the first-round response commits)

A second adversarial pre-review pass over the whole PR produced eight more commits, all included here:

  • fix(sparql) — triple-term subject + annotation tail no longer panics. Parse-accepted input like INSERT DATA { <<( :s :p :o )>> :q :o2 {| :a :b |} } reached an unreachable! in the annotation-expansion pre-pass (it guarded QuotedTriple subjects but not TripleTerm); it now defers with the same clean UnsupportedFeature as every other deferred triple-term shape, with negative tests for the INSERT DATA and Modify-template paths.
  • fix(query) — NaN is never RDFterm-equal. "NaN"^^xsd:double = "NaN"^^xsd:double was true via numeric_cmp's bit-level total order (kept for ORDER BY); = now special-cases NaN per XPath op:numeric-equal (false; != true), scoped to numeric operands, ORDER BY unchanged. Pre-existing bug surfaced by the X2 equality review.
  • fix(sparql)USING NAMED without USING gives the WHERE an empty default graph (§13.2.1 via §3.1.3). Previously the WHERE default fell through to the ledger's real default graph, so DELETE { ?s ?p ?o } USING NAMED <h> WHERE { ?s ?p ?o } deleted the whole default graph — the UPDATE USING combined with explicit GRAPH over-deletes from the default graph #1441 over-reach class one clause over. The no-USING and plain-USING paths are byte-identical; test proves fail-without-fix.
  • fix(query) — three evaluator edges: unary minus now coerces cast results (-xsd:float("1.5") was unbound); CONCAT keeps a language tag carried on the VALUE (constants/STRLANG results — CONCAT("a"@en, "b"@en) was plain "ab") with case-insensitive tag comparison; ROUND/CEIL/FLOOR pass xsd:integer beyond i64 through instead of unbinding.
  • feat(transact) — builder move_graph/add_graph complete the transfer-verb parity, and the reserved-graph guard now also fires on same-graph transfers (COPY <…#config> TO <…#config> is refused, not a blessed no-op).
  • chore(query) — blank-free CONSTRUCT templates skip the per-row bnode probes; triple_term_fn_arity's catch-all is now a debug_assert.
  • test — coverage adds: three-valued AND dominance (both surfaces), SUM-poison pair, an indexed (reindexed, range_provider-asserted) SUM/AVG-poison twin, same-graph no-op transfers for all three verbs on both surfaces, multi-op requests mixing data + graph-management ops, CLEAR of an annotation-bearing graph, two-level nested sub-SELECT correlation + GROUP BY-keyed correlation, and per-row (not per-distinct-binding) CONSTRUCT blank minting.
  • docs — recorded the CONSTRUCT blank-node-template parity exemption in sparql-compliance.md (the FQL construct template is a JSON-LD node map with no per-row fresh-blank syntax), plus stage-side notes: SILENT-with-missing-source still clears the destination, and O3 source-existence deliberately uses registry semantics vs the query surface's flake-carried D-6 model.

Third-round: bplatz review response + the origin/main merge

All eleven review comments are resolved on the branch (per-comment replies inline). Three were already fixed by the second-round commits that landed just before the review posted (the triple-term panic guard, the USING NAMED empty-default fix, NaN equality). The six new commits (8d8870ba1..2205252c5, after the merge):

  • fix(sparql) — FROM/FROM NAMED reject the reserved system graphs. The registry legs of within-ledger FROM resolution resolved #config/#txn-meta (the registry seeds them), exposing the governance graph where the GRAPH <iri> path blocks it; both legs now apply the same >= FIRST_USER_GRAPH_ID filter, with a 4-case rejection test.
  • fix(query) — strict typed-literal EBV on both eval paths; STRDT keeps Sid datatype args. Cast/computed numerics (xsd:float("1.5") is a String tagged xsd:float) now have their §17.2.2 EBV on the BIND'd-binding path AND the function-call path (whose lenient conversion read every typed literal as truthy); ill-formed numeric/boolean lexicals are EBV false per rule 1. Fixing the tests exposed that STRDT's datatype argument (a Sid — constant IRIs lower through the encoder) was silently dropped, producing plain literals; Sid args are now decoded.
  • fix(cypher) — pin CALL imports as lateral seeds. Merge fallout: main's newly-wired Cypher suite caught W1's Family-B rule breaking CALL (p) { OPTIONAL MATCH … RETURN count(…) } zero-match retention (openCypher imports are per-row bindings, not inferred correlations). SubqueryPattern.pinned_vars carries the explicit imports — set only by Cypher CALL lowering, so SPARQL/JSON-LD Family-B semantics (join-scope-1) are untouched.
  • feat(sparql) — CREATE GRAPH registers the graph. CREATE recorded nothing, so CREATE GRAPH <g> then non-SILENT COPY <g> TO <h> hit O3's "source does not exist" error; CREATE now registers the IRI (empty, non-enumerable — D-6), with scoped exceptions so a registration-only zero-flake commit persists while ordinary no-op updates still elide. CREATE-of-existing stays a no-op (documented D-6 divergence), and the CLEAR/DROP silent flag carries a doc note on why it is never consulted.
  • fix(sparql) — unforgeable INSERT-template anon labels; FROM build-time set-dedup; typo-aware dataset rejection. The transact-side counter minted template [] blanks as _:b{n} (a user's _:b0 fused with them); now _:[]{n} per main's scheme. Repeated FROM <g> contributes one member (§13.2 set). The unresolved-IRI message now points at typos before the cross-ledger API.
  • test(transact) — pin the schema-flake CLEAR policy bypass; document the whole-graph novelty cliff. A view-only default-deny identity CAN clear a schema-only graph (the is_schema_flake exemption) — pinned beside its rejected non-schema companion; scan_graph_flakes documents the one-commit novelty-doubling cliff for whole-graph ops. (Also verified, no change: ReservedGraphTarget/SourceGraphNotFound map to 400, not 500.)

The merge itself (d072a95d6) reconciled two same-problem-solved-twice collisions with wave-2's post-branch review commits now on main: the anonymous-blank hardening (main's _:[]{N} scheme adopted branch-wide; the wave-3 #anon counter dropped) and the eval-triple-terms register dedup (each side had removed the other's kept copy of six double-registered entries — restored once each; the suite's both-direction enforcement caught it). Register content is unchanged by the merge: same unique set, 0-failed / 0-stale.

Register entries removed (146 unique / 179 entry lines; suite-driven, both directions)

  • PR-G2 (13): SPARQL10_QUERY_EVAL dawg-dataset-01..12b (12) + SPARQL11_CONSTRUCT constructwhere04.
  • PR-W1 (3): SPARQL10_QUERY_EVAL filter-nested-2, dawg-optional-filter-005-not-simplified, join-scope-1.
  • PR-W2BC (27): SPARQL12_SYNTAX_TRIPLE_TERMS_POSITIVE → empty (basic/bnode/compound/expr/inside/nested/subject/update-tripleterm + compound-all).
  • PR-W2 (3): SPARQL10_QUERY_EVAL construct-3, construct-4 + SPARQL11_CONSTRUCT constructlist.
  • PR-U3 (68 unique tests / 91 register lines): the remaining SPARQL11_UPDATE graph-management eval tests (add/clear/copy/create/drop/load/move + -silent variants, the 4 same-bnode joint tests) and the 23 double-registered SPARQL11_SYNTAX_UPDATE_1 test_* (both copies deleted per the standing rule, so 23 tests remove 46 lines; that register is now empty). Per-PR unique counts sum to 145 (13 + 3 + 27 + 3 + 68 + 2 + 29); with the SPARQL12_EVAL_TRIPLE_TERMS intra-const dedup (c91f5556c) the wave removes 146 unique tests / 179 raw register lines in total (independently recomputed from the git diff at tip; zero entries added).
  • PR-U6 (2): SPARQL11_UPDATE dawg-delete-using-02a, dawg-delete-using-06a (empties the last class-F cluster).
  • PR-X2 (29): agg02, agg-err-01, concat02, strlang03-rdf11, dawg-langMatches-1/2/3/basic, type-promotion-03/04/05/21/29/30, {add,subtract,multiply,divide}-numbers-cast, unplus-2, unminus-2, dawg-bev-1..6, eq-2-1, eq-2-2, open-eq-04.

Query-surface parity

Per docs/contributing/sparql-compliance.md §"Query Surface Parity" (JSON-LD only; Cypher excluded — we do not own the openCypher grammar): every IR/engine-level fix carries a JSON-LD regression test alongside the SPARQL one, since the W3C submodule guards only the SPARQL surface. PR-X2 (value semantics), PR-W1 (scope/correlation — incl. the JSON-LD-only correlated-subselect bug fix), and PR-G2 (within-ledger dataset, a single construction over both surfaces) are IR/engine (case a) with JSON-LD tests in it_query_expression_semantics.rs, it_query_filter_scope.rs, and it_query_dataset.rs. PR-U3 exposes the new graph-management capability on the transact-builder surface in the same PR (it_named_graphs.rs::test_graph_mgmt_transact_builder_parity); a JSON-LD document key syntax is a deliberately-deferred surface-design decision. PR-U6 is a SPARQL USING surface fix with a JSON-LD fromNamed guard test on the shared machinery (the symmetric JSON-LD from-without-fromNamed restriction is a scoped follow-up). PR-W2 (CONSTRUCT bnode) and PR-W2BC (parse-only) are SPARQL-only surface (case c) — the FQL construct form has no blank-node template syntax, and accept-then-defer introduces nothing newly executable.

Performance (integrated A/B vs burndown/wave-2 @ 3040d8e)

FLUREE_BENCH_PROFILE=full FLUREE_BENCH_SCALE=small (real n=100; query_hot_bsbm.rs:166 sets sample_size from the profile, so the env var is the real knob). Query benches whole-bench interleaved; insert/import benches per-scenario fine-interleaved (wave-2↔wave-3 back-to-back) to kill cross-run drift; self-gated on rustc=0; contended runs discarded. Δ% = (wave-3 − wave-2)/wave-2 (positive = wave-3 slower); budget 5% at small.

  • query_hot_bsbm: q3 −2.27%, q5 +2.34% (worst), q9 +0.80% — CIs 0.4–0.6%.
  • query_hot_bsbm_bi: f2 +1.80% — CI 0.4/0.6%.
  • insert_formats: jsonld/100nodes −1.28%, jsonld/10nodes +0.40%, turtle/100nodes +0.10%, turtle/10nodes +0.98%.
  • import_bulk: single_threaded −0.72%, default_threads +0.24%.

All within budget. An initial coarse-interleaved pass showed two apparent breaches (jsonld/100nodes +7.33%, import single_threaded +12.31%) that were foreign-build contention landing in the longer wave-3 windows, not a code regression: no wave-3 change touches the data-insert/bulk-import hot path (fluree-db-core + fluree-graph-turtle untouched; u3's shared-stage() change is a single per-transaction guard, byte-identical for inserts; u6's is behind sparql_where), and the clean fine-interleaved re-run collapsed both to −1.28% / −0.72% with tight CIs.

Deferrals (staying registered, with per-entry pointer comments)

The equality cluster's remaining tests are blocked outside the equality/EBV/promotion lattice and stay registered with X2-owned notes: the D5b scan-path EncodedLit datatype-carry family (eq-4, open-eq-02/05/06, dawg-lang-3, quotes-3/4, eq-graph-1/2/4 — bare-BGP object term-match, deliberately not risking the binary-index hot path), blank-node OUTPUT-identity isomorphism (open-eq-07/08/10/11/12), temporal timezone semantics (eq-dateTime, date-1), dawg-langMatches-4 (LANG-of-IRI error propagation), not-not (ill-typed literal preservation, → PR-X3), and agg-count-rows-distinct (COUNT(DISTINCT *) needs a new CountDistinctAll IR aggregate + whole-row group plumbing — a perf-neutral post-wave-3 follow-up). The D-6 empty-named-graph divergence keeps bindings#graph + agg-empty-group-count-graph registered (PR-U3). D-11 lexical-form tests (group01, sameTerm/distinct/dawg-str) are PR-X3's. nested-opt-1/2 (correlated-OPTIONAL independence) is PR-W1-OPT; join-combo-2 + optional-complex-2/3/4 are PR-G1 (GRAPH-variable) territory.

Post-review scope note (xsd:float type-preservation): first-class xsd:float type-preservation holds on the Binding::Lit/query path but not the indexed/Binding::EncodedLit (binary-store) path — a stored xsd:float that materializes via late binary-index decode is currently recovered as xsd:double, so arithmetic on such a binding yields xsd:double. This is the same perf-sensitive encoded-path class as the deferred D5b datatype-carry family (and also covers the encoded-path lang-EBV corner), tracked as a separate follow-up.

Verification & harness/CI notes

Verified (including the first-round review fixes now merged): full W3C suite 36 passed; 0 failed; 0 ignored (0 stale, both directions); -p fluree-db-query -p fluree-db-api -p fluree-db-transact -p fluree-db-sparql 0-failed (fluree-db-query 1229, fluree-db-transact 332, fluree-db-sparql 617, grp_query 360 / grp_query_sparql 287 / grp_misc 244 / grp_graphsource 113); cargo fmt --all --check clean. Clippy: this PR's diff is clean (--no-deps over the touched crates), and both CI clippy jobs are green (the main job runs cargo clippy --all --all-features --all-targets with no -D warnings; the -D warnings job is scoped to the testsuite-sparql workspace). A newer un-pinned clippy (1.97.0) surfaces a handful of pre-existing pedantic lints in untouched infra crates (bench-support / r2rml / planner / …) under a whole-workspace -D warnings run — pre-existing baseline lint-debt, not introduced by this wave, tracked separately.

Harness notes for reviewers: testsuite-sparql is an EXCLUDED separate cargo workspace — run the W3C suite with cd testsuite-sparql && cargo test, not top-level cargo test. A fresh git worktree add does NOT populate the testsuite-sparql/rdf-tests submodule — run git submodule update --init before the suite or every manifest spuriously fails. fluree-db-api uses grouped test bins (grp_query, grp_query_sparql, grp_misc, …), so scope with the full -p fluree-db-api; a per-file --test <name> matches nothing. The pre-existing resolve_shapes_source_g_ids dead-code warning in fluree-db-api/src/tx.rs:506 is base noise (untouched by this wave; does not fire under clippy --all-targets).

Migration / changelog

  • Equality/EBV are spec-strict: comparing to an unrecognized/ill-typed-datatype literal, and the truth value of a numeric-zero / empty-string / non-boolean-typed literal, follow SPARQL §17 (type errors exclude rows; numeric zero and empty strings are falsy); AND/OR apply three-valued dominance; CONCAT and SUM/AVG raise type errors rather than coercing; numeric promotion preserves xsd:float (so xsd:float(10)/4 serializes as a typed xsd:float literal, not a bare number). Queries relying on the old permissive behaviour may return fewer rows.
  • New SPARQL UPDATE verbs (LOAD/CLEAR/DROP/CREATE/COPY/MOVE/ADD + SILENT/INTO). DROP ≡ CLEAR (empties, does not unregister); CLEAR/DROP DEFAULT|ALL retract default-graph schema too (scope schema into a named graph if it must survive); CREATE is a no-op; non-SILENT remote LOAD errors (no HTTP client in the transact path — use LOAD SILENT or the insert API); empty named graphs are not enumerable. Non-SILENT ADD/COPY/MOVE error when the source graph does not exist (§3.2) rather than treating it as empty — which, for COPY/MOVE, had silently emptied the destination on a typo'd source; SILENT opts into proceeding with the missing source treated as empty — which, per the spec's own shortcut equivalence (DROP SILENT dest; INSERT … WHERE source), still clears the destination; a SILENT transfer from a missing source is NOT a no-op. Graph-management verbs also reject the reserved #config/#txn-meta system graphs as target/source. New builder API: GraphTransactBuilder/Txn clear_graph/drop_graph/copy_graph/move_graph/add_graph (full parity with the SPARQL transfer verbs).
  • DELETE … USING <g> WHERE { GRAPH <h> … } now follows §3.1.3 — the GRAPH <h> block matches only when <h> is in USING NAMED; updates relying on the old over-reach should add USING NAMED <h>.
  • SPARQL CONSTRUCT templates with blank nodes now emit triples (a bug fix aligning with §16.2; no migration).
  • SPARQL 1.2 triple-term functions and <<( )>> values now parse/validate and produce a lower-time not_implemented where they previously produced a parse error.
  • JSON-LD subquery correlation (review follow-up, PR-W1): a JSON-LD ["query"] sub-SELECT no longer pins a correlation variable that is bound only via an inner OPTIONAL/UNION — it is produced independently inside the subquery and reconciled against the parent at join time (SPARQL §18.4 compatible-mappings; fixes W3C join-scope-1). Self-produced join-key correlations are unchanged. Existing JSON-LD queries of that exact shape may return different rows (standard natural-join semantics rather than forced equality); this is a correctness fix and no query rewrite is required.

Issues closed on merge

Closes #1441 (PR-U6 — the USING + explicit-GRAPH delete over-delete data-loss bug; PR-U6 greens dawg-delete-using-02a/06a, fully resolving it — the issue has no JSON-LD component). Closes #1447 (PR-X2 — aggregate / equality / EBV conformance). The symmetric JSON-LD from-without-fromNamed restriction PR-U6 identified is a separate scoped follow-up (file a new issue if it needs tracking), not part of #1441.

Mechanics: the Closes keywords fire only when this PR merges into the repository's DEFAULT branch. In the stacked burn-down flow (Wave 3 → burndown/wave-2burndown/wave-1main), merging this PR into burndown/wave-2 does NOT close #1441 or #1447 — GitHub auto-closes them only once the stack collapses to main and this PR is retargeted onto it. Until then the issue references stay linked but open.

aaj3f added 30 commits July 7, 2026 15:24
SPARQL CONSTRUCT templates containing blank nodes ([ ], _:a, or an RDF
collection ( )) evaluated to an empty @graph: template blank nodes lower
to variables the WHERE clause never binds, so instantiate_row dropped
every template triple at the (Some,Some,Some) guard.

Record the template blank-node variables (those whose registry name keeps
the `_:` prefix the term lowerer assigns) on ConstructTemplate.bnode_vars
at lowering, and mint a fresh blank node per solution row in the CONSTRUCT
formatter -- shared across a row's template triples, distinct across rows.
constructlist rides the same path via PR-1's collection desugaring.

Greens W3C construct-3, construct-4 (data-r2) and constructlist
(data-sparql11); their skip-register entries are removed suite-driven.
Add SPARQL CONSTRUCT regression tests proving a blank-node template mints
a fresh blank per solution -- one node per match, linking subject /
predicate / object within a row -- for both the anonymous [ ]
(construct-3 shape) and labeled _:a (construct-4 shape) forms.
…nting

Adversarial review found the per-solution CONSTRUCT blank labels were minted
as b{n}, which overlaps other blank-node producers' label spaces (BNODE() emits
_:b{hex}; SERVICE passes through _:b0). Safe today only because every real
Fluree producer uses the reserved fdb- prefix and external SERVICE is rejected,
but a mixed template could otherwise silently merge a minted blank with a data
blank — and the isomorphism-based W3C CONSTRUCT suite cannot catch that. Mint
into a reserved cst{n} prefix so the output is provably disjoint.
Recognize TRIPLE/SUBJECT/PREDICATE/OBJECT/isTRIPLE as arity-validated
function-call builtins that lower to not_implemented (burn-down D-1).

They are contextual function-call identifiers, not reserved keywords: the
lexer emits a single generic TripleTermFn token (carrying the canonical
name) and the expression parser resolves it via FunctionName::parse and
validates arity (TRIPLE/3, the rest/1). Greens the 3 bucket-B entries of
SPARQL12_SYNTAX_TRIPLE_TERMS_POSITIVE (expr-tripleterm-03/04/05).
Accept bare `<<( s p o )>>` triple-term values in the subject, object,
VALUES, and BIND positions the RDF 1.2 grammar allows, lowering them to
not_implemented (burn-down D-1). A shared parse_triple_term_value enforces
the grammar guardrails so the (empty) negative register stays rejected:
no path/collection/reified-triple inside a term; a value-context term
(VALUES/BIND) may not have a blank-node or nested-triple-term subject,
while a pattern-context term may; a bare `<<( )>> .` is not a statement.
BIND `<<( )>>` reuses the TRIPLE(...) function path. Greens the 24
bucket-C entries of SPARQL12_SYNTAX_TRIPLE_TERMS_POSITIVE (register now
empty). Two pr-w2a unit tests that asserted the pre-D-1 parse rejection
are updated to the accept-then-defer behavior; the rdf:reifies reifier
path keeps rejecting nested triple-term subjects.
A single-child group graph pattern was always unwrapped to its bare child
(parse_group_graph_pattern), erasing the `{ }` scope boundary. When the child is
a FILTER, BIND, or nested Group that boundary is load-bearing: the lowerer only
wraps *Group* children as uncorrelated subqueries, so an unwrapped
`{ FILTER(?v) }` or `OPTIONAL { { ... FILTER(?title) } }` lands in the enclosing
scope and the FILTER wrongly sees an enclosing-scope variable.

Keep a single-child group wrapped when the child is scope-sensitive
(GraphPattern::is_scope_sensitive: Filter/Bind/Group); the existing lowerer path
then routes it through an uncorrelated subquery evaluated in an independent
scope. Relax the lowerer's nested-Group invariant assertion accordingly.

Greens W3C filter-nested-2 and dawg-optional-filter-005-not-simplified. Adds
JSON-LD parity regression tests (it_query_filter_scope.rs) for the shared
filter-scope semantics guarded only on the SPARQL surface by the W3C submodule.
A correlation variable a sub-SELECT binds only via OPTIONAL/UNION is not
self-produced, so subquery.rs excluded it from the hash join key and the merge
kept the parent's value while silently dropping the subquery's conflicting
binding — W3C var-scope-join-1 returned rows where the natural join on that
variable must eliminate them.

Partition correlation_vars into join_keys (self-produced, unchanged hash path)
and reconcile_vars (the rest). Reconcile vars are (a) excluded from the per-row
seed so the subquery binds them independently instead of pinning them to the
parent value, and (b) checked at the merge: a row whose reconcile var is bound on
both sides to different terms is dropped (SPARQL 18.4 compatible-mapping join).
self_produced_vars and the join_key hash path are untouched, so BSBM-shape
correlated subqueries (self-produced correlation) stay byte-identical.

Greens W3C join-scope-1. The seed exclusion also brings the JSON-LD surface's
correlated sub-SELECT into parity: the SPARQL nested group lowers to an
uncorrelated subquery (join-mode), where the merge reconcile suffices; JSON-LD
`["query", ...]` is correlated (per-row seeded), where the merge check alone is a
no-op without the seed change. Adds the JSON-LD parity test for the join-scope
shape.
…he update harness

The UpdateEvaluationTest guard rejected any mf:result that parsed to no
ut:data/ut:graphData/ut:result, on the assumption that a deliberate
empty-store expectation always carries ut:result ut:success. That is false:
the W3C graph-management tests (DROP ALL, the update-silent/* cases) express
"expected: empty store" as a bare mf:result [] . Distinguish a present-but-
empty result node (legitimate empty expectation) from an entirely absent
mf:result (a real manifest mis-parse) via a new Test.result_present flag, and
fire the guard only on the latter.
…P/CREATE/COPY/MOVE/ADD)

Add grammar/AST/parse + execution for the SPARQL 1.1 Update graph-management
operations, plus SILENT/INTO. The verbs were already lexed; this is AST +
recursive-descent parse (parse/query/{mod,update}.rs, ast/update.rs) lowering
to a new Txn graph-management directive (transact/ir.rs) executed by a
whole-graph scan primitive at staging time (transact/stage.rs::stage_graph_mgmt):

- CLEAR/DROP GRAPH <g> | DEFAULT scan the g_id and emit retractions; CLEAR/DROP
  ALL|NAMED iterate GraphRegistry::iter_entries. DROP is semantically CLEAR
  (D-6): the registry is additive-only and an emptied graph is
  harness-indistinguishable from a dropped one.
- COPY/MOVE/ADD scan the source graph and re-home its flakes into the
  destination by rewriting only the flake's g (datatype/lang/list-index ride
  along), composed over the CLEAR primitive (COPY/MOVE clear the destination
  first, MOVE clears the source afterward). Set-difference against the
  destination avoids assert+retract of the same fact.
- CREATE and SILENT LOAD lower to an empty no-op transaction (Fluree cannot
  represent an empty named graph, D-6). Non-SILENT remote LOAD is a documented
  divergence (D-5): the embedded transact path has no HTTP client. No W3C eval
  test requires a real fetch.

The dispatch is a single Option check at the top of stage(); the ordinary
insert/upsert/update path and pr-u2's multi-op staging stay byte-identical.

Query-surface parity (compliance case 2): expose the new capability on the
non-SPARQL transact surface via Txn::clear_graph/drop_graph/copy_graph and
GraphTransactBuilder::clear_graph/drop_graph/copy_graph, with a parity test in
it_named_graphs.rs.

Also correct pr-u2's cross-operation blank-node scope check: it over-collected
from Modify (INSERT/DELETE ... WHERE) templates, wrongly rejecting the approved
positive tests insert-where-same-bnode/-2 (template bnodes are per-operation,
per-solution; reuse across operations yields distinct nodes). The scope rule
applies to ground DATA forms only (INSERT DATA / DELETE DATA), keeping the
syntax-update-54 negative test rejected.

Register: remove the 68 SPARQL11_UPDATE + 23 double-registered
SPARQL11_SYNTAX_UPDATE_1 graph-management entries (91 lines) that green.
Empty-named-graph model (D-6 second half) resolved as documented divergence:
bindings#graph and agg-empty-group-count-graph stay registered with a
divergence comment (Fluree models a graph as existing iff a flake carries it).
`v.as_array().map(|a| a.len())` trips clippy::redundant_closure_for_method_calls,
which is deny at the workspace level, so `cargo clippy --all-targets` failed to
compile the grp_graphsource test target. `as_array()` yields `Option<&Vec<Value>>`,
so neither `<[_]>::len` (expects &[_]; E0631) nor `Vec::len` (no inherent len)
works as a method reference — use an explicit match instead.
A SPARQL FROM / FROM NAMED clause naming graphs within the queried ledger (the
ledger alias -> the default graph; registered named-graph IRIs -> named graphs)
now builds a within-ledger DataSet over the one snapshot and runs through the
shared dataset execution path, reusing DatasetOperator, policy and reasoning.
Cross-ledger clause IRIs are rejected (use the connection path). Greens the 12
dawg-dataset tests + constructwhere04; the harness pre-loads clause-referenced
files as named graphs.

The delegation created a mutual async recursion between query_with_options and
query_dataset_with_options (whose single-ledger fast path delegates back), which
made the fluree-db-api type-check pathologically slow (30+ min) and defeated Send
inference. Broken by giving query_dataset_with_options a concrete boxed return
(-> Pin<Box<dyn Future + Send>>), which resolves the opaque-recursive auto-trait
cycle. R2RML + within-ledger FROM is a documented unsupported combination (the
R2RML dataset path is non-Send and runs from Send-required spawn contexts) and is
rejected rather than silently mis-executed.

Actions decision D-3 (within-ledger datasets, Option A engine construction).
`DELETE {..} USING <g> WHERE { GRAPH <h> {..} }` over-deleted: the explicit
`GRAPH <h>` block reached graph h even though `USING <g>` (no `USING NAMED <h>`)
scopes the WHERE dataset to default=g with an empty named-graph set. Per SPARQL
1.1 §3.1.3 the GRAPH block must then match nothing ("the GRAPH clause does not
override the USING clause").

`stream_where_into_accumulator` treated an empty `USING NAMED` set as "no
restriction" and registered every named graph in the runtime dataset, so the
`GRAPH <h>` probe found h and generated retractions against the default-graph
DELETE template. Restrict the WHERE-visible named graphs to exactly the
`USING NAMED` set whenever any `USING`/`USING NAMED` clause is present; keep the
ambient all-named-graphs dataset only when no USING clause is present at all, so
a plain `DELETE WHERE { GRAPH <g> {..} }` stays byte-identical.

Greens W3C dawg-delete-using-02a/06a and adds a JSON-LD `fromNamed` graph-scoped
delete-where parity test.
…egates

Predicate-grouped COUNT fast paths (StatsCountByPredicateOperator,
PredicateObjectCountFirsts) tagged the count xsd:long, but SPARQL COUNT is
xsd:integer (§18.5.1.6) — matching the materialized and streaming finalize
paths. Greens agg02.

SUM/AVG silently skipped bound non-numeric group members and averaged over
the numeric subset; per §18.5 a non-numeric member is a type error that
unbinds the whole aggregate. Poison both the materialized (aggregate.rs) and
streaming (group_aggregate.rs) paths; the streaming numeric hot path is kept
first so it stays byte-identical. Greens agg-err-01.
…lang match

CONCAT silently skipped a non-string (or unbound) argument, coercing e.g. an
xsd:integer away; per §17.4.3.5 a non-string argument is a type error, so the
whole result is unbound (concat02).

Language tags are case-insensitive (RDF 1.1 §3.3), and a language-tagged
literal's datatype is rdf:langString even though the SPARQL results format
writes only xml:lang. Normalize both in the harness result comparison so a
produced en-US / no-datatype literal matches an expected en-us / rdf:langString
fixture (strlang03-rdf11, dawg-langMatches-1/2/3/basic).
Numeric promotion collapsed xsd:float into xsd:double and produced xsd:decimal
for double+decimal. Add a first-class ComparableValue::Float(f32) so the XPath
lattice integer<decimal<float<double holds: float o {integer,decimal,float} =
float, float o double = double, double o decimal = double (op:numeric-*).

xsd:float is stored as an f64 tagged only by its datatype (coerce.rs), so
eval_to_comparable recovers the float type with a single datatype check on the
xsd:double literal path; the Long/String fast paths stay byte-identical.

Greens type-promotion-03/04/05/21/29/30 and expr-ops
{add,subtract,multiply,divide}-numbers-cast, unplus-2, unminus-2.
FILTER(?v) routed a bare variable through a bool-returning EBV where every
literal except xsd:boolean false was truthy and unbound was false. Per §17.2.2
the EBV of numeric-zero / empty-string is false, and a language-tagged or
foreign-datatype literal, an IRI, an ill-typed literal, or an unbound variable
is a type error (a demotable Comparison error, so a FILTER excludes the row and
a BIND leaves the variable unbound). Route the bare-variable path through a
fallible binding_effective_bool; the lenient From<&Binding>/From<ComparableValue>
EBVs stay for the Cypher structural surfaces (lists/maps/paths/rels).

Greens dawg-bev-1..6. not-not stays registered: its "z"^^xsd:boolean VALUES
row is stored coerced to Boolean(false), losing its ill-typedness before EBV —
blocked on ill-typed-literal preservation (D6 / PR-X3).
`=`/`!=` dropped datatypes: a foreign-datatype literal ("zzz"^^:t) compared
equal to a plain "zzz", and incomparable operands returned false/true rather
than a type error. Carry a foreign string datatype into the ComparableValue at
the eval boundary (the Long / xsd:double / xsd:string / lang fast paths stay
byte-identical; language tags are deliberately NOT carried, to keep the string
builtins working) and route `=`/`!=` through a three-valued RDFterm-equal
(§17.4.1.7): value equality with numeric promotion and plain = xsd:string; a
resource is never equal to a literal; two differently-typed recognized values
are known-unequal; an unrecognized / ill-typed datatype that might still denote
the same value is a type error excluding the row for both `=` and `!=`.

Also correct AND/OR to SPARQL three-valued logic (§17.2): false / true dominate
over an operand error instead of aborting on the first one (open-cmp-02).

Greens eq-2-1, eq-2-2, open-eq-04. The rest of the equality cluster stays
registered on separate blockers (typed-literal constant datatypes, blank-node
output identity, temporal timezone semantics, scan-path constraints).
… deferral notes

JSON-LD-surface regressions alongside the SPARQL ones (the W3C submodule guards
only the SPARQL surface): numeric-promotion result datatype (D4), datatype-aware
EBV (D-EBV), RDFterm-equal value equality vs known-unequal (D5), CONCAT
non-string type error (D11), AVG non-numeric poison (agg-err-01), plus
wave-2-failing discriminating regressions a revert would fail: foreign /
ill-typed datatype vs plain string is a type error under both = and != , and
three-valued OR (true dominates an operand error).

The predicate-grouped COUNT xsd:integer tests (agg02) are confirmatory-only, not
discriminating: a memory ledger routes through the general group_aggregate
finalize (already xsd:integer on wave-2) and bypasses the indexed
StatsCountByPredicateOperator fast path where the xsd:long bug lived; the fast
path is detected only for the bare `GROUP BY ?p COUNT(?o)` shape (no
post-aggregation bind), and xsd:long/xsd:integer both render as bare JSON
numbers, so the datatype can't be observed without dropping off the fast path.
The revert-catching coverage is the greened W3C agg02 test against indexed
storage (reviewer finding #4b).

Realigns two pre-existing float-division tests (grp_query / grp_query_sparql) to
the typed-literal rendering now that D4 keeps the result xsd:float instead of
widening it to xsd:double.

Also updates register comments to name PR-X2 as decision-owner for the deferred
cluster with the real root cause (scan-path BGP object-match for
eq-graph/open-eq-02/quotes; temporal for date-1/eq-dateTime; blank-node output
identity for open-eq-07..12; typed-constant lowering for open-eq-05/06). fmt
normalization of the eval files.
# Conflicts:
#	testsuite-sparql/tests/registers/mod.rs
# Conflicts:
#	fluree-db-transact/src/lower_sparql_update.rs
# Conflicts:
#	testsuite-sparql/tests/registers/mod.rs
…agement ops

stage_graph_mgmt's whole-graph scan/re-home for SPARQL 1.1 Update / builder
graph management (CLEAR/DROP/COPY/MOVE/ADD) had two gaps.

B2 — reserved graphs. The single-graph CLEAR/DROP path and the COPY/MOVE/ADD
source and destination resolved a graph by IRI with no reserved-graph guard,
while the Named/All scope already excluded g_id < FIRST_USER_GRAPH_ID. So
DROP GRAPH <urn:fluree:{ledger}#config> retracted SHACL/uniqueness governance,
CLEAR GRAPH <...#txn-meta> shredded commit metadata, and COPY/MOVE/ADD into them
injected flakes. Reject g_id < FIRST_USER_GRAPH_ID at all three sites via a new
TransactError::ReservedGraphTarget, mirroring the cross-ledger resolver's
reserved-graph guard.

O5 — edge annotations. Re-homing rewrites only each flake's g, but an edge
annotation encodes its edge's graph in the f:reifiesGraph object and anchors the
bundle by flake-level g; a COPY/MOVE/ADD desynced the two, so
EdgeKey::from_reifies_facts returned GraphMismatch and both readers (JSON-LD
hydration, attachment indexer) silently dropped the annotation. Fail loud with
TransactError::UnsupportedFeature when the source contains any f:reifies* flake;
full reification-aware re-homing is a follow-up.

Also document three existing behaviors inline: O3 (a missing/typo'd COPY/MOVE
source empties the destination under roadmap D-6), N3 (CLEAR ALL/DEFAULT retracts
default-graph schema — spec-correct), and O4 (graph management deletes the
modifiable set, not viewable-intersect-modifiable, unlike conditional
DELETE-WHERE — by design, CLEAR is unconditional per SPARQL 3.2).

Tests (it_named_graphs.rs): reserved-graph rejection for CLEAR/DROP/COPY/MOVE/ADD
against #config/#txn-meta and annotation-bearing transfer rejection (both fail on
wave-3, where the op silently succeeds, and pass here), plus a
graph-management-under-modify-policy regression-lock pinning the
policy-not-bypassed invariant (enforce_modify_policies still runs on the
whole-graph path).
The numeric builtins matched only Long/Double/Decimal/BigInt, so a stored xsd:float (routed to ComparableValue::Float by lit_to_comparable) fell to the Some(_) => Ok(None) catch-all and returned unbound; isNumeric likewise reported false. Add a type-preserving Float arm to eval_abs/round/ceil/floor (fn:abs/ceiling/floor are type-preserving; ROUND uses W3C half toward positive infinity, mirroring the Double arm) and Float to eval_is_numeric. The arms sit before the catch-all, so the Long/Double/Decimal/BigInt common path is byte-identical.
eval_in/eval_not_in compared with the derived variant-exact PartialEq, so 1 IN (1.0) was false while 1 = 1.0 was true. Route the membership test through rdf_term_equal (now pub(crate)) so IN uses the same value equality as =. The error-in-IN three-valued type-error propagation (SS 17.4.1.9) is deferred to a follow-up. The no-IN common path is unchanged.
eval_concat took its string via ComparableValue::as_str(), which also exposes IRIs and foreign-datatype literals, so CONCAT of an IRI or a foreign-typed literal silently concatenated instead of raising a type error (SS 17.4.3.5). Reject those locally in eval_concat, accepting only plain, xsd:string, and language-tagged strings. as_str() is left unchanged, since STR and the other string builtins rely on it accepting IRIs.
COPY/MOVE clear the destination before copying the source in, so a typo'd
or never-registered source graph — which scans as empty — silently empties
the destination: `COPY <typo> TO <important>` destroys `<important>`. SPARQL
1.1 Update §3.2 requires ADD/COPY/MOVE to raise an error when the source
graph does not exist, unless SILENT.

The additive-only graph registry (roadmap D-6) makes this fixable: a
never-registered source resolves to `None`, while an emptied-but-registered
graph resolves to `Some(g_id)` with zero flakes. The two are distinguishable,
so staging errors on a `None` source (unless SILENT) without breaking the
legitimate empty-source case.

- ir.rs: add `silent` to `GraphMgmtOp::Transfer`; the `copy_graph` builder
  sets it false (errors on a missing source, like non-SILENT SPARQL)
- lower_sparql_update.rs: thread the parsed SILENT flag into the Transfer IR
- stage.rs: at source resolution, a never-registered `GraphSel::Graph` source
  without SILENT returns `SourceGraphNotFound` before the clear-dest logic;
  refresh the stale O3 comment
- error.rs: add `SourceGraphNotFound { graph_iri }`
- it_named_graphs.rs: regression test — non-SILENT COPY/MOVE/ADD from a missing
  source error and preserve the destination; SILENT suppresses; a
  registered-but-emptied source still proceeds

@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.

Inline notes from a review pass. Two look like must-fix (system-graph exposure via FROM, and a reachable transactor panic on triple-term syntax); the rest are correctness gaps plus a few worth-a-glance items.

Comment thread fluree-db-api/src/view/query.rs Outdated

// Registered named graph in this ledger (registry, with the same
// binary-store fallback `select_graph` uses).
let known = db.snapshot.graph_registry.graph_id_for_iri(iri).is_some()

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.

known is also true for the reserved system graphs — the registry seeds #txn-meta (g_id 1) and #config (g_id 2). So FROM <urn:fluree:{ledger}#config> resolves and exposes the config graph, which the plain GRAPH <iri> path blocks via single_db_user_graph_id (>= FIRST_USER_GRAPH_ID). Suggest gating known on g_id >= FIRST_USER_GRAPH_ID.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed — the registry legs of resolve_within_ledger_graph lacked the >= FIRST_USER_GRAPH_ID filter the GRAPH <iri> path applies via single_db_user_graph_id. Fixed in 8d8870ba1: both legs (registry + binary-store fallback) now gate on user graphs, so FROM <…#config> / FROM NAMED <…#txn-meta> fall through to the standard "not in this ledger" rejection; test covers all four clause/graph combinations plus the user-graph positives.

SubjectTerm::QuotedTriple(_) => {
unreachable!("RDF-star quoted triples are rejected before annotation expansion")
}
SubjectTerm::TripleTerm(_) => {

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 is reachable. expand_annotated_triples guards QuotedTriple but not TripleTerm, so an annotated triple-term subject parses and panics here instead of returning the accept-then-defer error, e.g. INSERT DATA { <<( :a :b :c )>> :p :o {| :m :n |} }. Add a matching TripleTerm guard next to the QuotedTriple one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and empirically reproduced — this landed as 881a8e20c (pushed shortly after your review was cut): expand_annotated_triples now guards SubjectTerm::TripleTerm beside the QuotedTriple guard, returning the same accept-then-defer UnsupportedFeature as every other deferred triple-term shape, with negative tests for the INSERT DATA and DELETE-template paths (plus the QuotedTriple twin).

// registered named graph) applies — the case a plain
// `DELETE WHERE { GRAPH <g> { .. } }` (no USING) relies on. That
// no-USING path stays byte-identical to before.
if w.using_default_graph_iris.is_empty() && w.using_named_graph_iris.is_empty() {

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 fixes the named-graph half; the default-graph half of §3.1.3 still looks off. With USING NAMED <g2> and no plain USING, desired_where_default_graph_iris is empty and base_db falls back to g_id 0 (the real default) rather than the empty graph, so DELETE {..} USING NAMED <g2> WHERE {..} can still read/delete the default graph. Worth verifying.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed — same conclusion independently: USING NAMED <g2> with no plain USING fell back to g_id 0, so a default-scoped WHERE read (and deleted from) the real default graph. Fixed as 5b20e07ae (also just before your review posted): any USING NAMED with no plain USING gives the WHERE dataset an EMPTY default-graph member list (§13.2.1 via §3.1.3; WITH is likewise ignored for the WHERE when any USING form is present). The test proves fail-without-fix — the pre-fix code deletes the whole default graph.

FlakeValue::BigInt(n) => Ok(!n.is_zero()),
FlakeValue::Decimal(d) => Ok(!d.is_zero()),
FlakeValue::String(s) if is_xsd_string(dtc) => Ok(!s.is_empty()),
_ => Err(ebv_type_error()),

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 cast/computed xsd:float is a FlakeValue::String tagged xsd:float, which isn't is_xsd_string, so it falls to ebv_type_error() → false in FILTER. lit_to_comparable coerces such floats for comparison but EBV doesn't, so FILTER(?f) drops a truthy float. Coerce numeric-XSD string datatypes before the error fallthrough.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed, and it went one level deeper: besides lit_effective_bool (the BIND'd-binding path), the function-call path used the lenient Into<bool> conversion, so FILTER(xsd:float("0")) was TRUE. Fixed in f52d43847: string-backed numeric/boolean-tagged literals parse their lexical form on both paths — well-formed values follow the numeric/boolean rule, ill-formed forms are EBV FALSE per §17.2.2 rule 1 (not a type error). The dispatcher routes only TypedLiteral results through the strict path; everything else keeps lenient truthiness, which the shared Cypher surface's structural semantics rely on (D-12 carve-out — a Cypher CALL test locks this). Fixing the tests exposed a second bug: STRDT's datatype arg arrives as a Sid, which as_str() doesn't expose, so STRDT(?x, xsd:integer) silently produced a PLAIN literal — same commit decodes Sid datatype args.

UpdateOperation::Clear(gm) | UpdateOperation::Drop(gm) => {
lower_clear_drop(gm, prologue, opts)?
}
UpdateOperation::Create(_) => {

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.

CREATE no-ops without registering the graph, so a subsequent non-SILENT COPY <g> TO <h> / ADD / MOVE hits the new O3 "source does not exist" error even though CREATE reported success — the two behaviors contradict. (Also: the CLEAR/DROP silent flag is parsed but never read.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed — fixed in bf4b626a0: CREATE now registers the graph IRI in the additive registry (the same graph_delta mechanism as a transfer destination), so CREATE GRAPH <g> followed by non-SILENT COPY <g> TO <h> is a legitimate empty source. This needed two plumbing exceptions for registration-only (zero-flake) commits — the tx-builder no-op shortcut and build_commit's EmptyTransaction gate — both scoped to deltas containing an unregistered IRI, so ordinary no-op updates still elide. CREATE-of-existing stays a no-op rather than the §3.2.2 error (documented D-6 divergence), and the CLEAR/DROP silent flag now carries a doc note explaining why it is never consulted (a CLEAR/DROP of an unregistered graph is a D-6 no-op, so there is no error to suppress).

// retract the WHOLE default graph, including schema flakes
// (rdfs:Class, rdfs:subClassOf, …). That is spec-correct — CLEAR
// is an unconditional whole-graph retraction — but a one-line
// `CLEAR ALL` strips the ontology, and because `is_schema_flake`

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.

Worth a glance / a pinning test: this N3 bypass lets a narrow-grant identity CLEAR ALL (or MOVE DEFAULT TO <g>) and wipe/re-home the ontology unblockably. test_graph_mgmt_honors_modify_policy uses a non-schema flake, so it doesn't cover this case. Might be the intended contract, but a test pinning it would help.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed it's the intended contract, and agreed it deserved a pin — added in 2205252c5: a view-only default-deny identity CAN CLEAR DEFAULT a schema-only graph (the is_schema_flake exemption), sitting beside the companion test proving the same identity is REJECTED for non-schema flakes. Narrowing the exemption later now flips a test instead of moving silently.

Comment thread fluree-db-api/src/view/query.rs Outdated
let graph = self
.resolve_within_ledger_graph(db, iri)?
.ok_or_else(cross_ledger_dataset_error)?;
dataset = dataset.with_default(graph);

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.

Two minor things: repeated FROM <g> pushes the same graph twice → bag union, so solutions duplicate (spec wants RDF merge/dedup). And an unresolved within-ledger IRI (e.g. a typo) returns cross_ledger_dataset_error, telling the user to use the cross-ledger path — misleading for a local typo.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Both fixed in dd86e74b3: the within-ledger dataset build dedups default-graph members by resolved graph id (FROM <g> FROM <g> contributes one member — §13.2 set semantics; regression test compares row-for-row against single-FROM), and the rejection message now says to check the IRI against the ledger's registered graphs before reaching for the cross-ledger API. (The stacked #1483 additionally set-dedups triples across DISTINCT default members at execution time.)

ledger: &LedgerState,
g_id: GraphId,
tracker: Option<&Tracker>,
) -> Result<Vec<Flake>> {

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.

Not a correctness issue, just noting: CLEAR ALL / large COPY/MOVE materialize every flake into a Vec and only check at_max_novelty at entry, so one op can roughly double novelty in a single commit — a scale cliff for exactly the ops most likely to touch the whole store.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed — documented on scan_graph_flakes in 2205252c5: whole-graph ops materialize every flake and only check backpressure at commit entry, so one CLEAR ALL/large COPY can roughly double novelty in one commit; chunked staging for whole-graph ops is the named follow-up if the cliff is hit in practice. Left as a doc note rather than code in this PR.

Comment thread fluree-db-sparql/src/lower/term.rs Outdated
// here mapped to the same var, merging two intended-distinct
// template blanks; matches the `#bnpl…`/`#coll…` labeling the
// parser already uses for property lists / collections.)
let label = format!("_:#anon{}", self.anon_counter);

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.

Worth a quick glance: the unforgeable #anon counter added here isn't used by lower_reifier_id in lower/annotation.rs, which still mints _:b{vars.len()} — so a user-written _:bN could collide with an anonymous reifier blank. Looks like this path was missed by the hardening pass.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed for the path you named — and it's resolved by the origin/main merge now on this branch: main's 5c7cde84b (wave-2 review response) had independently hardened query-side anon labels — including lower_reifier_id — with the _:[]{n} scheme, which the merge reconciliation adopted branch-wide (the wave-3 #anon counter was dropped in its favor). Sweeping for stragglers found one more genuinely-forgeable instance neither pass caught: the transact-side BlankNodeCounter minted INSERT-template anon blanks as _:b{n}, so INSERT { [] ex:q ?h . _:b0 ex:p ?h } fused both into one node — fixed with the same _:[] scheme + regression test in dd86e74b3.

/// part of the W3C dataset, so they must never be a graph-management target.
/// Mirrors the cross-ledger resolver's reserved-graph guard.
#[error("graph-management operation targets reserved system graph <{graph_iri}>; refusing")]
ReservedGraphTarget {

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.

Worth confirming ReservedGraphTarget / SourceGraphNotFound map to a 4xx client error upstream, not a 500.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Verified, no change needed: fluree-db-server/src/error.rs maps ApiError::Transact(_) to 400 via the catch-all (status_code()), with 409 reserved for the retryable conflict variants (CommitConflict/PublishLostRace/NamespaceConflict) — so ReservedGraphTarget and SourceGraphNotFound both surface as 400 Bad Request with the stable INVALID_TRANSACTION error type.

aaj3f added 15 commits July 13, 2026 18:10
…anicking

The annotation-tail expansion pre-pass (expand_annotated_triples) runs
before the quad-pattern lowering whose TripleTerm arms defer cleanly, and
it guarded a QuotedTriple subject but not an RDF 1.2 triple-term subject —
so parse-accepted input like

    INSERT DATA { <<( :s :p :o )>> :q :o2 {| :a :b |} }

reached subject_to_object's unreachable!() and panicked the transactor
(reachable from INSERT DATA, DELETE DATA, and Modify templates). Mirror
the QuotedTriple guard for SubjectTerm::TripleTerm, returning the same
kind of UnsupportedFeature the other deferred triple-term shapes produce
(accept-then-defer, D-1).

Adds parse-then-lower negative tests for the INSERT DATA and DELETE
template shapes, plus the pre-existing QuotedTriple twin, so a future
reordering of the pre-pass cannot silently reintroduce the panic.
XPath op:numeric-equal (F&O §4.2.3) defines NaN as unequal to every value
including NaN, so `"NaN"^^xsd:double = "NaN"^^xsd:double` must be false
and `!=` true. rdf_term_equal's numeric fast path bottoms out in
numeric_cmp's bit-level total order — kept deliberately for ORDER BY
stability — which reports two identical-bit NaNs as Equal, so `=` said
true. Catch NaN (Double or Float) first and return Ne, scoped to
numeric-vs-numeric operands so NaN vs an unrecognized-datatype literal
still reaches the TypeError arm.

The ORDER BY total order is unchanged (identical-bit NaNs still sort as
equal), and NaN inside IN/NOT IN follows `=` for free. Pre-existing
behavior surfaced by the wave-3 X2 equality review; no register test
pins it, so the W3C suite is unaffected.

Unit tests pin the equality outcomes and the equality-only scope;
integration tests cover the constant and stored forms on both the SPARQL
and JSON-LD surfaces (shared-IR rule).
…lt graph

SPARQL 1.1 §13.2.1 (via Update §3.1.3): a dataset described only by
FROM NAMED / USING NAMED clauses "includes an empty graph for the default
graph", and a WITH clause is ignored for the WHERE clause whenever any
USING/USING NAMED is present. The runtime-dataset construction only
handled the named-graph side (PR-U6's #1441 fix); default-graph selection
fell through to the ledger's real default graph (g_id 0), so

    DELETE { ?s ?p ?o } USING NAMED <h> WHERE { ?s ?p ?o }

matched — and deleted — the entire default graph. The same over-reach
class as #1441, one clause over; not covered by the W3C delete-using
tests (all six use a plain USING).

When any USING NAMED is present with no plain USING, build the runtime
dataset with an empty default-graph list: default-scope scans then
iterate zero members (ActiveGraphs::Many([])) and bind nothing, while the
USING NAMED set stays visible to GRAPH blocks. The no-USING ambient path
and the plain-USING/multi-USING paths are byte-identical.

The new integration test fails without the fix (the default graph is
emptied) and pins the named-set-still-visible and ambient-control cases.
The symmetric JSON-LD fromNamed-only restriction remains the documented
scoped follow-up from PR-U6.
…g tags; ROUND/CEIL/FLOOR pass BigInt through

Three evaluator edges from the wave-3 review, each with SPARQL + JSON-LD
tests (shared-IR rule):

- eval_negate never called coerce_numeric_operand, unlike binary
  arithmetic and comparison, so a cast result (a string-backed
  `TypedLiteral{Explicit(xsd:float)}`) hit the non-numeric arm and
  `-xsd:float("1.5")` came back unbound while `xsd:float("1.5") - 0`
  worked. Coerce the operand first.

- eval_concat read the result language tag only off a Var BINDING
  (extract_lang_tag), so constant and computed args — `"a"@en`,
  STRLANG(...) — lost their tag: `CONCAT("a"@en, "b"@en)` returned plain
  "ab" where §17.4.3.5 requires "ab"@en. Prefer the tag carried on the
  value itself, fall back to the binding, and compare tags
  case-insensitively per RDF 1.1 (first spelling wins).

- eval_round/eval_ceil/eval_floor had no BigInt arm (ABS does), so an
  xsd:integer beyond i64 fell into the catch-all and returned unbound;
  integers are already integral, so pass them through.
…n same-graph transfers

The transact-builder surface exposed clear/drop/copy_graph but not the
other two SPARQL transfer verbs, though the GraphMgmtOp::Transfer IR
already supports every mode — add Txn::move_graph/add_graph and the
GraphTransactBuilder twins, with a builder↔SPARQL parity test.

Two review follow-ups on the Transfer arm:
- The `from == to` spec no-op short-circuited BEFORE the reserved-graph
  guard, so `COPY <…#config> TO <…#config>` was accepted as a no-op
  rather than refused; check reserved-ness first (and document that
  SILENT deliberately does not suppress the reserved-graph guards).
- New tests pin the same-graph no-op for all three verbs on both
  surfaces — the guard is the only thing between a refactor and MOVE's
  clear_dest destroying the graph — plus the reserved same-graph
  rejection.

Also document two model facts the review surfaced: a SILENT transfer
from a missing source still CLEARS the destination (the spec's own
DROP-SILENT shortcut equivalence, not a no-op), and O3 source-existence
deliberately uses registry semantics (survives CLEAR) as distinct from
the query surface's flake-carried D-6 model — only the registry can tell
a typo from a cleared graph.
…le-term arity domain

Two review nits: instantiate_row now short-circuits the per-term
bnode_vars probes when the CONSTRUCT template has no blank nodes (the
common case; the row map itself never allocated), and row_blank's doc
states the cst counter's per-execution scope (merging two CONSTRUCT
outputs needs standardizing apart — standard RDF blank scoping).
triple_term_fn_arity's silent `_ => 1` catch-all becomes a debug_assert
so a future caller routing a non-triple-term builtin through it fails
loudly instead of mis-validating arity.
…twin; record CONSTRUCT bnode parity exemption

Review coverage gaps: commit 0752012 changed AND and OR but only OR was
pinned — add both dominance directions (F && err = F survives a negated
conjunction; err && T = err drops the row) on both surfaces. Commit
45d6009 says "aggregates" plural but only AVG-poison was tested — add
the SUM pair. And the memory-backed poison twins exercise only the
general/streaming aggregate, so add a reindexed-ledger twin (seed_indexed
hard-asserts range_provider.is_some(), defeating the memory-fallback
fixture trap) pinning SUM/AVG poison where production ledgers run.

Also record the CONSTRUCT blank-node-template parity exemption in
sparql-compliance.md: the FQL construct template is a JSON-LD node map
with no syntax for per-solution-row fresh blanks (auto-subjects are
variables; an explicit _: @id is one fixed node), so the cst{n} minting
is SPARQL-surface-only by design, not a parity gap.
…d correlation, per-row CONSTRUCT minting

Four review "verified by inspection, untested" scenarios, now pinned:

- A multi-operation request mixing a data op with a graph-management op
  (`INSERT DATA { GRAPH <g> … } ; COPY <g> TO <h>` and the
  insert-then-CLEAR shape): each op must observe its predecessor's
  effects — including a g_id registered earlier in the SAME request —
  through the sequential-staging novelty view.
- CLEAR of an annotation-bearing graph retracts the whole f:reifies*
  bundle cleanly: graph reads empty, and re-inserting the identical
  annotated edge succeeds (a leftover anchor would corrupt the bundle).
- Family B reconciliation composes through TWO nesting levels of
  sub-SELECTs, and a correlation var used as a correlated sub-SELECT's
  GROUP BY key joins per-group (john's count of 2, not a pinned or
  leaked grouping).
- §16.2 CONSTRUCT: two solution rows with IDENTICAL bindings still mint
  two distinct template blanks — per-row, not per-distinct-binding
  (Jena/oxigraph-consistent).
# Conflicts:
#	fluree-db-sparql/src/lower/term.rs
#	fluree-db-sparql/src/parse/query/update.rs
#	fluree-db-transact/src/lower_sparql_update.rs
#	testsuite-sparql/tests/registers/mod.rs
The graph registry seeds the reserved #txn-meta (g_id 1) and #config
(g_id 2) system graphs, so the within-ledger dataset path's "is this a
registered graph" check resolved them and FROM <urn:fluree:{ledger}#config>
exposed the governance/config graph to any query — while the plain
GRAPH <iri> path blocks them via single_db_user_graph_id's
>= FIRST_USER_GRAPH_ID filter. Gate both resolution legs (registry and
binary-store fallback) on the same filter; a reserved IRI now falls
through to the standard "not in this ledger" rejection.

Review finding (bplatz) — the query-side twin of the wave-3 transact-side
reserved-graph guards. Test covers FROM and FROM NAMED over both reserved
graphs plus the existing user-graph positive cases.
…Sid datatype args

Review finding (bplatz): a cast/computed numeric literal — xsd:float("1.5")
is a String-backed TypedLiteral tagged xsd:float — had no EBV, so
FILTER(?f) dropped a truthy float on the Binding::Lit path, and the
function-call path's lenient bool conversion read EVERY typed literal as
truthy (FILTER(xsd:float("0")) passed). Per §17.2.2: parse the lexical
form of a numeric/boolean-tagged string — well-formed values follow the
numeric/boolean rule, and an ILL-FORMED lexical form is EBV FALSE
(rule 1), not a type error. Applied to lit_effective_bool (BIND'd casts)
and comparable_effective_bool (direct expression results); the function
dispatcher routes only TypedLiteral results through the strict path —
everything else keeps the lenient conversion, which the shared Cypher
surface's structural truthiness deliberately relies on (D-12 carve-out).

Fixing the tests exposed a second gap: STRDT's datatype argument arrives
as a Sid (constant IRIs in expression position lower through the IRI
encoder), which as_str() deliberately does not expose — so
STRDT(?x, xsd:integer) silently produced a PLAIN literal
(DATATYPE(STRDT(…)) came back unbound-ish and EBV saw a bare string).
Decode Sid datatype args back to the full IRI.

Tests cover the direct and BIND'd EBV paths, well-formed and ill-formed
lexicals (discriminated from demoted errors via negation), booleans, and
the JSON-LD twin.
…query operator

Merge fallout (wave-3 x main): openCypher's `CALL (p) { … }` defines the
import as a PER-ROW binding, but the shared SubqueryOperator's Family-B
rule (W1, SPARQL §18.4) classifies a correlation var the body binds only
via OPTIONAL as a reconcile var — evaluated unseeded and reconciled at
merge. `CALL (p) { OPTIONAL MATCH (p)-[:KNOWS]->(f) RETURN count(f) }`
therefore dropped a zero-match parent instead of retaining it with 0
(main's newly-wired it_query_cypher::cypher_call_subquery_correlated_
aggregate caught it; the failure reproduces at the merge commit with no
wave-3-review changes applied).

SubqueryPattern gains pinned_vars — explicit lateral imports, set only by
the Cypher CALL lowering. A pinned var is always seeded in per-row mode
(never reconciled), and a pinned var the body does not itself produce
forces per-row evaluation (evaluate-once + hash-join cannot retain
zero-match parents). SPARQL/JSON-LD subqueries leave it empty, so W1's
Family-B semantics are untouched (join-scope-1 tests unchanged); the
plain-MATCH join-mode promotion keeps its evaluate-once path (imports all
self-produced).
Review finding (bplatz): CREATE lowered to a pure no-op, so
`CREATE GRAPH <g>` reported success yet a subsequent non-SILENT
`COPY <g> TO <h>` raised O3's "source graph does not exist" — the two
behaviors contradicted. CREATE now records the IRI in graph_delta (the
same mechanism as a transfer destination): the graph is registered,
empty, and non-enumerable (D-6), and O3's source-existence check — which
consults exactly that registration — treats it as a legitimate empty
source. CREATE of an already-registered graph stays a no-op rather than
the spec's §3.2.2 error (documented D-6 divergence; `silent` has nothing
to suppress), and lower_clear_drop documents the symmetric reason the
CLEAR/DROP silent flag is never consulted.

Persisting a registration with zero staged flakes needs two plumbing
exceptions: the tx-builder's no-op-update shortcut and build_commit's
EmptyTransaction gate both now let a commit through when its graph_delta
contains an IRI the registry doesn't know yet (already-registered deltas
keep the old no-op elision, so ordinary no-op updates still skip
committing). Tests cover the cross-request CREATE→COPY flow, the
never-registered control, and the same-request multi-op CREATE;ADD.
… typo-aware dataset rejection

Three review follow-ups (bplatz):

- The transact-side BlankNodeCounter minted INSERT-template anonymous
  blanks as `_:b{n}` — inside the user label namespace, so a template
  `[] ex:q ?h . _:b0 ex:p ?h` fused both into ONE node per solution.
  Mint `_:[]{n}` instead (`[` is outside PN_CHARS), matching the
  query-side scheme main standardized; the annotation-reifier arm's
  `__fluree_ann_` reservation is unaffected. (The query-side
  lower_reifier_id instance of this class was already fixed by main's
  5c7cde8, which this branch now carries via the merge.)

- A repeated `FROM <g>` pushed the same graph into the default union
  twice — a bag that doubled every row where §13.2 defines a SET. The
  within-ledger dataset build now dedups members by resolved graph id.

- The unresolved-FROM-IRI rejection read as "use the cross-ledger path",
  which is misleading for a local typo; the message now says to check
  the IRI against the ledger's registered graphs first.
…e whole-graph novelty cliff

Review follow-ups (bplatz): a view-only, default-deny identity CAN
CLEAR DEFAULT a schema-only graph — is_schema_flake exempts schema
flakes from modify policy, so the ontology wipe is deliberately not
policy-blockable (N3). The new test pins that boundary next to its
companion (same identity REJECTED for non-schema flakes), so narrowing
the always-allow exemption later flips a test rather than moving
silently. scan_graph_flakes now documents the scale cliff: whole-graph
ops materialize every flake and only check backpressure at entry, so one
CLEAR ALL / large COPY can roughly double novelty in a single commit —
chunked staging is the known follow-up.

(Reviewed, no change needed: ReservedGraphTarget / SourceGraphNotFound
map to 400 via the server's ApiError::Transact(_) catch-all — 409 is
reserved for the retryable conflict variants.)
@aaj3f

aaj3f commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

All eleven review comments are addressed on the branch — per-comment replies are threaded inline. Three were fixed by the second-round commits that landed just before the review posted (the triple-term panic, the USING NAMED empty-default gap, NaN equality); the remaining eight resolve in 8d8870ba1..2205252c5, on top of an origin/main merge (d072a95d6) that makes the PR conflict-free against main (the PR body's new "Third-round" section has the full breakdown).

Two things from this response wave worth calling out beyond the inline threads:

  • The origin/main merge surfaced one real cross-branch regression: main's newly-wired Cypher suite caught the wave-3 Family-B subquery rule (W1) breaking CALL (p) { OPTIONAL MATCH … RETURN count(…) } zero-match retention — openCypher imports are per-row bindings, not inferred correlations. Fixed in 4a2243faa with an explicit pinned_vars lateral-import marker on SubqueryPattern, set only by the Cypher CALL lowering, so SPARQL/JSON-LD Family-B semantics (join-scope-1) are untouched.
  • The merge also reconciled a registers collision: this branch and main's wave-2 review response had each deduplicated the eval-triple-terms register in opposite directions, so six double-registered entries lost both copies in the auto-merge — the suite's both-direction enforcement caught it immediately, and each is restored once under its correct blocker cluster (register content is otherwise unchanged: same unique set, 0-failed / 0-stale).

Verified on the tip: full W3C suite 36 passed / 0 failed / 0 stale; 5,173 scoped integration/unit tests passing (query/transact/api/sparql/cypher); cargo fmt clean on both workspaces; scoped clippy clean. The stacked #1483 has been rebased onto this head (its lint-chore cherry-pick deduplicated away against main's original, as its PR body predicted).

@aaj3f
aaj3f merged commit f084a1e into main Jul 14, 2026
13 checks passed
@aaj3f
aaj3f deleted the burndown/wave-3 branch July 14, 2026 02:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants