diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bf358afd6..bedd6df743 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,6 +107,12 @@ jobs: working-directory: testsuite-sparql run: cargo clippy --all-targets -- -D warnings - - name: Check compiles (test targets) + # Full W3C compliance run. Every suite must be green: each test either + # passes or is listed in tests/registers/mod.rs. check_testsuite fails a + # suite on any unexpected failure AND on any stale register entry (a + # registered test that now passes), so the registers can only shrink. + # See docs/contributing/sparql-compliance.md. + - name: Run W3C SPARQL test suites working-directory: testsuite-sparql - run: cargo test --no-run + timeout-minutes: 45 + run: cargo test diff --git a/docs/audit/2026-07-sparql-testsuite-audit.md b/docs/audit/2026-07-sparql-testsuite-audit.md new file mode 100644 index 0000000000..6d1410a2dc --- /dev/null +++ b/docs/audit/2026-07-sparql-testsuite-audit.md @@ -0,0 +1,387 @@ +# W3C SPARQL Testsuite — Audit & 100% Coverage Plan (2026-07) + +**Status:** audit complete; Phases A (harness completeness) and E (CI +enforcement) are implemented on branch `test/sparql-testsuite-full-coverage`. +`cargo test` in `testsuite-sparql/` runs all 36 suites green (~15 s wall, +parallel): 1,420 tests — 815 passing, 538 registered engine/feature gaps, 67 +registered not-applicable — with the register enforced in both directions in +CI. Phases B/C/D burn the registers down from here. +**Baseline:** commit `15e2a4b95` (origin/main), rdf-tests submodule `efccbc6b8` (`sparql-mixed-rdf-version-tests-228-gefccbc6`). + +## 1. Executive summary + +The `testsuite-sparql` harness is architecturally sound and honors its design +contract (~4.2k lines of Rust drive ~1,420 W3C test cases discovered from +manifests; zero hand-written test cases). But **CI enforces none of it**: the +GitHub Actions job runs only `fmt`, `clippy`, and `cargo test --no-run`. The +Makefile/docs claim plain `cargo test` is "CI-safe"; it is not — the default +(non-ignored) suite currently fails **85 syntax tests**, which is presumably +why the CI run step was never enabled. + +Ground truth across every registered manifest plus the unregistered SPARQL 1.2 +suite: **~1,420 tests, 595 failures**. The failures cluster into a small number +of root causes, and a large fraction are **harness gaps, not engine gaps** — +the engine has since grown capabilities (SPARQL UPDATE execution, TriG/named +graphs, connection-level datasets) that the harness never adopted. + +Definition of done ("100% green light coverage"): + +1. **Every** manifest in the submodule is registered and executed in CI — + nothing is silently un-run. +2. Every test either passes or sits in an explicit, per-test, commented, + reviewed skip register (the same `ignored_tests` mechanism that exists + today, kept accurate by CI). +3. CI fails on any unexpected failure **and any unexpected pass** (stale skip + entries are errors), so the register shrinks monotonically as engine gaps + close. +4. Engine fixes land under the repo's perf-safety discipline (§6): no hot-path + regressions to buy correctness. + +## 2. Current state + +### 2.1 Harness (contract: intact) + +``` +testsuite-sparql/ (excluded from workspace; own Cargo.lock) +├── src/manifest.rs manifest.ttl → Test iterator (uses Fluree's own Turtle parser) +├── src/evaluator.rs test-type IRI → handler dispatch +├── src/sparql_handlers.rs handler registration +├── src/query_handler.rs QueryEvaluationTest: ledger + load + query + compare +├── src/subprocess.rs per-test subprocess isolation (5 s syntax / 10 s eval timeouts) +├── src/result_format.rs .srx/.srj/.ttl(DAWG)/CONSTRUCT-graph parsing +├── src/result_comparison.rs isomorphic comparison incl. bnode mapping +├── src/report.rs JSON report (W3C_REPORT_JSON) +└── tests/w3c_sparql.rs registry: 28 test fns = manifest URL + skip list +``` + +This is the Oxigraph-style pattern and it has held: adding a whole new suite is +a ~10-line test fn. The refactor risk the team worried about ("too much custom +code") has **not** materialized; the fixes below are additive handlers, not a +rewrite. + +### 2.2 What CI enforces today + +`.github/workflows/ci.yml` job `testsuite-sparql`: fmt + clippy + `cargo test +--no-run`. **No test executes in CI.** There is no durable adherence to any +pass rate today. + +### 2.3 Registry state + +- Syntax suites + property-path run by default; 12 eval categories, 1.0 eval, + update, json-res, csv-tsv, service, protocol, service-description, + http-rdf-update, entailment are all `#[ignore]`d. +- `sparql11_property_path` is the model citizen: green with a curated, + feature-grouped 18-test skip list. **13 of those 18 now pass** (stale — the + features landed); only `pp06 pp16 pp34 pp35 pp36` still fail. +- The whole `rdf-tests/sparql/sparql12/` tree (SPARQL 1.2 / RDF-star, + 254 tests) is **unregistered**. + +## 3. Ground truth (2026-07-06 baseline, this branch) + +| Suite | Total | Pass | Fail | Notes | +|---|---|---|---|---| +| 1.1 syntax-query | 94 | 81 | 13 | 7 pos-rejected, 6 neg-accepted | +| 1.1 syntax-update-1 | 54 | 23 | 31 | graph-mgmt ops missing from parser | +| 1.1 syntax-update-2 | 1 | 1 | 0 | | +| 1.0 syntax | 199 | 158 | 41 | 17 pos (lists/forms/qname), 24 neg | +| 1.1 syntax-fed | 3 | 3 | 0 | | +| 1.1 aggregates | 46 | 37 | 9 | 5 neg-accepted (GROUP BY projection validation) | +| 1.1 bind / cast / negation | 10/6/12 | all | 0 | 100% | +| 1.1 bindings | 11 | 10 | 1 | `graph` (named-graph) | +| 1.1 construct | 7 | 5 | 2 | constructwhere04, constructlist | +| 1.1 exists | 6 | 4 | 2 | exists03, exists-graph-variable | +| 1.1 functions | 75 | 69 | 6 | strlang03-rdf11, concat02, bnode01, in01, notin01, iri01 | +| 1.1 grouping | 6 | 4 | 2 | group06/07 neg-accepted | +| 1.1 project-expression | 7 | 6 | 1 | projexp05 | +| 1.1 property-path | 33 | 28 | 5* | *in skip list; 13 skip entries stale | +| 1.1 subquery | 14 | 11 | 3 | subquery02/04/12 | +| 1.0 eval | 282 | 154 | 128 | see §4 clusters | +| 1.1 update (syntax+eval) | 157 | 24 | 133 | 94 eval "not implemented" + 39 syntax | +| 1.1 json-res | 4 | 0 | 4 | Turtle lexer: `_:o6.` (bnode label + dot) | +| 1.1 csv-tsv-res | 6 | 0 | 6 | comparison not implemented | +| 1.1 service | 7 | 0 | 7* | *skip-listed: needs endpoints | +| 1.1 protocol / GSP / svc-desc | 34/19/3 | 0 | all* | *skip-listed: not applicable (HTTP) | +| 1.1 entailment | 70 | 20 | 50 | needs RDFS/OWL regimes (out of scope) | +| **1.2 (unregistered probe)** | 254 | 91 | 163 | see §4.3 | + +**Totals: ~1,420 registered+probe tests, 595 failing today.** + +## 4. Failure taxonomy → root causes + +### 4.1 Harness/registration gaps (no engine risk; largest, cheapest wins) + +| Gap | Failing tests affected | Fix | +|---|---|---| +| UPDATE evaluation handler is a `bail!` stub, but the API now supports UPDATE (`GraphTransactBuilder::sparql_update`, issues #509/#1288 closed) | 94 (+3 in 1.2) | Implement `UpdateEvaluationTest`: build ledger from `ut:data`/`ut:graphData`, apply update, compare result graph(s) vs `ut:result` (isomorphic, per-graph) | +| Named-graph data loaded into the **default** graph (comment says "until TriG is supported" — TriG landed, #1278/#1279 closed) | ~14 (1.0 graph) + 1 bindings + several 1.0 dataset/1.1 exists | Load `qt:graphData` as real named graphs (wrap each file in `GRAPH { }` TriG or use the named-graph insert API) | +| `FROM`/`FROM NAMED` rejected on single-ledger `GraphDb` | 12 (1.0 dataset) + part of graph | Design decision §5.3: within-ledger dataset construction (preferred) or harness-level graph→ledger mapping via `query_connection_sparql` | +| CSV/TSV result comparison stub | 6 | Implement CSV/TSV encoders/comparison (formatters may already exist in `fluree-db-api::format`) | +| SPARQL 1.2 suites unregistered | 254 | Register per-category fns like every other suite | +| `mf:PositiveUpdateSyntaxTest`/`mf:NegativeUpdateSyntaxTest` (1.2 non-`11` type IRIs) unhandled | 20 | Two `evaluator.register` lines | +| Labeled `qt:graphData` blank nodes silently dropped (`manifest.rs::get_graph_data` calls `term_to_string` on a blank node → `None`; never reads `qt:graph`) | latent (drops test data) | Read `qt:graph` off the blank node | +| Property-path skip list stale (13/18 now pass) | 0 (hides regressions) | Prune to `pp06 pp16 pp34 pp35 pp36`; CI's unexpected-pass check prevents recurrence | +| Combined suites (`sparql11_query_w3c_testsuite`, `sparql11_all`) duplicate per-category runs | n/a | Exclude from CI matrix (keep for local use) | + +### 4.2 Engine gaps (perf-safety analysis required, §6) + +Ordered roughly by breadth of impact: + +1. **UPDATE grammar: graph-management ops absent.** `UpdateOperation` has only + `InsertData/DeleteData/DeleteWhere/Modify`. Missing: `LOAD`, `CLEAR`, + `CREATE`, `DROP`, `COPY`, `MOVE`, `ADD` (+ `SILENT`), and whatever `Modify` + lacks (`WITH`/`USING` coverage TBD). Explains 27+27 positive-syntax + rejections (1.1 update suites) and blocks a slice of update-eval. Parser + work is off hot path entirely; execution maps to existing transact/ledger + ops. +2. **Value/type semantics cluster (~76 in 1.0 eval + ~6 1.1 functions).** + `type-promotion` (22), `open-world` (11), `expr-builtin` (14), `expr-ops` + (7), `expr-equals` (7), `boolean-effective-value` (7), `cast` (7) + 1.1 + functions (6). Root causes: XSD derived-type promotion in comparisons, + open-world equality for unknown datatypes, assorted builtin edge semantics. + **Hot-path sensitive** — this is FILTER evaluation. +3. **Negative-syntax validation (56).** Parser accepts invalid queries: + aggregate/GROUP BY projection scope (agg08-12, group06/07), plus 24 in 1.0 + and 6 in 1.1. Validation passes run at parse time only — off query hot + path by construction. +4. **Positive-syntax parser gaps (24 in 1.0/1.1 query).** Collections `(...)` + in patterns (syntax-lists-*), blank-node property list forms + (syntax-forms), `syntax-qname-05`, `syntax-order-07`, test_21/23/35a/36a/ + 63/64, `test_pp_coll`. +5. **Turtle lexer: bnode label followed by `.` without whitespace** (`_:o6.`) + fails to lex — blocks all 4 json-res tests' data load and any user data of + this shape. PN_LOCAL/label termination rule; perf-neutral char-class fix, + but lexing is import-hot → bench guardrail (`insert_formats`, + `import_bulk`). +6. **SPARQL parser: `BASE` + relative `PREFIX` IRI resolution** + (`base-prefix-1`: "expected IRI after prefix namespace") — several 1.0 + `basic` failures. +7. **Property-path multiplicity semantics** (pp06, pp16, pp34-36): sequence + paths must preserve solution multiplicity while `*`/`+` are + distinct-node; plus pp36 zero-var projection. Deep operator semantics in a + hot operator — needs the §6 pattern (common shapes keep current code path). +8. **RDF-star / SPARQL 1.2** — see §4.3. +9. Misc: `agg-count-rows-distinct` execution error, constructwhere04/ + constructlist execution errors, subquery02/04/12, exists03/ + exists-graph-variable, xsd:long-vs-integer VALUES issue (#1319, open). + +### 4.3 SPARQL 1.2 probe breakdown (254 tests, 91 pass today) + +| Sub-suite | Pass | Fail | Dominant cause | +|---|---|---|---| +| syntax-triple-terms-positive | 19 | 94 | parser lacks `<<( )>>`/triple-term syntax | +| syntax-triple-terms-negative | 63 | 2 | pass "for free" (parser rejects everything star) | +| eval-triple-terms | 0 | 41 | Turtle-star data won't load + engine support | +| lang-basedir | 1 | 10 | base-direction literals (`@en--ltr`) | +| codepoint-escapes | 2 | 6 | `\u`/codepoint escape handling | +| version | 6 | 3 | `VERSION` declaration | +| rdf11 / expression / grouping / syntax | 0/0/0/0 | 3/1/1/2 | mixed | + +RDF-star engine support state (code-level sub-audit, confirmed): + +Fluree implements RDF 1.2 via a deliberate **LPG-style edge-annotation / +reified-edge model**, not first-class triple terms. Much more exists than +the raw pass counts suggest: + +- **SPARQL parser: substantial support.** `<< >>` tokens exist (legacy + Fluree history form, `lower/rdf_star.rs`); RDF 1.2 triple-term `<<( )>>` + tokens are lexed and parsed (`parse/query/term.rs:690`) but deliberately + restricted to object-of-`rdf:reifies`; annotation `{| |}` and `~` lower to + `Pattern::EdgeAnnotation`/`AnnotationTarget` (`lower/annotation.rs`). +- **Engine + storage: end-to-end.** Annotations execute through the normal + scan/join/policy pipeline (`execute/where_plan.rs:74`) with an arena + fast-path (`annotation_edge_probe.rs`); durable storage is ordinary flakes + under 7 reserved `f:reifies*` system predicates, novelty-overlaid and + sealed into an on-disk annotation arena. ~519 test fns cover this. No + feature flag — always on. +- **The actual 1.2-suite gaps** (what the 84 positive-syntax + 41 eval + failures decompose into): + 1. `TRIPLE`/`SUBJECT`/`PREDICATE`/`OBJECT`/`isTRIPLE` functions — no + tokens, no AST (deliberately deferred); + 2. bare triple terms as values (outside `rdf:reifies` object position) — + conflicts with the no-first-class-triple-terms design choice; needs an + explicit decision: implement vs. register as documented divergence; + 3. RDF-star **Turtle/N-Triples ingest** — `fluree-graph-turtle` has zero + star tokens/grammar (`lex/token.rs:47-178`), and `fluree-graph-ir::Term` + is `Iri|BlankNode|Literal` only; blocks all 41 eval-triple-terms tests + at data load. The JSON-LD `@annotation` path IS fully handled — but in + `fluree-db-transact/parse/edge_annotations.rs`, not the generic parser; + Turtle-star ingest should map onto that same reifier pipeline; + 4. SPARQL `CONSTRUCT` projecting annotation metadata → + `UnsupportedFeature` (`lower/construct.rs:91-96`) — output + serialization only, matching works. +- Independent mini-features in the 1.2 suite: `VERSION` declaration (3), + base-direction literals `@en--ltr` (10), codepoint escapes (6) — + parser-local and cheap. + +Related confirmations: UPDATE `Modify` **does** parse `WITH`/`USING` +(`lower_sparql_update.rs:653`); the graph-management verbs are lexed as +reserved keywords but have no AST (only 4 ops in `ast/update.rs:20-32`). +SERVICE is parsed and executed but **Fluree-only** (`fluree:ledger:` / +`fluree:remote:` — external endpoints explicitly rejected, +`fluree-db-query/src/service.rs:487-491`), which is the durable rationale +for the service-suite register. + +### 4.4 Legitimately not-applicable (durable skip register with rationale) + +- protocol (34), http-rdf-update/GSP (19), service-description (3): require + HTTP client/server conformance testing; not a database-engine property. + Already skip-listed with rationale. Keep; re-home if a server-level + conformance harness ever exists. +- service (7): requires live external SPARQL endpoints. Future option: mock + endpoint in-process; until then skip-listed. +- entailment (50 of 70): requires RDFS/OWL/RIF entailment regimes — a + deliberate non-goal. **But 20 pass today** (simple-entailment-answerable); + pin those green so regressions surface, skip-list the rest with rationale. + +## 5. Target architecture decisions + +### 5.1 CI enforcement design + +Replace the compile-only step with a real run: + +- `cargo test` (default set) must be green → becomes the gate. Everything + reachable is registered **non-ignored** with explicit per-test skip lists. +- The `#[ignore]` attribute remains only for: combined/duplicate suites and + suites whose runtime or infra needs make them local-only (none expected). +- Add **unexpected-pass detection** to `check_testsuite`: a test in + `ignored_tests` that *passes* fails the suite with "stale skip entry — + remove it". This is what keeps the register honest (it already rotted once: + 13/18 property-path entries). +- Keep per-test subprocess isolation (existing) so one hang cannot eat the job; + job-level timeout as backstop. +- Nightly (not per-PR) full JSON report artifact for trend visibility. + +### 5.2 Skip-register policy (unchanged from docs, now enforced) + +Every entry: test IRI + comment (root cause, spec link, tracking issue) — +grouped by feature exactly like `sparql11_property_path` does today. CI +enforces both directions. Target end-state register after this effort: +protocol/GSP/service-desc/service/entailment (§4.4) + enumerated engine-gap +tests from §4.2 that don't land in the first wave, each tied to a GitHub +issue. + +### 5.3 Dataset (`FROM`/`FROM NAMED`) strategy — needs a design call + +W3C dataset tests select graphs from the test document set. Options: + +- **(a) Within-ledger datasets** (semantic match): allow dataset clauses on a + single ledger to restrict/compose from its named graphs. #1279's fix + suggests partial infrastructure. Engine change — needs planner review. +- **(b) Harness maps graph URLs → ledgers** and uses + `query_connection_sparql`: zero engine change, but ledger aliases must + admit arbitrary IRIs and semantics must match (default-graph union etc.). + +Decision deferred to implementation of the dataset category; everything else +is independent of it. + +## 6. Perf-safety discipline (non-negotiable) + +Repo priority is speed first, memory second. Correctness fixes must follow the +established off-hot-path pattern (cf. sibling-OPTIONAL fast path retained while +general OPTIONAL semantics were fixed; fulltext context setup skipped unless +the query uses `fulltext(...)`): + +1. **Classify** every engine fix as parse-time, prepare-time, or per-row. + Parse/prepare-time fixes (validation passes, grammar, update ops, promotion + *table construction*) are inherently safe — do them there whenever + possible. +2. **Per-row changes must preserve the common-type fast path.** e.g. type + promotion: keep the existing same-type / integer-integer / string-string + comparisons byte-identical; route only *mixed derived-numeric* and + *unknown-datatype* comparisons through a new slow path selected at + prepare time (per-predicate/per-expression, not per-row branching where + avoidable). +3. **Property-path multiplicity**: preserve current traversal for `*`/`+` + (distinct semantics are correct there); sequence-path counting only where + the plan shape requires it, chosen at plan time. +4. **Bench guardrails per PR**: `query_hot_bsbm`, `query_hot_bsbm_bi` (filter/ + join hot paths), `insert_formats` + `import_bulk` (Turtle lexer), within + `regression-budget.json` budgets; nightly bench workflow is the backstop. +5. Harness-only changes (Phase A) carry zero engine risk by definition. +6. **JSON-LD parity (team guideline).** SPARQL, JSON-LD query, and Cypher + share the IR and engine. Every burn-down fix must classify as + IR/engine-level (fixes all surfaces implicitly — still add a JSON-LD + regression test) or surface-syntax addition (anything newly possible in + SPARQL must be made possible in JSON-LD in the same effort, with tests — + we own the JSON-LD query syntax). The W3C submodule only guards the + SPARQL surface — JSON-LD regression coverage must be authored alongside + each fix. Cypher is deliberately excluded: openCypher's grammar isn't + ours to extend; it benefits from IR/engine fixes automatically. See + `docs/contributing/sparql-compliance.md` § "Query Surface Parity". + +## 7. Phased implementation plan + +**Phase A — harness completeness (no engine changes):** +A1 fix `get_graph_data` labeled-graph bug; A2 named-graph loading via TriG; +A3 UPDATE-eval handler over `sparql_update`; A4 register 1.2 suites + +non-`11` update-syntax types; A5 CSV/TSV comparison; A6 prune property-path +skip list; A7 unexpected-pass detection; A8 entailment: pin 20 green, skip +rest with rationale. Re-baseline after A — expected: update-eval failures +collapse to real engine gaps; graph/bindings/exists partially green; +1.2 counts become accurate. + +**Phase B — off-hot-path engine fixes:** +B1 UPDATE grammar graph-mgmt ops (+ exec mapping); B2 negative-syntax +validation passes; B3 positive-syntax parser gaps (lists/forms/qname/order); +B4 Turtle lexer bnode-dot fix (bench-guarded); B5 BASE/PREFIX resolution fix. + +**Phase C — semantics fixes (bench-guarded, §6 pattern):** +C1 type-promotion + open-world equality + expr builtins cluster; +C2 remaining eval mismatches (functions/subquery/exists/construct/aggregates); +C3 property-path multiplicity; C4 dataset strategy (§5.3) + graph category. + +**Phase D — RDF-star/1.2** (scope per the §4.3 sub-audit): Fluree's +edge-annotation model already covers parse→IR→match→storage. Work items: +D1 Turtle/TriG-star ingest mapped onto the existing reifier pipeline (the +same rewrite `edge_annotations.rs` does for JSON-LD `@annotation`); +D2 triple-term functions (TRIPLE/SUBJECT/PREDICATE/OBJECT/isTRIPLE); +D3 decision: bare triple-terms-as-values — implement vs. documented +divergence register (design conflict with the no-first-class-terms model); +D4 CONSTRUCT annotation projection; D5 mini-features (VERSION, +lang-basedir, codepoint escapes). + +**Phase E — CI switch-on:** replace `--no-run` with the real run + both-way +enforcement; docs update (`sparql-compliance.md`, Makefile "CI-safe" claim); +nightly report artifact. + +Sequencing: A → E can land immediately after A (CI enforces the accurate +baseline with explicit registers); B/C/D burn the register down in follow-up +PRs, each shrinking skip lists in the same change that fixes the engine. + +## 8. Implementation status (updated 2026-07-06) + +**Done (this branch):** +- Phase A complete: labeled `ut:graphData` parsing fixed; named graphs load + as real named graphs (TriG via transact builder); `UpdateEvaluationTest` + implemented end-to-end over the public `sparql_update` surface (24→67 + passing); CSV/TSV result comparison (0→5 of 6); SPARQL 1.2 suites + registered per-category; non-`11` update-syntax test types handled; + property-path register pruned (14 stale entries removed, `pp06` + additionally flagged by the new stale-skip detection); + unexpected-pass policing added to `check_testsuite`. +- Phase E complete: CI job now runs the full suite (was compile-only); + Makefile/docs rewritten to match the registers model + (`tests/registers/mod.rs`). + +**New engine findings from Phase A runs (added to registers):** +- `GRAPH ?g` exposes the default graph as a graph named by the ledger alias, + and binds `?g` as a *plain literal* rather than an IRI — breaks most + `sparql10/graph` tests even though named-graph data now loads correctly. +- `GRAPH` blocks inside `DELETE WHERE` are rejected at lowering + ("not yet supported"). +- `INSERT` into a not-yet-existing named graph silently loses the triples + (basic-update `insert-05a`, `insert-*-same-bnode*`). +- `USING` clause semantics incomplete (`dawg-delete-using-*`). +- Combined `DELETE`/`INSERT` `WHERE` applies inserts without the deletes + (`dawg-delete-insert-01c`). +- CSV output does not use canonical `xsd:double` lexical form (`csv03`). + +**Open items:** +- [ ] Phase B/C/D engine fixes per §4.2/§4.3 (each PR must shrink the + registers it addresses; bench guardrails per §6). +- [ ] Decide §5.3 dataset strategy when Phase C4 starts. +- [ ] File tracking issues per §4.2 cluster (audit A2-style batch). +- [x] Deep code-level RDF-star audit — done, folded into §4.3 (2026-07-06). +- [ ] Phase D3 design decision: bare triple-terms-as-values vs. documented + divergence (owner: engine team; blocks part of + syntax-triple-terms-positive / eval-triple-terms registers). diff --git a/docs/audit/burn-down/ROADMAP.md b/docs/audit/burn-down/ROADMAP.md new file mode 100644 index 0000000000..a689bad207 --- /dev/null +++ b/docs/audit/burn-down/ROADMAP.md @@ -0,0 +1,589 @@ +# W3C SPARQL Burn-Down — Consolidated, Verified Implementation Roadmap + +**Status:** Stage-3 synthesis. Inputs: the eight Stage-1 cluster audits in this +directory, eight Stage-2 adversarial verification passes (one skeptic per doc, +code-level + empirical), and a Stage-2 cross-check (ownership, movements, +conflicts, shrink accounting). Parent context: +`docs/audit/2026-07-sparql-testsuite-audit.md` (§6 perf discipline, §7 phases) +and `docs/contributing/sparql-compliance.md` (§ Query Surface Parity — JSON-LD +only; Cypher excluded). +**Baseline:** branch `test/sparql-testsuite-full-coverage`, rdf-tests +`efccbc6b8`, register `testsuite-sparql/tests/registers/mod.rs` @ 604 entries +(573 unique tests; the 31 syntax-update-1 tests are double-registered). +**Ground-truth split:** 63 not-applicable entries + 541 gap entries. The parent +audit's "538 gaps + 67 NA" headline is stale by 4; use 63/541. + +--- + +## 1. Verification summary + +55 load-bearing claims were adversarially verified across the eight docs: +**40 CONFIRMED, 13 REFUTED, 2 UNCERTAIN.** Every file:line citation spot-checked +in the CONFIRMED set matched the code; all reproduced failures reproduced +exactly. The docs are trustworthy design inputs *after* the corrections below. + +| Cluster doc | Claims | Confirmed | Refuted | Uncertain | +|---|---|---|---|---| +| `update-completeness.md` | 7 | 7 | 0 | 0 | +| `parser-syntax-validation.md` | 7 | 5 | 2 | 0 | +| `named-graph-dataset.md` | 6 | 3 | 3 | 0 | +| `expression-semantics.md` | 8 | 5 | 2 | 1 | +| `lexer-formatter.md` | 6 | 4 | 2 | 0 | +| `sparql12-wave1.md` | 6 | 4 | 2 | 0 | +| `sparql12-wave2-triple-terms.md` | 7 | 6 | 1 | 0 | +| `residual-eval.md` | 8 | 6 | 1 | 1 | +| **Total** | **55** | **40** | **13** | **2** | + +### 1.1 REFUTED claims and how this roadmap hedges them + +1. **"P1 collection desugaring greens `construct#constructlist`"** + (`parser-syntax-validation.md` §3). CONSTRUCT templates containing blank + nodes currently evaluate to an **empty graph** (template bnodes lower to + never-bound variables and are dropped); constructlist additionally needs + per-solution bnode instantiation in the CONSTRUCT output path. + *Hedge:* PR-1 must **not** remove the constructlist register entry (the + both-directions CI check would fail); constructlist moves to the new + **serialization cluster** (§2, unowned-work item W-2) alongside + construct-3/4 and quotes-3/4. `residual-eval.md` §3.4's delegation of + constructlist to the syntax PR is void. +2. **"An `Expression::variables()` helper already exists — reuse it"** + (`parser-syntax-validation.md` §2 V4). No such helper (nor + `contains_aggregate()`) exists. *Hedge:* PR-2 budgets writing both walkers + in `ast/expr.rs`; they are shared with the nested-aggregate check + (`sparql12-wave1.md` §1.2), which is folded into the same PR. +3. **"Removing the #1279 default-graph enumeration updates exactly two tests"** + (`named-graph-dataset.md` §6). A third test pins the extension: + `fluree-db-api/tests/it_upsert_duplicate_ids_repro.rs:228` (file-backed, + binary-index path). *Hedge:* PR-G1's blast radius and reviewer sign-off + scope include all three test updates. +4. **"#1317 plausibly shares BUG-2's root cause"** (`named-graph-dataset.md` + §6 open Q5). #1317's signature (concrete `GRAPH `, multi-level index, + wipe-then-upsert sequence) is incompatible with the unbound-`?g` enumeration + arm. *Hedge:* #1317 is routed to its own graph-registry/indexing + investigation (issue comment, §5); it is **not** closed against PR-G1; + post-land verification covers subquery04 only. +5. **"BUG-4: the GRAPH-exist unit row is dropped downstream (projection / + SPARQL-JSON writer)"** (`named-graph-dataset.md` §1). Empirically false — + a 0-column/1-row batch survives projection and formatting intact. + `graph-exist` actually fails on **relative-IRI/BASE resolution** (the query + names ``, the graph is registered under the absolute URL). + *Hedge:* PR-D is **deleted from the slate**; graph-exist reassigns to + **PR-BASE** (new). No shared projection/format code is touched. +6. **"The harness `.rdf` DAWG parser drops solutions containing unbound + variables"** (`expression-semantics.md` §6). Attribution to the harness is + correct but the mechanism is backwards: `parse_rdf_dawg_result_set` + (`result_format.rs:809`) treats `Event::Start | Event::Empty` identically + (`:835`), so self-closing `` elements skew the + state stack — it *keeps* the unbound solution and drops the bound ones. + *Hedge:* PR-H1 fixes the `Event::Empty` handling (pop/no-push), and audits + the identical pattern in the SRX parser at `result_format.rs:413`. +7. **"#1319 survives the scan path via `dt_compatible`"** + (`expression-semantics.md` §1). `dt_compatible` is asymmetric and is not + consulted for ordinary pattern objects — they carry **no** datatype + constraint at all (`lower/term.rs:164-166`, deliberate). The proposed fix + (lower bare integers to `xsd:integer`) is still correct. *Hedge:* nobody + "extends dt_compatible"; **D5b** (`open-eq-02`) is re-located to scan-path + datatype constraints — a deliberately-disabled, per-flake, perf-sensitive + change. It becomes an explicitly at-risk item inside PR-X2 with its own + bench gate, deferrable with a register entry. +8. **"All RDF-lexical double output paths = the 4 cited sites"** + (`lexer-formatter.md` §1b). Misses `fluree-db-api/src/export.rs:1236-1249` + and `:1465-1477` (N-Triples/N-Quads export, `format!("{f:E}")` → `"1E6"`), + and `LiteralValue::lexical()` is also consumed by the R2RML extractor + (`fluree-db-r2rml/src/loader/extractor.rs:457,478`). *Hedge:* PR-L2 scope + includes the export sites; the R2RML/Iceberg workstream is flagged on the + PR; DoD requires a whole-workspace consumer grep. +9. **"The bnode-dot lexer fix strictly widens what lexes; byte-identical for + currently-valid input"** (`lexer-formatter.md` §2). Spec-valid `_:a..b` + lexes today and would break under the doc's proposed loop (which also does + not compile). *Hedge:* PR-L1 uses the **greedy-scan + trailing-dot-rewind** + design (keep the existing per-char predicate; post-scan, rewind the input by + the count of trailing dots) — fixes `_:o6.` and keeps `_:a..b` + byte-identical. +10. **"`EdgeKey::to_reifies_facts` is the shared encoder both ingest paths must + use"** (`sparql12-wave1.md` §2.3). The JSON-LD path uses a JSON mirror of + the `_jsonld_compatible` shape (omits `f:reifiesDatatype`); calling the + full variant from Turtle would emit an extra flake and leave durable orphan + flakes under cascade retracts. *Hedge:* PR-W15 hard-codes + `to_reifies_facts_jsonld_compatible`; bit-identity is guarded by the + `edgekey_roundtrip_*` equivalence tests, not "by construction". +11. **"40 of 41 eval-triple-terms fail at qt:data load"** (`sparql12-wave1.md` + §2.1). It is 39; `update-3` fails parsing the **expected** TriG-star graph + *after* its `{| |}` INSERT DATA already executed. *Hedge:* update-3's + register re-point names harness expected-graph TriG-star parsing, and the + fact that `{| |}` INSERT DATA already executes is recorded as scoping + intel for the Option-1 epic. +12. **"inside-anonreifier/reifier-01/02 all fail on the object-position `<<>>` + gap"** (`sparql12-wave2-triple-terms.md` §1.1). The `-02` pair sits inside + RDF collections `( … )`, which are wholesale unparsed. *Hedge:* PR-W2A + **depends on PR-1** (collections); the two PRs coordinate register removal + across `SPARQL10_SYNTAX` syntax-lists and the wave-2 register so neither + trips the stale-entry check. +13. **"agg02's fix target is the streaming `GroupAggregateOperator` + variable-count finalize"** (`residual-eval.md` §3.3). That finalize + already emits `xsd:integer` and is shared by the *correct* COUNT(\*) path; + the re-typing provably happens elsewhere (plausibly shared encoded-binding + materialization — per-row-relevant). *Hedge:* agg02 inside PR-X2 is an + **investigation item**: probe first, classify the real site against §6, + bench-gate if it lands in shared materialization, write the JSON-LD parity + test against the real site. + +### 1.2 UNCERTAIN claims and what settles them + +1. **"The D5/D7 comparison-lattice fix keeps same-type fast paths + byte-identical"** (`expression-semantics.md` §2/§3). It is the *mandated* + shape (§6.2) and the infrastructure exists (prepare-time expression + analysis, per-predicate datatype stats), but `eval.rs:147` must newly read + `dtc` per string-literal row whenever static type info is unavailable + (fresh/unindexed ledgers have no stats). *Roadmap hedge:* treated as a + **bench-gated goal**, not a fact — PR-X2 acceptance is `query_hot_bsbm` + + `query_hot_bsbm_bi` within `regression-budget.json`; if the budget blows, + the fallback design (dtc niche-packing in `ComparableValue`) is invoked + before any budget waiver is discussed. *Settled by:* implementing and + running both benches. +2. **"6 residual tests are latent passes waiting only on the graph fix"** + (`residual-eval.md` §1). Only 4–5 are cleanly latent; + `agg-empty-group-count-graph` additionally needs enumerable empty named + graphs (decision D-6) and `exists03` has an unverified EXISTS + active-graph-scoping sub-check. *Roadmap hedge:* PR-G1/PR-BASE acceptance + criteria list pp34, pp35, subquery02, subquery04 as expected greens and + exists03 + agg-empty-group-count-graph as **conditional**. *Settled by:* + running the graph-fixed build against all six. + +Additional verified corrections that are not formal claim verdicts but bind the +plan: PR-X1's yield is ~35–36 entries, not ~40 (tP-03/04/05/21 need PR-X2 — +`expression-semantics.md` D4 contradicts its own §5); wave1 PR-4's GROUP BY +term-key (`group01`) is unimplementable as scoped because integer lexical forms +are canonicalized at ingest — it is gated on the D6 lexical-form decision; +`fluree-db-api` swallows parse-error diagnostics whenever an AST survives +recovery (`helpers.rs:271-285`), so PR-3's grammar tightening must make V1/V2 +errors prevent AST production or the product API silently keeps accepting; +`parse_trig_phase1` (`trig_meta.rs:1199`, wired via `tx_builder.rs:146`) +already extracts multi-graph TriG — pure-TriG eval blockers may be harness +routing, not new parser work. + +--- + +## 2. Final PR slate + +Consolidated per the team preference for fewer, larger, thematically-coherent +PRs: **17 firm PRs + 5 decision-gated items + 2 unowned work-streams**. Per the +update-completeness skeptic, the standard bench gates (`query_hot_bsbm`, +`query_hot_bsbm_bi`, `insert_formats`, `import_bulk` within +`regression-budget.json`) run on **every** PR — no waivers; "extra gates" below +are additions. "Shrink" counts **register entries** (syntax-update-1 tests +count twice — both register copies must be deleted). Decisions D-1…D-12 are +defined in §4. + +### Wave 0 — zero/near-zero engine risk, all parallel-safe + +| PR | Scope | Owning doc(s) | Shrink | Risk | Extra gates / JSON-LD tests owed | Gated on | +|---|---|---|---|---|---|---| +| **PR-H1 — harness + hygiene** | Fix `parse_rdf_dawg_result_set` `Event::Empty` state-stack skew (`result_format.rs:809/:835`); audit the same pattern in the SRX parser (`:413`); apply the full register-comment correction batch (§6.1) and the parent-audit corrections (§6.3) | `expression-semantics.md` §6 (corrected per §1.1-6) | **−3** (dawg-sort-3/6/8) | None (harness-only) | None / none | — | +| **PR-1 — parser accepts valid syntax** | P1 collections (desugar to `rdf:first/rest/nil`), P4 empty IRIREF `<>`, P5a function-`NIL`, P5b bare ORDER BY constraint, P6 sub-select placement, P7 `VALUES ()`, P8 path-in-`[]`; + SPARQL 1.2 codepoint-escape pre-pass (`Cow` fast path) | `parser-syntax-validation.md` §2 PR-1; `sparql12-wave1.md` §1.1 | **−34** (24 syntax positives + list-1..4 + CODEPOINT_ESCAPES −6). **NOT constructlist** (§1.1-1). base-prefix-1 transfers to PR-BASE | Low (accept-more only) | None extra / `@list`↔first-rest parity test in `it_query.rs`; surface-only decisions recorded | — | +| **PR-L1 — Turtle bnode-dot lexer** | Greedy-scan + trailing-dot-rewind in `parse_blank_node_name` (NOT the doc's loop — §1.1-9); unit tests incl. `_:a..b` byte-identity | `lexer-formatter.md` §2 (corrected) | **−4** (JSON_RES) | Low, import-hot file | `insert_formats`+`import_bulk` are the primary gates / post-insert JSON-LD-surface query test | — | +| **PR-L2 — canonical `xsd:double`** | Shared `canonical_xsd_double` helper; route `sparql.rs`, `sparql_xml.rs`, `delimited.rs`, `LiteralValue::lexical()`, **and `export.rs:1236/:1465`** through it; zero-alloc write-into-buffer variant for `delimited.rs`; JSON-LD numeric output untouched; changelog | `lexer-formatter.md` §3 (scope extended per §1.1-8) | **−1** (csv03) | Low; user-visible output change | Whole-workspace `lexical()` consumer grep (R2RML flagged) / JSON-LD number-output guard tests stay unchanged | — | +| **PR-X1 — expression cheap high-yield** | D1 constant-FILTER placement, D2 DATATYPE/LANG expression args, D2b non-literal type error (SPARQL-scoped; covers **projexp05** and RDF11 `langstring-datatype`/`plain-string-datatype`), D3 dateTime cast, D9 BNODE per-solution, D10 regex `q`, #1319 bare-int lowering | `expression-semantics.md` §5 PR-A; `residual-eval.md` §3.3; `sparql12-wave1.md` §1.3 (DATATYPE half) | **≈−39** (~35-36 expr entries — tP-03/04/05/21 stay for PR-X2 — + projexp05 + RDF11 −2). D8/iri01 transfers to PR-BASE | Low-medium (cold/erroring paths only) | Standard gates / D1, D2/D2b, #1319 JSON-LD tests per `expression-semantics.md` §4 | — | +| **PR-PP — property-path operator** | pp16 zero-length node universe (all graph terms incl. **literals** — internal pair buffers must carry full terms, larger than the doc's "node set only" wording); pp36 `empty_schema_with_len` guard | `residual-eval.md` §3.2 | **−2** | Low-medium, contained to `property_path.rs` | Add `query_hot_property_path` bench and gate on it / record SPARQL-only decision | — | + +### Wave 1 — parse/validate behavior changes + first UPDATE PRs + +| PR | Scope | Owning doc(s) | Shrink | Risk | Extra gates / JSON-LD tests owed | Gated on | +|---|---|---|---|---|---|---| +| **PR-2 — semantic validation passes** | V3 bnode-scope, V4 group/projection scope, V5 BIND scope, V6 dup alias (write `contains_aggregate()` + free-var collector from scratch — §1.1-2); + nested-aggregate and duplicated-VALUES-var checks (SPARQL12_SYNTAX); + wave-2 negative (annotation blocks in INSERT/DELETE DATA) | `parser-syntax-validation.md` §2 PR-2; `sparql12-wave1.md` §1.2; `sparql12-wave2-triple-terms.md` §2/PR-D | **−27** (24 + 2 + 1) | Medium (reject-more) | Standard / JSON-LD analytical group/BIND-scope rejection tests (`it_query_analytical.rs`, `it_query_grouping.rs`); shared-checker-vs-authored-tests decision recorded | D-4 | +| **PR-3 — dot + FILTER grammar tightening** | V1 load-bearing dots, V2 FILTER Constraint. **Errors must prevent AST production** (or the API must honor error-severity diagnostics) — otherwise `fluree.query` keeps mis-executing (§1 addendum). Single owner for the trailing-token/EOF assertion shared with PR-U2 | `parser-syntax-validation.md` §2 PR-3 | **−13** | Highest parser regression surface; isolate for bisect | Standard; full query-suite reruns both directions / changelog + migration note | D-4 | +| **PR-U1 — UPDATE validation (classes D+E)** | Reject bnodes in DELETE forms; route DELETE-WHERE-GRAPH through Modify template lowering (drop validator rejection at `validate/mod.rs:154`); amend the compliance-doc parity table with an UPDATE/transact row (`it_transact_update.rs`, `it_named_graphs.rs`) | `update-completeness.md` §2 D/E, PR-U1 | **−19** entries (test_36 + test_50/51/52 ×2 registers; delete-insert-03..09; delete-where-02/04/06) | Low | Standard / JSON-LD named-graph-delete test + JSON-LD bnode-in-delete negative test | — | +| **PR-W2A — reifier-form parser extensions** | Object-position/nested/standalone `<<>>`, `~`-inside-`<<>>`, multi-reifier/annotation, richer annotation blocks; lower to existing `EdgeAnnotation` where evaluable else clean `not_implemented` | `sparql12-wave2-triple-terms.md` §7 PR-A | **−63** (60 TRIPLE_TERMS_POSITIVE + **3 SPARQL12_VERSION** — the omission the cross-check caught) | Low (parse-time; small lowering surface) | Standard / **JSON-LD regression for object-position-reifier reachability through `Pattern::EdgeAnnotation` — explicit deliverable, not prose** | **Depends on PR-1** (collections for inside-\*-02) | + +### Wave 2 — decision-gated semantics + big engine PRs + +| PR | Scope | Owning doc(s) | Shrink | Risk | Extra gates / JSON-LD tests owed | Gated on | +|---|---|---|---|---|---|---| +| **PR-G1 — GRAPH operator W3C conformance** | BUG-1 `?g` as IRI, BUG-2 drop implicit default-graph enumeration (keep explicit alias addressing), BUG-3 seed `?g` into inner subplan, BUG-5 EncodedSid/EncodedLit extraction; **explicitly adopts residual defects A3/A4** (subquery02/04 acceptance criteria). Updates **three** pinning tests (`it_query_dataset.rs:1591/:1752`, `it_upsert_duplicate_ids_repro.rs:228`) | `named-graph-dataset.md` §3 PR-A/B/C merged; `residual-eval.md` §3.1 | **−14** (9 /graph/ + graph-variable-join + graph-optional + exists-graph-variable + subquery02 + subquery04). `bindings#graph` stays (D-6) | Medium; semantics change, 2-reviewer sign-off | Standard / 3 FQL tests per `named-graph-dataset.md` §5 — **BUG-5's test must use the EXISTS/late-materialized shape** (top-level scan-bound `?g` passes today) | D-2 | +| **PR-BASE — base-relative IRI resolution** *(new work item; fills the ownership vacuum)* | Thread the query BASE through lowering/plan: constant GRAPH IRIs, FROM/FROM NAMED clause IRIs, `IRI()`/`URI()` (D8), output IRIs. Replaces phantom PR-D | Vacuum identified by cross-check; halves in `parser-syntax-validation.md` open-Q3 and `expression-semantics.md` D8 | **≈−7 direct** (graph-exist, iri01, base-prefix-1 eval, base-prefix-2/5) **+3 joint** with PR-G1 (pp34, pp35, exists03 — second lander removes) | Low-medium (prepare/lower-time) | Standard / IRI()-base JSON-LD test | — (but PR-G2 depends on it) | +| **PR-U2 — multi-operation UPDATE requests (classes B+C)** | Request-level `;` loop sharing one prologue; trailing-token/EOF assertion (coordinate single implementation with PR-3); sequential staging of ops against evolving novelty within **one atomic commit**; empty/prologue-only request = valid no-op; cross-op bnode-scope validation (test_54) | `update-completeness.md` §2 B/C, PR-U2 | **−9** (01c + test_38/39/40 ×2 + test_54 ×2); **unblocks** insert-05a + 3 same-bnode (green after PR-U3) | Medium-high (sequential staging design; single-op path must stay byte-identical) | Standard — this PR touches the shared parse entry and commit path, so gates are mandatory / array-of-ops JSON-LD surface decision recorded | **D-10** (Txn IR model) | +| **PR-U3 — graph-management verbs (class A)** | Grammar/AST/parser for LOAD/CLEAR/DROP/CREATE/ADD/COPY/MOVE + SILENT; retract-all-in-g_id staging primitive; DROP≡CLEAR; CREATE near-no-op; COPY/MOVE/ADD composed over CLEAR; LOAD parse + SILENT-swallow, remote LOAD as documented divergence. Expose `clear_graph`/`drop_graph`/`copy_graph` on the transact builder + JSON-LD surface (merges the docs' U3+U4+U5) | `update-completeness.md` §2 A, PR-U3/U4/U5 | **≈−91** (remaining SPARQL11_UPDATE eval + SYNTAX_UPDATE_1 ×2 entries; completes the 4 same-bnode tests jointly with PR-U2) | Medium (large but off query path) | Standard / builder + JSON-LD tests for exposed capabilities | D-5, D-6 | +| **PR-X2 — equality/EBV/promotion lattice** | D5+D7 datatype-aware comparison (`sameTerm`, IN via `=`), D-EBV, D4 float/double∘decimal (incl. tP-03/04/05/21), D11 CONCAT, D12 **SPARQL-scoped only** (STRLANG + result normalization; the ingest lower-casing option is struck); `not-not` + `plain-string-same` (folded from wave1 PR-3/4); aggregates: agg-err-01 poison-on-non-numeric, agg-count-rows-distinct new IR aggregate, **agg02 (probe-first — §1.1-13)**, subquery12 (repro-first); **at-risk carve-out:** D5b/open-eq-02 scan-path datatype constraints — own bench sign-off or defer with register entry | `expression-semantics.md` §5 PR-B; `residual-eval.md` §3.3; `sparql12-wave1.md` §1.3/1.5 | **≈−40** (~34 expr PR-B + bnode… bnode01 is in X1; + not-not + plain-string-same + agg02 + agg-err-01 + agg-count-rows-distinct + subquery12; + eq-graph-1/2/4 jointly with PR-G1 — second lander removes) | **Highest engine risk in the slate** (FILTER `=`, EBV, join residuals) | `query_hot_bsbm` **and** `query_hot_bsbm_bi` every commit; §1.2-1 byte-identical goal is the acceptance bar / D5/D7, D-EBV, D4, agg JSON-LD tests per `expression-semantics.md` §4 + `residual-eval.md` §5 | D-12 (strict-mode scoping) | +| **PR-G2 — within-ledger FROM/FROM NAMED (Option A)** | Replace the `view/query.rs:556` guard with within-ledger DataSet construction (single snapshot, existing `DatasetOperator`); harness pre-loads clause-referenced files; must route through `dataset_query.rs` (policy/reasoning), reuse `as_runtime_dataset` | `named-graph-dataset.md` §2 PR-E | **−13** (12 /dataset/ + constructwhere04) | Medium | Standard (BSBM benches never instantiate dataset code — confirm flat) / FQL within-ledger `from`/`fromNamed` test | **D-3; depends on PR-BASE** (relative clause IRIs) | +| **PR-U6 — USING + GRAPH scoping (class F)** | Fix WHERE default-graph selection when USING co-occurs with explicit GRAPH (`stage.rs:1382-1399` region); only class touching shared WHERE lowering | `update-completeness.md` §2 F, PR-U6 | **−2** (delete-using-02a/06a) | Medium (shared prepare-time lowering) | Standard, query benches emphasized / JSON-LD graph-scoped delete-where test | Sequence **after PR-G1** | +| **PR-W15 — Turtle-star ingest (asserting forms)** | Star tokens + parser hooks + sink events → **`to_reifies_facts_jsonld_compatible`** (§1.1-10); **fresh reifier per anonymous occurrence** (mirror `_:fluree_ann_N`, never dedup by EdgeKey) as an explicit requirement with an equivalence test for the repeated-anon case; reject `<<( )>>` and TriG-star with clear deferred errors; **re-run the eval suite after landing to re-baseline the 39** | `sparql12-wave1.md` §2 PR-5 (corrected) | **−2** (pattern-3, pattern-3-nomatch) | Medium (import-hot lexer) | `insert_formats`+`import_bulk` primary; byte-identical non-star corpus assertion / Turtle↔JSON-LD flake-equivalence tests next to `edgekey_roundtrip_*` | D-8 informs the TriG half | +| **PR-W2BC — triple-term syntax accept-then-defer (Option 4)** | Parse TRIPLE/SUBJECT/PREDICATE/OBJECT/isTRIPLE as builtins (arity-validated, lowering `not_implemented`); accept `<<( )>>` in subject/object/VALUES/BIND honoring the §2 negative-suite guardrails | `sparql12-wave2-triple-terms.md` §7 PR-B/C | **−27** | Low (parse-time) | Standard / record category-2 parse-only classification per compliance §Query Surface Parity | **D-1** | + +### Decision-gated tail (not started until the decision lands) + +| Item | Scope | Shrink if favorable | Gated on | +|---|---|---|---| +| **PR-X3 — D6 lexical-form preservation** | Lexeme column vs documented divergence; also unblocks `group01` (SPARQL12_GROUPING) and completes parts of RDF11 | −8 expr + −1 group01 | D-11 | +| **PR-W16 — base direction** | Option B encoding (lang-string `en--ltr`) + 4 new functions + both lexers + JSON-LD `@direction` in the same effort; or Option C divergence comments | −10 LANG_BASEDIR | D-7 | +| **PR-ENT — entailment pragma harness variant** | Inject `# PRAGMA reasoning: owl2rl` per named test; start rdfs03/04/09; per-test checks for rdfs05-07/10/11, parent\* | −3 firm, more candidates | D-9 | +| **Triple-term first-class-value epic (Option 1)** | New arena-handle object kind across the 8 closed enums; bucket-B eval; CONSTRUCT annotation projection; result serialization; TriG-star; JSON-LD value surface | up to −39 (EVAL_TRIPLE_TERMS) + `update-3` | D-1 (scoped after PR-W15 re-baseline) | +| **Empty-named-graph model** | Enumerable empty graphs vs permanent divergence | −2 (bindings#graph, agg-empty-group-count-graph) | D-6 | + +### Unowned work-streams needing a ninth cluster doc or divergence sign-off + +- **W-1 Algebra/FILTER-scope + nested-OPTIONAL cluster (9 entries):** + algebra/filter-nested-2, join-combo-2, join-scope-1, nested-opt-1/2, + optional/dawg-optional-complex-2/3/4, optional-filter-005. Verified real + (filter-nested-2 reproduces as a scope bug). Its fix territory is + `where_plan.rs` filter partitioning — **the same machinery PR-X1's D1 + touches**; land PR-X1 first and give this cluster a single owner before it + starts. +- **W-2 Output/serialization cluster (5 entries):** construct-3/4 + (reification output), quotes-3/4 (string-escape serialization), and + **constructlist** (per-solution bnode instantiation in CONSTRUCT templates — + the capability gap exposed by §1.1-1; per-solution output path, bench-aware + but not scan-hot). + +### Adjusted shrink accounting + +Cross-check firm total 411, adjusted for verdicts: −1 (constructlist out of +PR-1) +3 (sort-3/6/8 into PR-H1) +2 (base-prefix-2/5 into PR-BASE) +3 +(eq-graph-1/2/4 into PR-G1+PR-X2) = **≈418 entries removed by the firm slate**. +Remaining ≈186 = 63 not-applicable + 50 entailment (−3 via PR-ENT) + 39 +triple-term eval epic + 10 lang-basedir + 8 D6 + 2 empty-graph + **14 orphans** +(W-1: 9, W-2: 5). With all gates favorable: ≈124 remain (63 NA + 47 entailment ++ 14 orphans pending W-1/W-2). Caveats already priced in: pp34/pp35/exists03 +firm only because PR-BASE + PR-G1 explicitly adopt them; wave-2's −27 assumes +D-1 = accept. + +--- + +## 3. Sequencing plan (safest-first) + dependency map + +**Wave 0 (now, all parallel):** PR-H1, PR-1, PR-L1, PR-L2, PR-X1, PR-PP. +**Wave 1:** PR-U1, PR-W2A (after PR-1); PR-2, PR-3 (after D-4). +**Wave 2:** PR-BASE, PR-G1 (after D-2), PR-U2 (after D-10 + EOF coordination +with PR-3), PR-W15. +**Wave 3:** PR-G2 (after PR-BASE + D-3), PR-U3 (after D-5/D-6), PR-X2 (after +PR-X1; agg02 probe inside), PR-U6 (after PR-G1), PR-W2BC (after D-1). +**Tail:** PR-X3, PR-W16, PR-ENT, epics, W-1/W-2 per decisions. + +Hard dependencies (all others are parallel-safe): + +``` +PR-1 ──────────────► PR-W2A (collections; joint register updates) +PR-3 ◄──coordinate──► PR-U2 (single trailing-token/EOF implementation + in the shared parse_query entry) +D-10 ──────────────► PR-U2 (Txn IR: Vec vs UpdateRequest — + decide BEFORE the PR; JSON-LD/Cypher/ + pre_built_txn consumers ripple) +D-2 ───────────────► PR-G1 ──► PR-U6 (class F sits on the same default-vs- + named semantics; also gates the algebra + cluster's optional-complex-2) +PR-BASE ───────────► PR-G2 (dataset clause IRIs are relative) +PR-BASE + PR-G1 ───► pp34/pp35/exists03 removal (second lander removes) +PR-G1 + PR-X2 ─────► eq-graph-1/2/4 removal (second lander removes) +PR-X1 ─────────────► PR-X2 (D2 unmasks D4's tP tests) +PR-X1 (D1) ────────► W-1 algebra cluster (same where_plan.rs filter machinery) +PR-W15 ────────────► eval-triple-terms re-baseline ──► Option-1 epic scoping +D-1/D-3/D-4/D-5/D-6/D-7/D-9/D-11/D-12 ► their gated PRs (see §2/§4) +``` + +Cross-cluster file-collision watchlist: `validate/mod.rs` (PR-2, PR-U1, PR-2's +wave-2 negative — merge-order only, no semantic conflict; note PR-U1 *removes* +the DELETE-WHERE-GRAPH rejection while others add checks); +`parse/query/term.rs`/`pattern.rs` (PR-1's P1/P8 before PR-W2A/W2BC); +`fluree-graph-turtle/src/lex/lexer.rs` (PR-L1 vs PR-W15 — land L1 first, +tiny); `where_plan.rs` (PR-X1 D1 vs W-1); `graph.rs` (PR-G1 vs residual A1-A4 +— merged by construction here). + +--- + +## 4. Decision list for the team + +> **STATUS (2026-07-06): ADOPTED AS RECOMMENDED.** AJ approved proceeding with +> the recommendation column for all of D-1 through D-12. **Standing +> requirement:** any PR that actions one of these decisions must state in its +> PR description that a decision point existed, enumerate the valid options +> that were considered (including the ones not taken), and explain why the +> adopted option was chosen — linking back to this table and the owning +> cluster doc. Silent adoption of a decision inside an implementation PR is +> not acceptable; reviewers should be able to re-litigate the choice from the +> PR description alone. + +| # | Decision | Options | Audits' recommendation (verified) | Register entries gated | +|---|---|---|---|---| +| **D-1** | Triple-term syntax: accept-then-defer vs documented divergence | Option 4 accept (parse, lower=`not_implemented`) / Option 3 reject-on-principle / (Option 2 desugar is dominated — drop) | **Option 4** (`sparql12-wave2-triple-terms.md` §4); book Option 1 as a separate epic scored by EVAL_TRIPLE_TERMS | 27 (W2BC) now; 39 eval via the epic | +| **D-2** | `GRAPH ?g` W3C-by-default: drop the #1279 implicit default-graph enumeration | Drop implicit + keep explicit alias addressing / opt-in flag / keep as-is | **Drop implicit, keep explicit, no toggle** (`named-graph-dataset.md` §3); 2-reviewer sign-off; **3** pinning tests updated (§1.1-3) | 14 (PR-G1) + downstream subquery04 | +| **D-3** | Within-ledger datasets | Option A engine / Option B harness-ledger-per-graph | **Option A** (`named-graph-dataset.md` §2) — product value, reuse, single-snapshot perf; verified mechanically sound | 13 (PR-G2) | +| **D-4** | Reject-more parser behavior changes (V1–V6) shipping as hard errors | Hard error / warning-under-flag / defer | **Hard error + changelog** (`parser-syntax-validation.md` §5), with the added requirement that errors **prevent AST production** (API diagnostic-swallowing hole) | 37 (PR-2 + PR-3) | +| **D-5** | Remote (non-SILENT) LOAD | Documented divergence / opt-in fetch hook | **Documented divergence** — zero W3C cost, no HTTP client in transact (`update-completeness.md` §2 A) | LOAD subset of PR-U3 | +| **D-6** | DROP≡CLEAR observability + empty-named-graph model (one decision, three consumers) | DROP≡CLEAR now + empty graphs as divergence / build registry-remove + enumerable empty graphs | **DROP≡CLEAR now** (harness-indistinguishable); empty-graph question must be decided **once** — `update-completeness.md` recommends permanent divergence, `named-graph-dataset.md` open-Q2 wants a product call; today's DROP≡CLEAR design bakes in the divergence answer | PR-U3 shape; bindings#graph + agg-empty-group-count-graph (−2 only if enumerable) | +| **D-7** | Base-direction representation | A first-class field (storage bump) / B encode in lang string / C defer+divergence | **B if in product scope, else C** (`sparql12-wave1.md` §5); B requires JSON-LD `@direction` in the same PR | 10 (LANG_BASEDIR) | +| **D-8** | TriG parser ownership | New `fluree-graph-turtle` GRAPH-block parsing / route harness qt:data through the existing `parse_trig_phase1` builder path | **Try the builder-path routing first** (verified capability exists, `trig_meta.rs:1199`); new parser work only for star-inside-TriG | 5 eval-triple-terms (inside the 39) | +| **D-9** | Entailment owl2rl PRAGMA harness injection | Named-subset pragma variant / leave all 50 registered | **Yes, named subset** starting rdfs03/04/09 (verified green end-to-end with the pragma, `residual-eval.md` §6); RIF/OWL-DL never winnable | −3 firm + candidates of the 50 | +| **D-10** | Multi-op guard fast-track + Txn IR model | (a) fast-track a loud trailing-token error before PR-U2; (b) sequential `Vec` in one commit vs N commits vs new `UpdateRequest` IR | **(a) yes — this is silent data loss in production** (§5 issue 1); (b) one atomic commit (recommended by `update-completeness.md`); resolve the IR shape **before** PR-U2 — it ripples to JSON-LD/Cypher lowering and `pre_built_txn` | PR-U2's 9 + unblocks 8 (with U3) | +| **D-11** | D6 lexical-form preservation vs documented value-matching divergence | Lexeme column (ingest+storage change) / divergence register | Split decision, do not block PR-X1/X2 (`expression-semantics.md` §5); note **group01 and parts of RDF11 hang on the same call** — one decision, two docs currently assume different outcomes | 8 expr + 1 group01 | +| **D-12** | SPARQL strict-type-error mode vs unified strictness across surfaces | Per-surface flag / one strict path | Needed for D2b/D5/D11 (`expression-semantics.md` open-Q2); a flag needs an explicit carve-out against compliance §Query Surface Parity **first** (the guideline's JSON-LD tests would otherwise assert behavior the flag disables) | Scoping of PR-X1 (D2b arm) + PR-X2 | + +--- + +## 4b. Wave 0 — implementation status (2026-07-06, updated post-implementation) + +All six Wave-0 PRs are implemented, review-accepted, and sitting on local +branches stacked on this coverage branch (not pushed until PR #1437 merges): + +| Branch | Scope | Net register delta | Notes | +|---|---|---|---| +| `burndown/pr-h1` | harness `.rdf`/SRX self-closing-element fix (+`rdf:nodeID`), register-comment batch, parent-audit corrections | −3 | mechanism verified as the §1.1-6 corrected direction | +| `burndown/pr-1` | P1/P4/P5a/P5b/P6/P7/P8 + codepoint pre-pass, collections guarded out of quoted-triple contexts | −32 (33 removed, `test_65` added for PR-2 V6) | see deltas below | +| `burndown/pr-l1` | bnode-dot lexer (greedy-scan + trailing-dot rewind) | −5 (json-res 4 + `owlds02`) | ABAB n=100 benches within budget | +| `burndown/pr-l2` | canonical `xsd:double` across all six RDF-lexical sites | −1 | `ryu` dep dropped; changelog note in description | +| `burndown/pr-x1` | D1/D2/D2b/D3/D9/D10/#1319, per-defect commits each suite-green | −37 (39 removed, `tP-29/30` added) | D-12 transparency section in description | +| `burndown/pr-pp` | pp16 zero-length universe (full terms incl. literals), pp36 batch guard, new `query_hot_property_path` bench + budget | −2 | slower closure numbers = spec-mandated larger output on previously-wrong shapes | + +Combined: ≈−80 register entries once merged (integration re-baseline at merge +time is authoritative). Merge order: PR #1437 → PR-H1 (owns the comment batch) +→ siblings in any order (trivial register-file textual conflicts against H1). + +**Wave 1 — implementation status (2026-07-07):** all four PRs implemented, +review-accepted, on local branches (merge after the Wave-0 set): + +| Branch | Scope | Net register delta | Notes | +|---|---|---|---| +| `burndown/pr-2` | V3-V6 validation passes + 1.2 checks (9 commits, walkers written from scratch) | −28 | V5 forced into the parser (group-simplification AST ambiguity); D-4 fallout: `GROUP BY (expr)`+reprojection (the #1362 shape = W3C agg08) now rejected — alias the key; SPARQL grouped-list extension (= group06) removed from SPARQL, kept on JSON-LD with parity pins | +| `burndown/pr-3` | V1 dot structure, V2 FILTER constraint, **API seam fix** (error diagnostics authoritative even when recovery yields an AST), EOF/trailing-token assertion (D-10a, #1438 guard, verified end-to-end) | −15 | unmasked and fixed 5 "green-by-recovery" tests; systemic caveat: every future reject-more PR will unmask more | +| `burndown/pr-u1` | GRAPH in DELETE WHERE via Modify lowering; bnode-in-DELETE rejection (validator+lowering+JSON-LD surface, `_:fdb-` exempt) | −19 | JSON-LD delete-template rejection is a surface behavior change — in the migration note | +| `burndown/pr-w2a` | RDF 1.2 reifier forms → `Pattern::EdgeAnnotation`; deferred positions lower to clean `not_implemented` (D-1) | −61 | STACKED ON `burndown/pr-1`; includes the 3 SPARQL12_VERSION entries | + +Combined Waves 0+1 ≈ −200 register entries (integration re-baseline at merge +time is authoritative; expect ~340 remaining). Cross-PR coordination for +mergers: `syntax-order-07` (pr-1 ∩ pr-3, byte-identical hunk) and +`syntax-update-anonreifier-02` (pr-2 ∩ pr-3, two independent defenses) are +double-claimed — whichever lands second drops its stale removal hunk (the +both-way CI gate enforces this mechanically). Full merge order: +PR #1437 → pr-h1 → {pr-1, pr-l1, pr-l2, pr-x1, pr-pp, pr-2, pr-3, pr-u1} in +any order → pr-w2a (needs pr-1). + +**Wave 2 — implementation status (2026-07-07):** all four PRs implemented and +review-accepted; the ninth cluster audit (`algebra-serialization.md`) delivered: + +| Branch | Scope | Net register delta | Notes | +|---|---|---|---| +| `burndown/pr-base` | RFC 3986 resolver extracted to `fluree_vocab::iri`; all constant-IRI BASE resolution at lowering time; IRI()/URI() constant fold; `resolve_dataset_clause()` ready for PR-G2; harness supplies the query-document BASE | −6 | pp34 + exists03 greened on this branch ALONE (audit had predicted joint); variable-argument IRI() deliberately not base-resolved (documented, no W3C test) | +| `burndown/pr-g1` | D-2 semantics change: `?g` as IRI, no implicit default-graph enumeration; seed-when-produced + join-at-merge (the cluster doc's unconditional seeding REGRESSED graph-variable-scope — deviation documented); EncodedSid arms + `GraphVarCorrelated` EXISTS fallback (Semijoin keys never match across encodings) | −14 | three pinning tests updated; #1317 not claimed; eq-graph-1/2/4 + pp35 second-lander comments | +| `burndown/pr-u2` | multi-op UPDATE: `UpdateRequest` with per-op prologue snapshots; `Vec` sequential staging via virtual commit, last-wins fold, ONE atomic commit (D-10b); real cross-op bnode validation + validator-owned annotation check replaced the guard-carried greens | −9 | STACKED ON pr-3; insert-05a + 3 same-bnode re-pointed at PR-U3 (verb-gated); benches noise-level incl. transact_commit | +| `burndown/pr-w15` | Turtle-star asserting forms → `to_reifies_facts_jsonld_compatible` in FlakeSink + ImportSink; fresh reifier per anonymous occurrence; base-triple assertion recorded as deliberate divergence (Option-1 epic owns spec-exact semantics) | −2 | eval-triple-terms re-baselined: 15 wave-2 syntax / 10 D-1 data / 5 serialization / 5 TriG / 2 harness expected-graph / 2 misc; benches within budget (6-round interleave) | + +**Ninth-audit corrections (binding):** W-1's fix territory is parser/lowerer + +`subquery.rs` + `optional.rs`, NOT `where_plan.rs` — the land-PR-X1-first gate +is void; quotes-3/4 are the D5b datatype-drop (move to PR-X2), not +serialization; all three optional-complex tests are GRAPH-gated (PR-G1 to +re-verify post-merge); construct-3/4 + constructlist fall to one per-solution +bnode-instantiation fix. New slate items: **PR-W1** (FILTER-scope + subquery +correlation, low-med), **PR-W2** (CONSTRUCT bnode instantiation, SPARQL-only), +**PR-W1-OPT** (OPTIONAL independent-scope semantics — hot-path risky, +deferrable, double-bench-gated). + +Running ledger: Waves 0+1 ≈ −200, Wave 2 ≈ −31 → **~307 registered entries +remain pre-integration** (of which 63 not-applicable + ~47 entailment). +Remaining big movers: PR-U3 (~91), PR-X2 (~40), PR-W2BC (27), PR-G2 (13), +PR-W1/W2 (~5). Merge order addendum: pr-u2 lands after pr-3; pr-w2a after +pr-1; BASE↔G1 joint entries resolve at whichever lands second. + +**Post-implementation corrections to this roadmap:** + +- **`basic#list-1..4` do NOT green via PR-1** (contra §2's PR-1 row): Turtle + *ingest* stores object-position collections as Fluree `list_index` items and + drops `()` objects entirely (`parse_collection_as_list`, + fluree-graph-turtle), so the `rdf:first/rest` triples never exist in + storage. This is a new, owned-by-no-one ingest/model gap — **decision D-13**: + materialize first/rest at ingest vs translate at query time vs documented + divergence. Register comments updated in PR-1. +- **`subquery12` is resolved by PR-1** (bare-`{SELECT}` misparse executing + through error recovery) — remove it from PR-X2's scope; its probe item is + closed, and it further evidences the diagnostic-swallowing hole PR-3 must + fix. +- **PR-W2A's expected shrink adjusts 63 → 61**: PR-1's P8 greened + `annotation-(anon)reifier-07` ×2. +- **PR-X2's scope gains `tP-29/30`**: PR-X1's D2 fix unmasked the D4 + promotion defect underneath (expected dynamics; entries registered with + that comment). +- **Entailment enforced-green set is now 21** (`owlds02` was a bnode-dot + data-load casualty, not an entailment gap); `sparqldl-03` re-pointed to + result-mismatch. +- **PR-X1's D1 mechanism** differed from the cluster doc: the fix landed in + `reorder_patterns` front-hoisting plus `Batch::new` zero-column length + inference (plan-time; var-full paths byte-identical). +- **Bench-corpus gap**: the `insert_formats`/`import_bulk` corpora contain no + `_:` tokens, so lexer changes to blank-node handling are invisible to the + gates — add bnode-heavy content when the bench backlog is next touched. + +--- + +## 5. Production bugs to file as GitHub issues NOW + +Independent of W3C work; all confirmed end-to-end through public surfaces. + +1. **SPARQL UPDATE: multi-operation (`;`) requests silently execute only the + first operation.** `parse_sparql` returns after one operation with no + trailing-token check (`fluree-db-sparql/src/parse/query/mod.rs:40-74, + 193-207`); `parse_and_lower_sparql_update` lowers the single body. A request + like `INSERT …; DELETE …` commits the INSERT and silently discards + everything after the first `;` — verified through + `graph().transact().sparql_update().commit()` with readback. This is silent + data loss for any client sending standard multi-op SPARQL UPDATE. Interim + mitigation (D-10a): reject requests with trailing tokens loudly; full fix is + request-level sequential staging (PR-U2). +2. **Variable-free FILTER eliminates every solution.** `FILTER(true)`, + `FILTER(1=1)`, `FILTER(2 IN (1,2,3))` return zero rows through the public + query API; the same filter with any variable reference works. Root cause: + `required_vars = referenced_vars()` is empty, so the filter is "eligible" + before any triple binds and is inlined against the seed row + (`fluree-db-query/src/execute/where_plan.rs:697, :990`). Silent wrong + results; planner-only fix (evaluate constant filters once per stream). +3. **DATATYPE()/LANG() reject any non-variable argument.** + `eval/rdf.rs:58-62` bails unless the argument is a bare `Expression::Var`; + `LANG` (`eval/string.rs:115-134`) silently returns `""` for expression + arguments. `FILTER(datatype(?a + ?b) = xsd:integer)` and + `datatype("foo"@en)` fail; 29 W3C tests and any user composing expressions + inside these builtins are affected. Fix: evaluate the argument to a value, + read its datatype/lang, type-error on non-literals (SPARQL-scoped per + D-12 — the `@id` extension is deliberate on the JSON-LD surface). +4. **UPDATE `USING` + explicit `GRAPH` over-deletes from the default graph.** + `DELETE {…} USING WHERE { GRAPH {…} }` deletes rows it must not + (dawg-delete-using-02a: expected 5 triples remain, got 3). Single-operation, + verified; the explicit GRAPH block is polluted by USING scoping + (`fluree-db-transact/src/stage.rs:1382-1399` region). Data loss in a + supported UPDATE form. +5. **`GRAPH ?g` binds the graph name as an `xsd:string` literal and enumerates + the default graph.** `graph.rs:314-323` builds `Binding::Lit{String}` where + an IRI term is required; `graph.rs:686-711` appends the ledger alias to the + unbound-`?g` fan-out (deliberate #1279 extension, W3C-breaking). File as the + tracking issue for the D-2 behavior change; note three tests pin the current + behavior (incl. `it_upsert_duplicate_ids_repro.rs:228`). +6. **Bound `GRAPH ?g` silently returns nothing when `?g` is a late-materialized + `EncodedSid`.** `extract_graph_iri_from_binding` (`graph.rs:157-172`) has no + `EncodedSid`/`EncodedLit` arm; manifests inside `FILTER EXISTS { GRAPH ?g + {…} }` where the ref-valued row is dropped while string-valued rows survive + (verified probe). Top-level shapes work today, so the bug hides in + correlated/EXISTS contexts. +7. **Turtle import fails on `_:label.` (blank-node label followed by the + statement dot).** `parse_blank_node_name` + (`fluree-graph-turtle/src/lex/lexer.rs:477-490`) greedily consumes the dot + then hard-errors ("unexpected character '_'"), rejecting valid Turtle that + every other store ingests. Fix design must use trailing-dot rewind so + `_:a..b` (valid, currently accepted) keeps lexing (§1.1-9). +8. **`xsd:double` serialization is non-canonical and internally inconsistent.** + SPARQL-JSON/XML render Rust `Display` (`"1000000"`, + `"1000000000000000000000000000000"`), native CSV/TSV renders ryu + (`"1000000.0"`), N-Triples/N-Quads export renders `{:E}` (`"1E6"` — missing + the mandatory mantissa dot). Canonical is `"1.0E6"`. One helper, all sites + (PR-L2); changelog note for downstream string-matchers. +9. **`fluree-db-sparql` fails to compile with `--no-default-features`.** + `ast/term.rs:91` and `parse/query/term.rs:1174` reference `fluree_vocab` + unconditionally, but it is optional behind the `lowering` feature. Any + parse-only (Lambda/WASM) consumer is broken today. +10. **Numeric aggregates silently skip non-numeric group members.** `AVG`/`SUM` + over a group containing a bnode/IRI/string return a value computed over the + numeric members (`aggregate.rs:737-755`, `binding_to_numeric` → `None`); + SPARQL requires a type error → unbound. Silent wrong aggregates on mixed + data. + +Also: **comment on #1319** with the verified mechanism (pattern objects carry +no datatype constraint; `dt_compatible` is asymmetric and not consulted on this +path — do not "fix" by extending it), and **comment on #1317** that it is a +distinct defect class from the GRAPH ?g enumeration (registry g_id +assignment/indexed-read routing under multi-level indexes; suspect +`context.rs:981-985` silent `unwrap_or(binary_g_id)` fallback) and must not be +closed against PR-G1. + +--- + +## 6. Register hygiene + +### 6.1 Register-comment corrections + reassignments (apply as a batch in PR-H1; each owning PR re-verifies on landing) + +| Entries | Correction / movement | +|---|---| +| SPARQL11_UPDATE: insert-05a, insert-data-same-bnode, insert-where-same-bnode(2) | Comment "INSERT into not-yet-existing named graph silently loses triples" is **wrong** → class B multi-op `;` truncation (only first op runs). Owner: PR-U2 (+U3) | +| SPARQL11_UPDATE: dawg-delete-insert-01c | "Combined DELETE/INSERT WHERE applies inserts without deletes" is **wrong** (single combined op passes) → class B multi-op. Owner: PR-U2 | +| SPARQL11_SYNTAX_UPDATE_1: test_36 | Not graph-management grammar → class D (validator rejects GRAPH in DELETE WHERE). Owner: PR-U1 | +| SPARQL11_SYNTAX_UPDATE_1: test_38/39/40 | Not graph-management grammar → class C (empty/prologue-only request). Owner: PR-U2 | +| SPARQL11_SYNTAX_UPDATE_1: test_54 | → class B + cross-op bnode-scope validation. Owner: PR-U2 | +| SPARQL12_VERSION: version-01/02/05 | "VERSION declaration support" is **wrong** — VERSION already lexes/parses; failures are bare `<< >>` patterns. Owner: PR-W2A (−3 in that PR's math) | +| SPARQL11_PROPERTY_PATH: pp34, pp35 | Not path-cardinality → graph-cluster tests (constant-GRAPH-IRI base-expansion vs exact-key registry miss + `?g`-as-literal). Owner: PR-BASE + PR-G1 | +| SPARQL11_PROPERTY_PATH: pp16 | Comment: zero-length closure **node-universe completeness** (non-path-predicate + literal nodes), not multiplicity. Owner: PR-PP | +| SPARQL11_PROPERTY_PATH: pp36 | Comment: empty-schema `Batch::new` len-collapse **inside PropertyPathOperator**, not projection. Owner: PR-PP | +| SPARQL11_EXISTS: exists03 | → graph cluster (base resolution + EXISTS active-graph inheritance). Owner: PR-BASE + PR-G1 (conditional) | +| SPARQL11_SUBQUERY: subquery02 / subquery04 | → graph cluster (A4 correlation / A3 default-graph leak). Owner: PR-G1 | +| SPARQL11_SUBQUERY: subquery12 | → expression cluster (CONSTRUCT ↔ sub-SELECT alias visibility; repro-first). Owner: PR-X2 | +| SPARQL11_AGGREGATES: agg02, agg-err-01, agg-count-rows-distinct | → expression/aggregate cluster; agg02 comment must note the fix site is **unconfirmed** (not the group_aggregate finalize). Owner: PR-X2 | +| SPARQL11_AGGREGATES: agg-empty-group-count-graph | → graph + empty-named-graph decision (D-6); expected to remain registered after PR-G1 | +| SPARQL11_CONSTRUCT: constructlist | "Query execution error" is **wrong** → parse-time rejection today; after PR-1 it becomes the CONSTRUCT-template bnode-instantiation gap. Owner: **W-2**, stays registered through PR-1 | +| SPARQL11_CONSTRUCT: constructwhere04 | Comment correct (FROM on single-ledger). Owner: PR-G2 | +| SPARQL10_QUERY_EVAL: basic#list-1..4 | → parser cluster P1 (collection triple silently dropped today because the API swallows parse diagnostics). Owner: PR-1 | +| SPARQL10_QUERY_EVAL: basic#base-prefix-1/2/5 | → PR-BASE (relative-IRI/BASE resolution at lower time); base-prefix-1's lexer half in PR-1 | +| SPARQL10_QUERY_EVAL: sort#dawg-sort-3/6/8 | → harness `.rdf` DAWG parser (`Event::Empty` state-stack skew — engine output verified correct). Owner: PR-H1 | +| SPARQL10_QUERY_EVAL: algebra/filter-nested-2, join-combo-2, join-scope-1, nested-opt-1/2, optional-complex-2/3/4, optional-filter-005 | → **W-1 algebra cluster** (no doc yet; complex-2 also gated on PR-G1) | +| SPARQL10_QUERY_EVAL: construct-3/4, quotes-3/4 | → **W-2 serialization cluster** (no doc yet) | +| SPARQL10_QUERY_EVAL: expr-equals#eq-graph-1/2/4 | → joint PR-G1 (GRAPH-var) + PR-X2 (D5); second lander removes | +| SPARQL11_CSV_TSV: csv03 | Comment correct. Owner: PR-L2 | +| SPARQL12_RDF11: langstring-datatype, plain-string-datatype | Same D2 constant-argument defect → PR-X1 removes both clusters' entries in one fix | +| SPARQL12_EVAL_TRIPLE_TERMS: graphs-1/2, expr-1, update-1/2 | Additionally blocked on TriG GRAPH parsing (orthogonal to star; D-8); update-3 blocked on harness expected-graph TriG-star parsing (not data ingest); only pattern-3/pattern-3-nomatch green on wave-1 ingest — re-point the other 39 at wave-2 query syntax / D3 values / functions / serialization / CONSTRUCT / TriG | +| SPARQL11_BINDINGS: graph | Stays after PR-G1; gated on D-6 (empty-graph enumerability) | + +**Standing rule:** the 31 syntax-update-1 tests are registered twice +(`SPARQL11_SYNTAX_UPDATE_1` + inside `SPARQL11_UPDATE`) — every UPDATE fix PR +deletes **both** register lines per test or the both-directions check fails. + +### 6.2 Harness fixes discovered (land in PR-H1 unless noted) + +- `.rdf` DAWG result parser: handle `Event::Empty` for self-closing + `rs:value rdf:resource/nodeID` elements in `parse_rdf_dawg_result_set` + (`result_format.rs:809`, `:835`); greens sort-3/6/8. +- Audit the same `Start|Empty` push-without-pop pattern in the SRX parser + (`result_format.rs:413`) before trusting more `.srx`-based categories. +- Query-base injection: the harness prepends `@base` to **data** TriG + (`query_handler.rs:532-538`) but passes query text with no base — PR-BASE + needs the query's base URI plumbed (harness- or engine-side; decide in + PR-BASE design). +- Dataset tests (PR-G2): pre-load `FROM`/`FROM NAMED`-referenced files as named + graphs from the parsed dataset clause. +- TriG qt:data routing through the `parse_trig_phase1` builder path (D-8). +- Entailment pragma-injection harness variant (D-9, PR-ENT). +- Eval-triple-terms expected-result handling: TriG-star expected graphs + + `"type":"triple"` result values (Option-1 epic scope). + +### 6.3 Parent-audit corrections (annotate `2026-07-sparql-testsuite-audit.md`) + +§4.2's "INSERT into a not-yet-existing named graph silently loses the triples" +and "Combined DELETE/INSERT WHERE applies inserts without the deletes" findings +are refuted (both are class-B multi-op truncation); §4.2 item 7 still lists +pp06 as failing (it passes; already deregistered); the §1 headline split is +63 NA / 541 gaps, not 67/538. Also amend +`docs/contributing/sparql-compliance.md` § "Where to add parity tests" with an +UPDATE/transact row (`it_transact_update.rs`, `it_named_graphs.rs`) — PR-U1. + +--- + +## 7. Definition of done (every PR) + +1. **Register shrink, both directions:** remove exactly the entries the PR + greens (both copies for duplicated update tests; cross-register removals + coordinated where §2 notes them — PR-1↔W2A, PR-G1↔PR-X2, PR-BASE↔PR-G1). + CI's unexpected-pass/stale-entry policing is the enforcement. +2. **JSON-LD parity per compliance §Query Surface Parity:** classify the fix + (IR/engine vs surface-syntax vs SPARQL-only), author the named JSON-LD + regression tests in the same PR (`it_query.rs` / `it_query_analytical.rs` / + `it_query_grouping.rs` / `it_transact_update.rs` / `it_named_graphs.rs`), + or record the surface-only decision in the PR description. Cypher excluded. +3. **Bench gates, no waivers:** `query_hot_bsbm`, `query_hot_bsbm_bi`, + `insert_formats`, `import_bulk` within `regression-budget.json` on every PR; + plus the PR-specific gates in §2 (`query_hot_property_path` for PR-PP; + import benches primary for PR-L1/PR-W15; both query benches every commit for + PR-X2). Nightly bench workflow is the backstop. +4. **Behavior changes carry a changelog/migration note:** PR-2/PR-3 + (reject-more), PR-G1 (GRAPH ?g semantics), PR-L2 (double serialization), + PR-X2 (equality/EBV strictness), PR-U2 (trailing-token errors), PR-U3 + (new verbs + DROP≡CLEAR divergence note), documented-divergence register + comments where a decision resolves to divergence (D-5, D-6, D-7-C, D-11). +5. **Verification hygiene:** full W3C suite + `cargo test -p fluree-db-sparql` + + the JSON-LD groups (`grp_query`, `grp_query_sparql`, `grp_transact`, + `grp_graphsource` as applicable); PRs touching pinned extensions update the + pinning tests named in §2; PRs with investigation items (agg02, subquery12, + D5b) include the probe result in the PR description before the fix commit. +6. **Decision transparency (AJ, 2026-07-06):** if the PR actions any D-1…D-12 + decision, the PR description must present the decision point, all options + considered, and the rationale for the adopted option (link §4). See the §4 + status note. diff --git a/docs/audit/burn-down/algebra-serialization.md b/docs/audit/burn-down/algebra-serialization.md new file mode 100644 index 0000000000..da9b09f1ce --- /dev/null +++ b/docs/audit/burn-down/algebra-serialization.md @@ -0,0 +1,453 @@ +# Algebra/OPTIONAL-scope + Output/Serialization — Deep Audit (burn-down W-1/W-2) + +**Cluster owner deliverable — pre-implementation deep audit. No source was +modified (only this document).** This is the ninth cluster doc, owning the 14 +register entries the Stage-2 cross-check found unowned (ROADMAP §2 work-streams +**W-1** algebra/OPTIONAL-scope (9) and **W-2** output/serialization (5)). Parent +context: `docs/audit/burn-down/ROADMAP.md` §2/§3/§6.1; the reassignment origin +in `docs/audit/burn-down/expression-semantics.md` §6; perf discipline in +`docs/audit/2026-07-sparql-testsuite-audit.md` §6; parity rules in +`docs/contributing/sparql-compliance.md` § Query Surface Parity. + +**Baseline:** branch `test/sparql-testsuite-full-coverage` @ `0b84e6e24` (= +current HEAD = branch tip). rdf-tests submodule `efccbc6b8`. **Every** root +cause below was reproduced against the live engine — either through the W3C +harness (`W3C_REPORT_JSON` capture of the `sparql10_query_eval_tests` and +`sparql11_construct` suites) or by driving `target/debug/fluree query` on +fresh in-memory ledgers loaded with each test's own `.ttl`. Actual output +quoted below is real engine output. + +**PR-X1 composition note.** The brief mandated designing against PR-X1's landed +D1 changes (`git diff 0b84e6e24..burndown/pr-x1 -- fluree-db-query`). I read +that diff. D1 lives entirely in `planner.rs::reorder_patterns` (pins var-free +FILTERs / BNODE-binds after all preceding `produced_vars`, `:1286-1326`) and +`filter.rs` (a zero-column batch carries its row count via +`empty_schema_with_len`, `:64-72`, `:594-599`). **None of the W-1 root causes +is in that machinery** (see §0.1 correction C1). The two touch different +compilation stages and compose without conflict; §2 states the one place D1's +`filter.rs` fix strengthens the general case. + +--- + +## 0. Executive summary + +The 14 entries are **not two clusters with one root cause each** — they are +**six** distinct defects. The register/roadmap groupings are partly wrong; the +five corrections below are load-bearing and change PR ownership. + +| Family | Tests | Root cause | Owner | +|---|---|---|---| +| **A. FILTER/BIND scope leak** | filter-nested-2, dawg-optional-filter-005 | parser unwraps single-child `{ }` groups (`pattern.rs:228`) + lowerer flattens a group's own FILTER/BIND, so the scope boundary is lost and the FILTER sees an enclosing-scope variable | **PR-W1** (this cluster) | +| **B. Subquery merge of an optionally-produced correlation var** | join-scope-1 | `subquery.rs::self_produced_vars` (`:694`) excludes OPTIONAL-bound vars, so a correlation var bound only via OPTIONAL is not a join key; the merge keeps the parent's value and silently drops the subquery's conflicting binding | **PR-W1** (this cluster) | +| **C. Correlated-OPTIONAL executor** | nested-opt-1, nested-opt-2 | OPTIONAL is executed as a per-row **correlated** left-join (`optional.rs`, `SeedOperator`); SPARQL requires the body evaluated as an independent scope then left-joined. Divergence appears when the body rebinds a shared variable (through a nested OPTIONAL, C-1) or when a shared var appears in both the left and a later sibling OPTIONAL body (C-2) | **PR-W1-OPT** (separate, higher-risk) | +| **D. GRAPH-var dependent** | join-combo-2, dawg-optional-complex-2/3/4 | all four use a `GRAPH ?var` block; blocked by the GRAPH-var-as-literal + default-graph-enumeration defects | **PR-G1** (verify after) | +| **E. CONSTRUCT template blank nodes** | construct-3, construct-4, constructlist | template bnodes (`[ ]`, `_:a`, and collection first/rest) lower to `_:`-named `Var`s the WHERE never binds; `construct.rs::instantiate_row` (`:104`) skips any triple with an unbound term → empty graph | **PR-W2** (this cluster) | +| **F. Pattern-object datatype-drop (NOT escaping)** | quotes-3, quotes-4 | a plain-string pattern object matches a `:someType`-typed literal of the same lexeme and vice-versa — the scan-path datatype-drop of open-eq-02 / D5b, not string-escape serialization | **PR-X2** (reclassified out) | + +### 0.1 Corrections to the roadmap / register (Stage-2 discipline) + +- **C1 — W-1's fix territory is NOT `where_plan.rs` filter partitioning.** + ROADMAP §2 W-1 and §3's dependency map assert W-1 shares "the same + `where_plan.rs` filter machinery PR-X1's D1 touches" and therefore must land + after PR-X1. Refuted empirically and by code read: the FILTER-scope fix is in + the **parser** (`parse/query/pattern.rs:228`) and **lowerer** + (`lower/pattern.rs`); the join-scope fix is in `subquery.rs`; the nested-opt + fix is in `optional.rs`. `where_plan.rs::partition_eligible_filters` is not + implicated in any of the nine. **W-1 has no dependency on PR-X1** and is + parallel-safe with it (they touch disjoint files and stages). The "land PR-X1 + first" sequencing note for W-1 is void. + +- **C2 — quotes-3/4 are datatype-matching, not "string-escape serialization".** + ROADMAP §2 W-2 and `expression-semantics.md` §6 file quotes-3/4 as + "string-escape serialization". Refuted: the triple-quote/newline escaping + works (both queries produce a result); the bug is that a triple-pattern + **object** ignores the datatype for unknown-datatype string literals — + identical to `open-eq-02` (D5b). They belong in **PR-X2's D5b carve-out**, not + W-2. This removes 2 entries from W-2 (W-2 is 3 CONSTRUCT tests, not 5). + +- **C3 — W-1 is four defects, not one "FILTER-scope" cluster.** Only + filter-nested-2 and dawg-optional-filter-005 are the FILTER-scope class named + by `expression-semantics.md` §6. join-scope-1 is a subquery-merge bug, + nested-opt-1/2 are OPTIONAL-executor bugs, and four are GRAPH-var-gated. + +- **C4 — all three `optional-complex-*` tests are GRAPH-var-dependent, not just + complex-2.** `expression-semantics.md` §6 flags only complex-2 as using + `GRAPH ?x`. In fact complex-2 and complex-3 both use `GRAPH ?x { [] … }` and + complex-4 uses `GRAPH ?g { [] … }` inside an OPTIONAL. All three are gated on + PR-G1 (register comment at `mod.rs:492` should be widened). + +- **C5 — construct-3/4 need generic per-solution bnode instantiation, not + "reification-vocabulary" handling.** The register/roadmap frame construct-3/4 + as "reification output" (distinct from constructlist's "bnode instantiation"). + Empirically construct-3 (`[ … ]`), construct-4 (`_:a`), and constructlist + (collection `( … )`) fail for the **same** reason (template bnode → never-bound + var → dropped). One fix greens all three (constructlist additionally needs + PR-1's collection desugaring). No reification-specific code is required. + +--- + +## 1. Per-test root cause (with file:line evidence + empirical reproduction) + +### Family A — FILTER/BIND scope leak + +The IR `Pattern` enum has **no group variant** (`ir/pattern.rs:297-366`): +`Filter`, `Optional`, `Union`, `Bind`, … are siblings in a flat `Vec`. +Group scope is reconstructed only by the lowerer wrapping *nested-Group +children* of a plain Group as uncorrelated subqueries +(`lower/pattern.rs:47-71`). Two holes in that reconstruction leak scope. + +**filter-nested-2** — `SELECT ?v { :x :p ?v . { FILTER(?v = 1) } }`, data +`:x :p 1,2,3,4`. Expected **0**; actual **1** (`{v:1}`). +- SPARQL algebra §18.2.2: the inner `{ FILTER(?v=1) }` = `Filter(?v=1, Z)` over + the empty-pattern solution `Z`, where `?v` is **unbound** → EBV type error → + false → 0 solutions; `Join(BGP(:x :p ?v), ∅) = ∅`. +- Mechanism: the inner group has one element, so the parser's single-pattern + simplification returns the bare `Filter` **unwrapped** + (`parse/query/pattern.rs:228-232`), never a `Group`. The outer group's + children become `[Bgp, Filter]`; the lowerer's Group arm flattens both (only + *Group* children get the subquery treatment, `lower/pattern.rs:49`), so the + FILTER lands in the same scope as `:x :p ?v` and sees `?v` bound → keeps + `v=1`. The sibling `filter-nested-1` (`{ :x :p ?v . FILTER(?v = 1) }`, filter + *in* the same group) is **not** registered — it correctly returns `{v:1}`, so + the defect is precisely the lost `{ }` boundary. +- Reproduced (CLI): `{ :x :p ?v . { FILTER(?v=1) } }` → 1 row; the equivalent + multi-pattern nested group `{ :x :p ?v . { ?y :q ?w . FILTER(?v=1) } }` + (which *does* stay a Group → subquery) → **0 rows** (correct). This proves the + existing uncorrelated-subquery path already drops rows when a FILTER + references an unbound var — the fix only has to route the single-element case + through it. + +**dawg-optional-filter-005-not-simplified** — +`{ ?book dc:title ?title . OPTIONAL { { ?book x:price ?price . FILTER(?title = "TITLE 2") } } }`. +Expected **3** rows, all title-only; actual 3 rows but one carries `price=20`. +- SPARQL: the FILTER sits in the doubly-nested group `{ ?book x:price ?price . + FILTER(?title=…) }` where `?title` is **not** locally bound; evaluated as an + independent scope, `FILTER(unbound = "TITLE 2")` errors → false → the group is + empty → the OPTIONAL adds nothing → no row gets a price. +- Mechanism: `OPTIONAL { { … } }` — the OPTIONAL's outer group has one child + (the inner Group), so the parser unwraps it (`pattern.rs:228`), making the + inner `Group([triple, filter])` the OPTIONAL's direct pattern. Lowering the + OPTIONAL (`lower/pattern.rs:74-79`) lowers that Group via the Group arm, which + flattens its FILTER child into the OPTIONAL body. Fluree's OPTIONAL is a + **correlated** left-join (§Family C), so the flattened FILTER is evaluated + against the joined row where `?title` **is** bound → keeps price for TITLE 2. +- Reproduced (harness report): actual row `[1] {title:"TITLE 2", price:20}` + where expected is `{title:"TITLE 2"}`. + +### Family B — subquery merge of an optionally-produced correlation var + +**join-scope-1** (`var-scope-join-1.rq`) — +`SELECT * { ?X :name "paul" { ?Y :name "george" . OPTIONAL { ?X :email ?Z } } }`. +Expected **0**; actual **2** (`{X:B1(paul), Y:B3, Z:john}`, `{X:B1, Y:B3, +Z:ringo}`). +- SPARQL: the inner group produces `{Y:B3, X:B2, Z:john}`, `{Y:B3, X:B4, + Z:ringo}` (X/Z from the OPTIONAL over email triples, B2/B4); `Join` with the + outer `?X :name "paul"` (X=B1) on `?X` → B1∉{B2,B4} → 0. +- Mechanism: the inner group is multi-pattern → wrapped as an uncorrelated + subquery projecting `{Y,X,Z}` (`collect_bound_variables` descends into + OPTIONAL, `lower/pattern.rs:340-342`). At `subquery.rs:119-124` + `correlation_vars = {X}` (X ∈ parent schema ∩ subquery SELECT). But + `join_keys` is filtered to vars in `self_produced_vars` + (`subquery.rs:150-155`), and `self_produced_vars` (`:694-708`) counts only + `Triple`/`PropertyPath`/non-sliced `Subquery` — **it does not descend into + `Optional`/`Union`/`Bind`**. So X (bound only by the OPTIONAL) is a + correlation var but **not** a join key; the merge appends the subquery's + *new* vars `{Y,Z}` to the parent row and never checks X, keeping the parent's + X=B1 while dropping the subquery's conflicting X=B2/B4. +- Isolation probes (CLI, var-scope data): + `{ ?X :name "paul" { ?Y :name "george" . ?X :email ?Z } }` (X via a **required + triple** inside) → **0** (X is self-produced → join key → correct); + join-scope-1 (X via OPTIONAL) → **2** (wrong). The OPTIONAL is the trigger. + +### Family C — correlated-OPTIONAL executor (highest risk) + +Fluree's OPTIONAL is built **per required row**, seeding the optional side with +that row's bindings (`optional.rs:10` "the optional side is built per-row", +`:42` `SeedOperator`, `:60-92` `OptionalBuilder`). This equals the SPARQL +left-join for *well-designed* patterns but diverges when the body rebinds a +shared variable. + +**nested-opt-1** (`two-nested-opt.rq`) — +`{ :x1 :p ?v . OPTIONAL { :x3 :q ?w . OPTIONAL { :x2 :p ?v } } }`, data +`:x1 :p 1 . :x2 :p 2 . :x3 :q 3,4`. Expected **1** (`{v:1}`, w unbound); actual +**2** (`{v:1,w:3}`,`{v:1,w:4}`). +- SPARQL: the outer OPTIONAL body evaluated **independently** = + `LeftJoin(:x3 :q ?w, :x2 :p ?v)` = `{w:3,v:2}`,`{w:4,v:2}`; outer + `LeftJoin({v:1}, …)` on `?v` → 1≠2 → keep `{v:1}` (w discarded). +- Mechanism: correlated seeding pins `?v=1` into the body, so the inner + `OPTIONAL { :x2 :p ?v }` looks for `:x2 :p 1` (absent), binds nothing, and the + outer optional keeps `w=3/4`. The independent evaluation would have bound + `v=2`, forcing the mismatch that drops `w`. + +**nested-opt-2** (`two-nested-opt-alt.rq`) — +`{ :x1 :p ?v . OPTIONAL { :x3 :q ?w } OPTIONAL { :x3 :q ?w . :x2 :p ?v } }`. +Expected **2** (`{v:1,w:3}`,`{v:1,w:4}`); actual **1** (`{v:1}`, w unbound). +- Isolation probes: the first OPTIONAL alone → `{v:1,w:3}`,`{v:1,w:4}` (correct); + the second OPTIONAL alone → `{v:1}` (correct); **both together → `{v:1}`** — + the second OPTIONAL, seeded with `w=3/4` from the first, fails its + `:x2 :p ?v` match and returns a row with `?w` **unbound**, clobbering the + `w=3/4` the first optional had bound. This is a distinct left-join merge + defect: a shared variable (`?w` appears in the left *and* the failing + optional's body) is reset to unbound on optional-miss instead of preserving + the left value. + +### Family D — GRAPH-var dependent (gated on PR-G1) + +All four use a `GRAPH ?var` block and fail on the GRAPH-var-as-literal + +default-graph-enumeration defects documented in `named-graph-dataset.md` §1/§3 +(BUG-2/BUG-5) and `residual-eval.md` §3.1 (A1-A4). `burndown/pr-g1` is an empty +placeholder at `0b84e6e24` (no engine work landed yet), so a graph-fixed +build cannot be run; these are classified, not reproduced-post-fix. + +- **join-combo-2** — `{ GRAPH ?g { ?x ?p 1 } { ?x :p ?y } UNION { ?p a ?z } }`. + Expected 1, actual 4. The `GRAPH ?g` block mis-binds `?x` (as a literal, and + the default graph is enumerated) → spurious `?x` rows feed the UNION join. +- **dawg-optional-complex-2** — `GRAPH ?x { [] foaf:name ?name; foaf:nick ?nick }` + + OPTIONAL/UNION. Expected 2, actual 6. +- **dawg-optional-complex-3** — `GRAPH ?x { [] … }` + nested OPTIONAL. Expected 2, + actual 6. +- **dawg-optional-complex-4** — `GRAPH ?g { [] … }` **inside** an OPTIONAL. + Expected 5, actual 5 but non-isomorphic bindings. + +Acceptance: after PR-G1 lands, **re-run all four**; complex-3/4 (which combine +GRAPH with nested OPTIONAL) may retain a Family-C residual and must be verified, +not assumed green. + +### Family E — CONSTRUCT template blank nodes + +**construct-3** (`[ rdf:subject ?s ; rdf:predicate ?p ; rdf:object ?o ]`), +**construct-4** (`_:a rdf:subject ?s ; …`): expected **24** triples, actual +**0**. **constructlist** (`CONSTRUCT { (?s ?o) :prop ?p }`): today a parse error +("RDF collection (list) syntax is not yet supported"); after PR-1 desugars the +collection to `_:l0 rdf:first ?s ; rdf:rest _:l1 . _:l1 rdf:first ?o ; rdf:rest +rdf:nil . _:l0 :prop ?p`, it hits the same bnode gap. +- Mechanism: `Term`/`Ref` have **no blank-node variant** (`ir/triple.rs:18-30`, + `:117-129`) — a template bnode lowers to a `Var` whose name keeps the `_:` + prefix (`lower/term.rs:75-93`: `_:a` → `vars.get_or_insert("_:a")`, `[ ]` → + `vars.get_or_insert("_:b{n}")`). That var is not produced by the WHERE, so + `instantiate_row` (`format/construct.rs:97-109`) resolves it to `None` + (`resolve_subject_term`, `:157` Unbound / `:173` absent) and the guard at + `:104` (`let (Some(s),Some(p),Some(o)) = … else { continue }`) **skips every + template triple** → empty graph. +- Reproduced (CLI): a bound-subject template `{ ?s :tagged :Yes }` emits + triples; the bnode-subject template `{ [ rdf:subject ?s ; rdf:object ?o ] }` + yields `@graph: []`. The output path already emits blank nodes for `_:`- + prefixed bindings (`construct.rs:143-155`) — only per-solution *instantiation* + is missing. + +### Family F — pattern-object datatype-drop (reclassified to PR-X2) + +**quotes-3** (`?x ?p '''x\ny'''`, plain string) and **quotes-4** (`?x ?p +"""x\ny"""^^:someType`, typed), data `:x2 :p2 "x\ny"`, `:x3 :p3 +"x\ny"^^:someType`. quotes-3 expects only `x2`, quotes-4 only `x3`; both queries +return **both `x2` and `x3`** (reproduced via CLI). The `x\ny` triple-quote +value parses fine on both sides — the escaping is correct. The defect is that +the triple-pattern object match ignores the datatype for unknown-datatype string +literals: plain `"x\ny"` matches `"x\ny"^^:someType` and vice-versa. This is +byte-for-byte the `open-eq-02` scan-path defect (**D5b**, +`expression-semantics.md` §1 D5b, register `mod.rs:263`). **Move quotes-3/4 to +PR-X2's D5b carve-out**; they are removed by the same per-flake datatype +constraint fix and need no W-2 work. + +--- + +## 2. Fix designs (composing with D1) + +### PR-W1 — group scope & correlation (Families A + B; greens 3) + +**A. Preserve the scope boundary for scope-sensitive single-child groups.** +Root the fix in the parser, because once `pattern.rs:228` unwraps the group the +boundary is unrecoverable. Change the simplification so a single-child group is +returned bare **only when the child is not scope-sensitive**; keep it as a +`Group` when the child is a `Filter`, `Bind`, or a nested `Group`: + +``` +// parse/query/pattern.rs (~:228) — sketch +if patterns.len() == 1 && !is_scope_sensitive(&patterns[0]) { + Some(patterns.remove(0)) // Triple/Optional/Union/Path/… : safe to unwrap +} else { + Some(GraphPattern::group(patterns, span)) // Filter/Bind/Group : keep the boundary +} +``` + +Then the **existing** lowerer path (`lower/pattern.rs:49-65`) wraps that Group +child as an uncorrelated subquery — the mechanism already proven correct for +multi-pattern groups. Relax the `debug_assert!(… inner.len() >= 2 …)` +(`lower/pattern.rs:34-45`) to admit single-element FILTER/BIND groups, and +update its comment. This one change fixes **both** filter-nested-2 (single +`{ FILTER }`) and dawg-optional-filter-005 (the `OPTIONAL { { … } }` double +brace now keeps the inner Group, which the OPTIONAL-body lowering wraps as a +subquery, so the FILTER is evaluated in an independent scope where `?title` is +unbound). + +*Why this is correct against the algebra:* wrapping `{ FILTER(?v=1) }` as an +uncorrelated subquery with body `[Filter(?v=1)]` and `select = {}` yields +`Filter(?v=1, Z)` — the filter runs over the unit solution with `?v` unbound → +0 rows → cross-join with the parent → 0. Verified: `{ { FILTER(?v=1) } }` → 0 +today, and the multi-pattern analogue → 0. + +*Composition with D1:* independent — the change is parse/lower-time; D1 is +`planner.rs`/`filter.rs`. For the **target tests** (`?v=1`, `?title="TITLE 2"`, +both var-referencing) the fix works with or without D1. D1's `filter.rs` +zero-column-batch fix (`:64-72`) additionally guarantees the *general* case +`{ FILTER(true) }`-as-subquery keeps its unit row instead of the pre-D1 +row-wipe (#1439); land W-1 on top of PR-X1 to inherit that (no hard dependency, +but it removes a latent regression for constant-filter nested groups). + +**B. Reconcile optionally-produced correlation vars in the subquery merge.** +In `subquery.rs`, a correlation var that the subquery *may* bind (via +OPTIONAL/UNION/BIND) but is not a hash `join_key` must still be checked at merge +time: for each subquery row where that var is bound to a value **≠** the parent +binding, reject the row; where it is unbound in the subquery row, keep the +parent binding (unbound-compatible natural join, SPARQL §18.4). Concretely, +partition `correlation_vars` into `join_keys` (self-produced — unchanged hash +path) and `reconcile_vars` (produced only via OPTIONAL/UNION — the set +`self_produced_vars` currently omits), and add an equality check on +`reconcile_vars` in the per-row merge. `self_produced_vars` (`:694`) stays as-is +for the hash-key decision (an optional var is not a safe hash key); the new +reconciliation is a post-merge filter, not a key. + +*Composition with D1:* none — different file. + +### PR-W1-OPT — correlated-OPTIONAL independence (Family C; greens 2; higher risk) + +nested-opt-1/2 require the OPTIONAL body to be evaluated as an **independent +scope** when it is *not well-designed* w.r.t. the outer scope. Selected at +**prepare time** (never per-row): a body is non-well-designed iff a variable +bound in the outer/required scope is also (re)bound inside a **nested** +OPTIONAL/UNION of the body (nested-opt-1), or a variable appears in both the +left of the left-join and a later sibling OPTIONAL body (nested-opt-2). For +well-designed bodies (the overwhelming common case, incl. BSBM Q3) keep the fast +correlated `SeedOperator` path **byte-identical**. For the flagged shapes, route +the body through the uncorrelated-subquery evaluation (independent, then +left-join on shared vars) — the same primitive PR-W1 uses. nested-opt-2 also +needs the left-join merge to **preserve** a left-bound shared variable when the +optional misses (don't reset `?w` to unbound). This is the deepest, hottest +change in the cluster; isolate it so PR-W1 stays low-risk and bisectable. + +### PR-W2 — per-solution CONSTRUCT bnode instantiation (Family E; greens 2 now + constructlist after PR-1) + +1. **Record template blank-node vars.** At CONSTRUCT lowering + (`lower/construct.rs::lower_construct_template`), collect the set of template + vars that originated as blank nodes (identifiable today by the `_:` name + prefix the lowerer already assigns, `lower/term.rs:87/:91`) into a new + `ConstructTemplate.bnode_vars: HashSet` (`ir/query.rs:31-41`). + Recording it explicitly beats re-sniffing names in the formatter. +2. **Instantiate per solution row.** In `instantiate_row` + (`format/construct.rs:89-112`), before resolving terms, build a per-row map + `bnode_var → BlankId`, minting a fresh label per (var, row) — same label for + the same template bnode within one row (so `[ rdf:subject ?s ; rdf:object ?o + ]` links both triples to one node) and distinct across rows (a running + counter). `resolve_subject_term`/`resolve_object_term` consult this map when + the `Ref::Var` is a template bnode var, instead of `batch.get`. The output + side already renders `IrTerm::BlankNode` (`construct.rs:144/:152`), so only + the source of the label is new. +3. **constructlist** additionally depends on **PR-1** (collection desugaring in + `parse/query/term.rs`); its first/rest bnodes then flow through the same + instantiation. Keep constructlist registered until *both* land (ROADMAP + §1.1-1 already requires this). + +*Composition with D1:* none — CONSTRUCT output path. + +### Reclassifications (no W-1/W-2 code) + +- **quotes-3/4 → PR-X2** (D5b scan-path datatype constraint). Remove from W-2. +- **join-combo-2, optional-complex-2/3/4 → PR-G1**; re-verify complex-3/4 for a + Family-C residual afterward. + +--- + +## 3. Hot-path classification + bench gates + +| Fix | Stage | Per-row hot? | Notes / gate | +|---|---|---|---| +| **A** parser/lowerer scope boundary | parse + prepare | No | Only rare shapes (single-`{FILTER}`, `{ { } }`) now execute as uncorrelated subqueries — a mechanism already paid for multi-pattern nested groups; common BGP/OPTIONAL unchanged. Gate `query_hot_bsbm`/`query_hot_bsbm_bi` for no-op sanity. | +| **B** subquery merge reconcile | prepare (partition) + per-row (correlated subq only) | Only correlated-subquery merge | Added check runs only for `reconcile_vars` (optional-produced correlation vars — uncommon); hash `join_key` path byte-identical. Gate both BSBM benches. | +| **C** OPTIONAL independence | **prepare-time selection** + per-row (flagged shapes only) | **OPTIONAL is hot** | Highest risk. Well-designed detection is a plan-build predicate; the correlated `SeedOperator` fast path (incl. BSBM Q3) must stay byte-identical. **Gate `query_hot_bsbm` AND `query_hot_bsbm_bi` every commit**; the BI variant exercises OPTIONAL-heavy shapes. | +| **E** CONSTRUCT bnode instantiation | per-solution-row (output) | No (not scan-hot) | CONSTRUCT is not in any `regression-budget.json` bench; a per-row bnode map is a small alloc bounded by template bnode count. Standard gates only; optionally add a CONSTRUCT micro-bench when the bench backlog is next touched. | + +No fix here is *forced* onto the scan/join hot loop except C, and C is +prepare-time-selected so its common case is unchanged. Per §6, correctness must +not buy a hot-path regression: PR-W1-OPT's acceptance bar is both BSBM benches +within budget with the fast path unaltered. + +--- + +## 4. Query-surface parity (SPARQL + JSON-LD; Cypher excluded per compliance doc) + +Families A/B/C are **IR/engine-level** (lowerer, `subquery.rs`, `optional.rs` +are shared by the JSON-LD FQL surface), so they fix JSON-LD implicitly — but +nothing guards it without a test. Family E (CONSTRUCT) is a **SPARQL-only** +surface. Family F is handled by PR-X2's D5 JSON-LD tests. + +| Fix | JSON-LD expressible? | Regression test to author | +|---|---|---| +| A — nested-group FILTER scope | Yes (a `filter` inside a nested `optional`/`union` block whose vars are enclosing-scope) | `it_query.rs`: nested-scope filter must not see an enclosing var; and an OPTIONAL-with-inner-filter-referencing-outer-var must not bind (JSON-LD analogue of optional-filter-005) | +| B — subquery optional-var correlation | Yes (JSON-LD sub-select projecting an optional-bound var that equals a parent var) | `it_query.rs`: nested sub-select whose OPTIONAL-bound var equals the parent var → join reconciles (0 rows for the join-scope shape) | +| C — nested-OPTIONAL independence | Yes (nested `optional` inside `optional`; sibling `optional`s sharing a var) | `it_query.rs`: nested-optional rebinding an outer var drops the outer optional's binding; sibling optionals preserve the first's binding | +| E — CONSTRUCT bnode instantiation | **No** (JSON-LD query has no CONSTRUCT form) | SPARQL-only: `it_query_sparql.rs` bnode-template emits fresh bnodes per solution (24 triples; same bnode within a solution). Record the surface-only classification in the PR description per compliance §Query Surface Parity. If a JSON-LD "construct"/graph-projection capability is later added it must reuse this path. | + +Files: SPARQL → `fluree-db-api/tests/it_query_sparql.rs`; JSON-LD → +`it_query.rs`. Run via `grp_query` / `grp_query_sparql`. + +--- + +## 5. Blast radius, PR composition, risks, open questions + +**Blast radius.** A (parser+lowerer) is contained but touches a load-bearing +parser invariant (single-child unwrap) consumed by the lowerer's scope logic — +full parser + SPARQL/JSON-LD query suites must rerun. B is contained to +`subquery.rs`. C is the widest and hottest (`optional.rs` + left-join merge) — +every OPTIONAL query is in scope. E is contained to `format/construct.rs` + +`ir/query.rs` + `lower/construct.rs`, SPARQL-reachable only. + +**Recommended composition (two owned PRs + one carve-out):** + +- **PR-W1 — algebra scope & correlation** (Families A + B). Coherent theme + (group-scope correctness), low-medium risk, greens **filter-nested-2, + dawg-optional-filter-005, join-scope-1**. Parser + lowerer + `subquery.rs`. + Standard gates + the three JSON-LD parity tests above. **No PR-X1 dependency** + (C1); land any time, ideally after PR-X1 to inherit the `filter.rs` + constant-filter fix for the general case. +- **PR-W2 — CONSTRUCT bnode instantiation** (Family E). Low risk, SPARQL-only, + greens **construct-3, construct-4**, and **constructlist** once PR-1 lands + (keep constructlist registered until then). `format/construct.rs` + + `ir/query.rs` + `lower/construct.rs`. +- **PR-W1-OPT — correlated-OPTIONAL independence** (Family C, **separate**). + Highest risk in the cluster; greens **nested-opt-1, nested-opt-2**. Isolate + for bench sign-off and bisectability; do not bundle with PR-W1. Deferrable + with the two entries kept registered if the prepare-time + well-designed detection can't be made bench-clean. + +**Not owned here:** join-combo-2 + optional-complex-2/3/4 (PR-G1, verify after); +quotes-3/4 (PR-X2 D5b). + +**Register accounting (this cluster's 14):** PR-W1 −3, PR-W2 −2 now (+ +constructlist −1 jointly with PR-1), PR-W1-OPT −2, PR-G1 −4 (verify), PR-X2 −2 +(reclassified). Full green requires PR-1 (constructlist), PR-G1 (4), PR-X2 (2), +plus the three owned efforts. + +**Risks.** +- A changes a parser invariant; a mis-scoped unwrap predicate could wrap + benign single-triple groups as subqueries (perf) or miss a scope-sensitive + child (correctness). Enumerate the scope-sensitive set precisely + (Filter/Bind/Group) and keep Triple/Optional/Union/Path unwrapping unchanged. +- C is a behavior change for non-well-designed OPTIONAL patterns; some users may + rely on the current (wrong) correlated results. Changelog note. +- B: confirm the reconcile-vars post-filter does not double-count when a var is + *both* a hash key and optional-produced (it can't be, by the partition, but + assert it). + +**Open questions.** +1. C's well-designed predicate: is "shared var rebound inside a nested + OPTIONAL/UNION of the body" the exact necessary-and-sufficient trigger, or + are there sibling-scope cases (nested-opt-2) needing a broader rule? Settle + by enumerating the DAWG algebra tests against the predicate before coding. +2. E: should Fluree ever expose a JSON-LD graph-projection ("construct") form? + If not, record CONSTRUCT as permanently SPARQL-only surface (no parity + obligation) — recommended. +3. E edge case (not in the test set): a blank-node label used in **both** the + WHERE and the template lowers to one `_:`-named var today, conflating + template-scoped and WHERE-scoped bnodes (SPARQL keeps them separate). Flag as + a latent follow-up; the per-solution instantiation should key on + template-only bnode vars to avoid regressing WHERE bnodes. diff --git a/docs/audit/burn-down/expression-semantics.md b/docs/audit/burn-down/expression-semantics.md new file mode 100644 index 0000000000..893c6498c9 --- /dev/null +++ b/docs/audit/burn-down/expression-semantics.md @@ -0,0 +1,534 @@ +# Burn-down: SPARQL expression & value semantics + +**Cluster owner deliverable — pre-implementation deep audit. No source was +modified.** Parent audit: `docs/audit/2026-07-sparql-testsuite-audit.md` +(§4.2.2, §6 — the perf section is binding here). Register: +`testsuite-sparql/tests/registers/mod.rs` (`SPARQL10_QUERY_EVAL`, +`SPARQL11_FUNCTIONS`, `SPARQL11_CSV_TSV`). Baseline rdf-tests submodule +`efccbc6b8`. Spec references are to *SPARQL 1.1 Query Language* +(https://www.w3.org/TR/sparql11-query/) and, for numeric promotion, *XPath 2.0 +/ XQuery F&O* (op:numeric-*). + +**Every root cause below was reproduced against the live engine** by driving the +`run-w3c-test` subprocess binary on individual tests (and on ~10 throwaway +probe queries), so the "actual" behaviour quoted is real engine output, not +inference. Where a defect only surfaces once an upstream defect is fixed, that +is stated. + +## 0. Scope and headline + +This is the largest, most perf-sensitive cluster (~110 register entries across +17 categories). It decomposes into **13 engine defects plus GH #1319**, and +**~28 register entries that are not expression-semantics bugs at all** (harness +result-parsing, the algebra/OPTIONAL-scope cluster, CONSTRUCT/serialization, +the GRAPH-var-as-literal issue) and should be reassigned — see §6. + +The two highest-leverage findings, both cheap and both surprising: + +1. **`DATATYPE()`/`LANG()` reject any non-variable argument** (they `bail` unless + `args[0]` is a bare `Expression::Var`). Every one of the 22 `type-promotion` + tests and all 7 `cast` tests is a `FILTER(datatype() = xsd:T)` — so + **one 3-line guard fails 29 tests** (D2). +2. **A variable-free (constant) top-level `FILTER` eliminates every solution.** + `FILTER(true)`, `FILTER(1<2)`, `FILTER(2 IN (1,2,3))`, `FILTER(2 NOT IN ())` + all return zero rows; the moment the filter references one variable it works. + This — not `IN` semantics — is why `in01`/`notin01`/`dawg-boolean-literal` + fail (D1). + +| ID | Defect | Tests | Locus | Time class | +|---|---|---|---|---| +| **D1** | Variable-free `FILTER` drops all rows | in01, notin01, dawg-boolean-literal (3) | `execute/where_plan.rs` filter placement | prepare-time | +| **D2** | `DATATYPE`/`LANG` reject expression args | 22 type-promotion + 7 cast (29) | `eval/rdf.rs:58`, `eval/string.rs:115` | per-row (cheap) | +| **D2b** | `DATATYPE`/`LANG` of non-literal → value, not type error | dawg-datatype-2, dawg-lang-1/2/3 (4) | `eval/rdf.rs:90`, `eval/string.rs:133` | per-row | +| **D3** | `xsd:dateTime()`/`date()`/`time()` cast unsupported | cast-dT (1) | `lower/expression.rs:307`, `eval/dispatch.rs:221` | parse+per-row | +| **D4** | Numeric promotion → wrong result datatype (float→double; double∘decimal→decimal) | tP-03/04/05/21, all expr-ops (~11) | `eval/value.rs:78-252`, `eval/cast.rs` | per-row | +| **D5** | `=`/`!=`/`<`/`>` drop datatype; unknown/incompatible datatypes compare by string; `!=` returns true not error | eq-4, eq-2-1/2-2, eq-dateTime, open-eq-04/05/06/07/08/10/11/12, date-1 (~14) | `eval.rs:147`+`eval/value.rs:593`, `eval/compare.rs:341-415` | per-row (hot) | +| **D6** | Original lexical form not preserved (values canonicalized on ingest) | open-eq-01, dawg-str-1/2, distinct-1/9, sameTerm-simple/eq/not-eq (8) | storage/ingest (`fluree-db-core`, turtle/json-ld) | ingest-time (deep) | +| **D7** | `sameTerm` / `IN` / `NOT IN` use structural (datatype-dropped) equality | sameTerm-* (w/ D6); IN/NOT IN latent | `eval/rdf.rs:146`, `eval/logical.rs:96,130` | per-row | +| **D-EBV** | EBV of a bare variable too permissive (only bool-false is falsy) | dawg-bev-1..6 (6) | `eval.rs:62` → `binding.rs:799-825`; `eval/value.rs:300` | per-row (hot) | +| **D8** | `IRI()`/`URI()` don't resolve relative IRIs against base | iri01 (1) | `eval/rdf.rs:237-263` | prepare-time (base) | +| **D9** | `BNODE(str)` identity global, not per-solution | bnode01 (1) | `eval/rdf.rs:278-296` | per-row | +| **D10** | `REGEX` `"q"` (literal) flag unsupported | regex-no-metacharacters(-ci) (2) | `eval/helpers.rs:109-129` | prepare-time (compile) | +| **D11** | `CONCAT` coerces numeric arg to string, no type error | concat02 (1) | `eval/string.rs:300`, `eval/value.rs:405` | per-row | +| **D12** | Lang-tag not lowercased; STRLANG on non-string not erroring; lang-literal output omits `rdf:langString` | strlang03-rdf11, dawg-langMatches-1/2/3/4/basic (6) | `eval/string.rs:619`, result serialization / `result_comparison.rs` | per-row + harness | +| **#1319** | VALUES/inline bare integer tagged `xsd:long`, storage uses `xsd:integer` | latent; term-identity contexts | `parse/lower.rs:528`, `lower/term.rs:455` | lowering-time | + +--- + +## 1. Per-defect root cause, evidence, and spec + +### D1 — a variable-free top-level FILTER eliminates all solutions +`ASK {}` → **true** (correct, one empty solution). `ASK { FILTER(1 = 1) }` → +**false**. `ASK { ?s ?p ?o . FILTER(1 = 1) }` (with data) → **false**. Probe +matrix (real output): + +| filter | references a var? | result | +|---|---|---| +| `FILTER(?s = ?s)` | yes | pass (true) | +| `FILTER(?s = ?s && 1 = 1)` | yes | pass (true) | +| `FILTER(isIRI(?s) && 2 IN (1,2,3))` | yes | pass (true) | +| `FILTER(true)` / `FILTER(1 < 2)` / `FILTER(1 = 1)` | no | **fail (false)** | + +`notin01` = `ASK { FILTER(2 NOT IN ()) }` is unconditionally true (empty list); +its returning false is *only* explicable by the FILTER over the (single, empty) +group solution yielding no passing row — i.e. the variable-free filter is +mis-placed/mis-applied so it removes every row instead of passing them. A +`FilterPattern`'s `required_vars` is `expr.referenced_vars()` (`where_plan.rs:697`); +for a constant expression that set is empty, so `required_vars.is_subset(bound)` +in `partition_eligible_filters` (`where_plan.rs:990`) is trivially true — the +filter is marked "ready" *before any triple is bound* and inlined against the +seed row, which drops the block. The correct behaviour: evaluate a constant +filter once and keep/drop the whole stream. +Tests: `in01`, `notin01` (`SPARQL11_FUNCTIONS`); `dawg-boolean-literal` +(`SPARQL10_QUERY_EVAL/boolean-effective-value`, got 0 vs 1). Spec §18.6 (FILTER). + +### D2 — DATATYPE()/LANG() reject non-variable arguments (29 tests) +`eval/rdf.rs:58-62`: +```rust +let Expression::Var(var_id) = &args[0] else { + return Err(QueryError::InvalidExpression("DATATYPE requires a variable argument"...)); +}; +``` +`eval/string.rs:115-134` (`LANG`) is the same shape — only a `Binding` fetched +from a bare `Var` is inspected; the catch-all returns `""`. Every +`type-promotion` query is `ASK { … FILTER(datatype(?l + ?r) = xsd:T) }` and every +`cast` query is `SELECT ?s { … FILTER(datatype(xsd:T(?v)) = xsd:T) }` — the +argument is an *arithmetic expression* / *cast call*, not a variable, so +`DATATYPE` errors, the FILTER demotes to false (`error.rs:114` `can_demote_in_expression`), +and the ASK/SELECT returns false/empty. Verified: `tP-short-short` → `Expected +true, Actual false`; `cast-int` → `Expected 1, got 0`. +Tests: `type-promotion-01..22` (22) + `cast-{str,flt,dbl,dec,int,dT,bool}` (7). +Spec §17.4.2.1 (`LANG`), §17.4.2.3 (`DATATYPE`) — the argument is any expression +evaluating to an RDF term. + +### D2b — DATATYPE()/LANG() of an IRI/blank node returns a value, not a type error +`eval/rdf.rs:90-92`: `DATATYPE` of a `Sid`/`Iri` returns the Fluree +`id_type` extension. `eval/string.rs:133`: `LANG` catch-all returns `""`. +`dawg-datatype-2` (`FILTER(datatype(?v) != )`, data-builtin-2 += 5 literals + 1 IRI + 1 bnode) → **Expected 5, Actual 7**: the IRI (x6) and +blank node (x7) wrongly pass because `datatype()` gives them a value instead of +raising a type error (which would exclude them). `dawg-lang-1` is identical (7 +vs 5). Spec: `DATATYPE`/`LANG` require a literal argument; a non-literal is a +type error. Note the `id_type` result is a deliberate JSON-LD-surface extension +— the fix must be SPARQL-scoped, not a blanket removal (see §5 open questions). +Tests: `dawg-datatype-2`, `dawg-lang-1`, `dawg-lang-2`, `dawg-lang-3`. + +### D3 — xsd:dateTime()/date()/time() casts unsupported +`lower/expression.rs:307-322` maps only `BOOLEAN/INTEGER/FLOAT/DOUBLE/DECIMAL/ +STRING`; `xsd:dateTime` falls to `Function::Custom(iri)` → `eval/dispatch.rs:221` +"Unknown function" error. `cast-dT` = `FILTER(datatype(xsd:dateTime(?v)) = +xsd:dateTime)` → **Expected 1, got 0** (blocked by both D3 and D2). Spec §17.5 +(XSD constructor functions). Cheap: one lowering arm + one `eval/cast.rs` arm +reusing the existing dateTime parser. + +### D4 — numeric promotion produces the wrong result datatype +Two sub-defects, both proven by `expr-ops` (which SELECTs the typed result) and +by `type-promotion` once D2 is fixed: + +- **D4a — `xsd:float` has no value representation; float arithmetic yields + `xsd:double`.** `xsd:float` casts are stored as a `TypedLiteral{String}` + (`eval/cast.rs:129-193`) and `coerce_numeric_operand` (`eval/value.rs:340-366`) + turns any float operand into `ComparableValue::Double`; `ArithmeticOp::apply` + (`eval/value.rs:78-252`) has no float arm, so results re-tag as `xsd:double`. + `unplus-2` (data-numbers): expected `+("3"^^xsd:float)` = `"3"^^xsd:float`; + **actual `"3"^^xsd:double`**. +- **D4b — `Double ∘ Decimal → Decimal`** (`eval/value.rs:234-248`) instead of + `Double`. XPath makes `xsd:double` the widest type. `add-numbers-cast`: + expected `decimal + double = double`; **actual `= decimal`**. + +Tests (post-D2): `type-promotion-03` (double-decimal, D4b), +`-04`/`-05`/`-21` (float, D4a); `expr-ops/{add,subtract,multiply,divide}-numbers-cast`, +`unplus-2`, `unminus-2`, `add-literals`. Spec: XPath op:numeric-* type +promotion; SPARQL §17.4 operator mapping. + +### D5 — value comparison drops the datatype; incompatible datatypes compare by string +`eval_to_comparable` for a `Binding::Lit { val, .. }` (`eval.rs:147`) converts via +`TryFrom<&FlakeValue>` (`eval/value.rs:593-624`), which maps +`FlakeValue::String → ComparableValue::String` and **never receives the binding's +`dtc`** (datatype/lang). So `"zzz"^^:myType`, `"zzz"^^xsd:string`, `"zzz"@en` and +plain `"zzz"` all collapse to `String("zzz")`, and `CompareOp`/`cmp_values` +(`eval/compare.rs:309-415`) compares them as equal. + +- `eq-4` (`FILTER(?v = "zzz")`) → **Expected 1 (xp1 plain), Actual 2** — + `"zzz"^^:myType` (unknown datatype) wrongly equals `"zzz"`. Spec: comparing + literals of different/unknown datatypes is a **type error**, not `true`. +- `open-eq-07` (`FILTER(?v1 = ?v2)`, data-2) → **Expected 12, Actual 38** — all + `xyz` variants (plain / xsd:string / xsd:integer / :unknown / @en / @EN) + compare equal. Spec: plain = xsd:string (value-equal), `@en`/`@EN` equal + (case-insensitive), a term equals itself reflexively even if ill-typed, but + cross-type incomparable pairs are type errors. +- Also: for the ordering operators `cmp_values` returns `None` (incomparable) → + `<`/`>` raise `TypeMismatch` (demoted to false), but `!=` returns **true** for + incomparable operands (`eval/compare.rs:341-355`); per spec an incompatible + `!=` is a type error (row excluded), not true. +- `eq-dateTime` and `date-1` show the same shape plus timezone handling + (`date-1`: `"2006-08-23"`, `"2006-08-23Z"`, `"2006-08-23+00:00"` treated as + equal — expected only the exact term). + +**D5b (scan-level variant):** `open-eq-02` (`{ ?x :p "a"^^t:type1 }`) → **Expected +1, Actual 2** — the pattern object `"a"^^t:type1` also matches `"a"^^t:type2`, +i.e. triple-pattern object matching for unknown-datatype string literals ignores +the datatype (dictionary/scan path, not the filter path). Same root class as D5 +but on the match side. Tests: `open-eq-04/05/06/07/08/10/11/12`, `eq-4`, +`eq-2-1`, `eq-2-2`, `eq-dateTime`, `date-1`. Spec §17.4.1.7 (RDFterm-equal), +§17.3 (operator mapping / type errors). + +### D6 — original lexical form is not preserved (canonicalization on ingest) +Fluree stores the parsed *value*, not the original lexeme: `"001"^^xsd:integer`, +`"01"`, `"1"` all become `FlakeValue::Long(1)`; `"1.0e0"^^xsd:double`, `"1.0"`, +`"1"` all become the same `Double`, rendered canonically as `"1"`. Consequences, +all verified: + +- `open-eq-01` (`{ ?x :p "001"^^xsd:integer }`) → **Expected 0, Actual 2** (z1 + `"1"`, z2 `"01"`): value match instead of simple-entailment term match. +- `dawg-str-1` (`FILTER(str(?v) = "1")`) → **Expected 4, Actual 7**: `STR` of + `"1.0e0"^^double` and `"1.0"^^double` returns `"1"` (canonical) instead of the + original lexeme, so they wrongly match. +- `distinct-1`/`distinct-9` (`SELECT DISTINCT ?v`, data-num) → **Expected 9, + Actual 5**: 9 distinct terms (`"01"`/`"+1"`/`"1"` integer, `"1.0"`/`"01.0"` + decimal, `"1.3e0"` float vs double, …) collapse to 5. +- `sameTerm-simple`/`-eq`/`-not-eq` → **26 vs 14**: distinct double lexemes are + `sameTerm`-equal. + +This is a **storage/ingest** property, not a query-layer bug: the flake stores +the canonical value and the datatype `Sid` separately (`fluree-db-core/src/value.rs`), +so the lexical form is gone before the query engine ever sees it. Spec: RDF 1.1 +§3.3 (literal term equality is *lexical form × datatype × lang*); SPARQL simple +entailment matching is term-based. This is the one defect in the cluster that is +**not** a localized fix (see §4/§5) — likely a mix of an original-lexical-form +column and a documented value-matching divergence for a subset. + +### D7 — sameTerm / IN / NOT IN semantics +`sameTerm` (`eval/rdf.rs:146-149`) compares two `ComparableValue`s with the +derived `PartialEq`; because D5 already dropped datatype/lang, it cannot +distinguish `"1"^^xsd:integer` from `"1"^^xsd:int` or (via D6) two double +lexemes. `IN`/`NOT IN` (`eval/logical.rs:96,130`) use the same structural `==`, +**not** the `=` operator — so no numeric promotion (`1 IN (1.0)` → false; +spec-true) and no type errors (`2 IN (3,"cat")` → false; spec-error). The IN +divergence is currently *latent* (`in01`/`notin01` fail on D1, not on IN), but +it is a real spec gap. Spec §17.4.1.8 (sameTerm), §17.4.1.9 (`IN` is defined via +`=`). + +### D-EBV — Effective Boolean Value of a bare variable is too permissive +`FILTER(?v)` routes through `Expression::eval_to_bool_uncached` +(`eval.rs:62`): `Ok(row.get(*var).is_some_and(Into::into))` — i.e. the +**binding-level** `From<&Binding> for bool` (`binding.rs:799-825`), where +`Binding::Lit { .. } => true` for every literal *except* `xsd:boolean false`. +So `"0"^^xsd:integer`, `"0.0"^^xsd:double`, `NaN`, and ill-typed literals are all +truthy. `dawg-bev-1` (`FILTER(?v)`, data-1) → **Expected 4, Actual 7**: the +numeric-zero rows (y1, y2) wrongly pass. `dawg-bev-2` (`FILTER(!?v)`) → **4 vs +1** (mirror). `dawg-bev-5`/`-6` add `OPTIONAL` so `?w` is often unbound; +`FILTER(!?w)` should be a type error (unbound), but `Unbound → false` then +`!false → true` includes them (**6 vs 1**). This is *distinct* from the +`ComparableValue` EBV (`eval/value.rs:289-303`), which *does* apply the +numeric-zero rule — the two disagree, and the bare-variable path takes the wrong +one. (The `ComparableValue` EBV has its own milder deviation: `_ => true` at +`value.rs:300` makes unmodeled typed literals / durations truthy instead of a +type error.) Tests `dawg-bev-1..6`. Spec §17.2.2 (EBV). + +### D8 — IRI()/URI() do not resolve relative IRIs against the base +`eval/rdf.rs:237-263` builds a `Sid`/`Iri` from the argument string with no base +resolution. `iri01` (`BASE SELECT (URI("uri") AS ?uri) +(IRI("iri") AS ?iri)`) → **actual `Iri("uri")`, `Iri("iri")`** vs expected +`http://example.org/uri`, `…/iri`. The base is known at parse/plan time; thread +it into the builtin. Spec §17.4.2.8 (`IRI`). + +### D9 — BNODE(str) identity is global, not per-solution +`eval/rdf.rs:278-296` hashes only the label (`_:b{hash(label)}`), so the same +argument yields the same blank node across *all* solutions. `bnode01` expects a +fresh bnode per solution but the *same* bnode for equal args *within* one +solution; actual reuses `b3e8b8c44c3ca73b7` for `"foo"` across different rows. +Fix: fold the solution/row identity into the bnode key. Spec §17.4.2.3 (`BNODE`). + +### D10 — REGEX "q" flag unsupported +`build_regex_with_flags` (`eval/helpers.rs:109-129`) errors on any flag outside +`i/m/s/x`; `regex-no-metacharacters` uses `regex(?val, "a?+*.{}()[]c", "q")` +(the XPath `q` flag = pattern is a literal string), so Fluree errors → 0 rows. +Fix: on `q`, escape the pattern (compose with `i`). Spec: SPARQL `REGEX` inherits +XPath `fn:matches` flags. Tests `regex-no-metacharacters`, `-case-insensitive`. + +### D11 — CONCAT coerces a numeric argument instead of raising a type error +`concat02` (data2 has `:s7 :str 7`, an `xsd:integer`) → **49 vs 49 rows, wrong +contents**: SPARQL `CONCAT` requires string arguments (a non-string arg → type +error → the projected `?str` is unbound), but `into_string_value` +(`eval/value.rs:405`) coerces `Long → String`, so Fluree produces `"…7"` where +the row should be empty. Same theme as D2b/D11 (builtins coerce rather than +erroring). Spec §17.4.3.x (`CONCAT`). + +### D12 — language-tag handling and langString output +- `strlang03-rdf11` (`STRLANG(?o,"en-US")`) → 16 vs 16, mismatch: expected lang + is **`en-us`** (lower-cased canonical) and non-string args (`n*` numerics, + `d*` dates) must yield *unbound*; Fluree does not lower-case the tag and/or + binds a value for non-string args. Spec: BCP47 canonical lower-casing; STRLANG + requires a string arg. +- `dawg-langMatches-1..4`/`-basic` → same row count, differ only in that the + output literal is `{lang:"en-gb", datatype:None}` where the fixture is + `{lang:"en-gb", datatype:rdf:langString}`. The `langMatches` *function* is + correct; this is a **serialization/comparison** gap — a lang-tagged literal + must carry `rdf:langString` (RDF 1.1), or `result_comparison.rs` must normalize + `None ≡ rdf:langString`. Mostly harness-side (low risk, off hot path); listed + here because the tests are in this cluster's register. + +### #1319 — VALUES/inline bare integers are tagged xsd:long +`parse/lower.rs:528` (JSON-FQL inline data) and `lower/term.rs:455` (SPARQL +`VALUES` rows) default a bare integer to `xsd:long`, while storage +(`fluree-db-transact/generate/flakes.rs:471`), the Turtle parser +(`fluree-graph-turtle/parser.rs:678`), arithmetic results +(`eval/value.rs:465`), and SPARQL *triple-pattern* lowering (`lower/term.rs:210`) +all use `xsd:integer`. Verified: `SELECT ?v { VALUES ?v { 3 } }` serializes a +datatype ≠ `xsd:integer`. **However** the scan/join path survives it — a stored +`xsd:integer 3` *does* match a VALUES `xsd:long 3` because +`fluree-db-core/src/datatypes.rs:16` `dt_compatible()` treats the integer family +as compatible in the scan post-filter (probe verified: the VALUES-join returns +the row). So #1319's real blast radius is **(a)** the datatype tag on +VALUES/inline integers in results and **(b)** term-identity contexts +(`DISTINCT`/`GROUP BY`/`sameTerm`) where `dt_compatible` is not consulted — the +same number becomes two distinct group/term keys (`group_aggregate.rs` +`MaterializedLitKey` includes the datatype). No W3C test in this register fails +*solely* on #1319, but it compounds D6/D7 and is a latent correctness bug worth +fixing at the source. + +--- + +## 2. Fix design (honoring §6: prepare-time vs per-row; fast-path preservation) + +The governing rule (§6.2): keep the same-type fast paths — integer/integer, +double/double, string/string, iri/iri — **byte-identical**, and route only the +mixed / unknown-datatype / derived cases through new slow paths chosen at +**prepare time** (per-expression, per-predicate), never as a new per-row branch +in the common case. + +**Prepare-time / parse-time (inherently safe — do these first):** + +- **D1** — filter placement. A `FilterPattern` with empty `required_vars` must be + recognized at plan build (`where_plan.rs`), evaluated **once** against the unit + row, and either kept as a no-op or turned into an empty-stream short-circuit — + not inlined into the first block's per-row path. Pure planner change; zero + per-row cost. +- **D3** — add `xsd:dateTime/date/time` to the cast lowering table + (`lower/expression.rs`) and a matching `eval/cast.rs` arm. Parse-time table + + a cast that only runs when the query uses the constructor. +- **D8** — capture the query base in the plan and pass it to `Function::Iri` + lowering/eval; relative-IRI resolution is a constant-fold when the argument is a + literal. +- **D10** — `q`-flag handling is in `build_regex_with_flags`, already behind the + thread-local compiled-regex cache (`eval/helpers.rs:84`) — compile-time only. +- **D2** — the promotion/derived-type *tables* are static; build a + `datatype → numeric-class` and a `(class,class) → result-class` table once (a + `const`/`OnceLock`), consulted only on the slow path. +- **#1319** — fix at the lowering boundary: lower a bare integer to `xsd:integer` + in `parse/lower.rs:528` and `lower/term.rs:455` (matching storage and RDF 1.1 + §"integer" → xsd:integer). This is the cheapest correct fix and removes a + term-identity discrepancy for free; the alternative (making comparison treat + long≡integer everywhere) is broader and duplicates `dt_compatible`. + +**Per-row, but structured to preserve the fast path:** + +- **D2 (DATATYPE/LANG on expressions) / D2b (type errors on non-literals)** — replace + the `Expression::Var` guard with "evaluate arg to a `Binding`/value, read its + datatype/lang, error on non-literal (SPARQL scope only)". The evaluation + already happens for expression arguments; the added cost is only for the + previously-erroring path, so no regression on today's passing queries. +- **D4** — give `ComparableValue` a first-class `Float` (or a + `Numeric{class}` tag on the numeric variants) so float stays float and + `double∘decimal→double`. Keep the four existing same-type arms untouched; add + a Float arm and correct the two mixed arms (`value.rs:234-248`). The common + integer/double fast paths are unchanged. +- **D5 / D7 (equality lattice)** — the real fix is to **stop dropping the + datatype**: `eval_to_comparable` must carry the `dtc` for string-valued typed + literals (today `TryFrom<&FlakeValue>` can't, because it never sees the + binding's `dtc` — `eval.rs:147` must pass it, producing a `TypedLiteral` for + non-`xsd:string` string literals rather than a bare `String`). Then dispatch the + comparison at **prepare time** by the expression's static type-pair when known + (constant vs constant, or a predicate whose object datatype is known from + stats), falling to a per-row "typed comparison" only for genuinely mixed + columns. The fast paths (both sides `xsd:string`/plain, both `Long`, both + `Double`, both `Sid`) stay on the current arms; only the "one side has a + non-string datatype" case takes the new lattice (numeric-family promote → + compare; same-unknown-datatype → term compare; else type error). `sameTerm` + and `IN` then reuse the datatype-aware term-equality and the `=` operator + respectively. +- **D-EBV** — fix `From<&Binding> for bool` (`binding.rs:799-825`) to apply + §17.2.2: `Boolean → b`; numeric → `≠ 0 && !NaN` (the value is already inline in + `Binding::Lit`, and `EncodedLit`'s `o_kind`/`o_key` classify numeric-zero + without a full dictionary decode); `xsd:string`/plain → non-empty; other + datatypes / unbound → type error (→ false in FILTER). Keep the `Sid`/`Iri`/ + path/rel truthy arms and the boolean fast path unchanged. This is on the FILTER + hot path, so the numeric check must stay branch-light (see §3). +- **D9** — extend the `BNODE(arg)` key with the solution's row identity + (`eval/rdf.rs:278`); per-row but trivial. +- **D11** — `CONCAT` must reject non-string args (type error) rather than calling + `into_string_value` on them; per-arg check, cheap. +- **D12** — lower-case lang tags at STRLANG/ingest; emit `rdf:langString` on + lang-literal output (or normalize in `result_comparison.rs`). Off hot path. + +**Inherently deep (not a localized fix):** + +- **D6** — preserving the original lexical form requires either storing the + lexeme alongside the value (ingest + storage + serialization change across + `fluree-db-core`, turtle/json-ld parsers) or accepting value-based matching as + a **documented divergence** for the affected subset. Recommend: split D6 out of + the burn-down, decide value-vs-term matching as a design call (mirrors the §5.3 + dataset decision), and register the un-fixed tests with a rationale rather than + block the cluster on it. D6 alone accounts for `open-eq-01`, `dawg-str-1/2`, + `distinct-1/9`, and the `sameTerm-*` over-matching. + +--- + +## 3. Hot-path risk and bench gates + +Gating benches (per `regression-budget.json`): `query_hot_bsbm` and +`query_hot_bsbm_bi` — budgets **10% tiny / 5% small / 3% medium** — cover the +FILTER/join hot paths this cluster touches. Every PR here must run both; the +nightly bench workflow is the backstop. + +| Defect | On hot path? | Risk / mitigation | +|---|---|---| +| D1, D3, D8, D10, #1319 | No (prepare/parse/compile-time) | Zero per-row cost. Not bench-sensitive. | +| D2, D2b, D11 | Cold path only | The changed branch only runs where Fluree previously *errored*; passing queries are unaffected. Negligible. | +| D4 | Warm | Adds a `Float` variant / numeric-class tag. Risk = enum size + one extra match arm. Mitigation: keep `Long`/`Double` arms first and identical; gate on `query_hot_bsbm` (BSBM filters are integer/decimal). | +| **D5/D7** | **Hot (highest risk)** | This is FILTER `=`/`<` and join residuals. Carrying the datatype into `ComparableValue` must **not** allocate on the string/plain and numeric fast paths. Mitigation: (a) keep same-type arms byte-identical; (b) choose the typed-comparison slow path at prepare time per expression, so the per-row code for an all-`xsd:string` or all-numeric column is unchanged; (c) bench `query_hot_bsbm` **and** `query_hot_bsbm_bi` (BI variant exercises typed object filters). If the datatype must be threaded per-row, estimate ~1 extra enum-tag load + branch on the *mixed* path only. | +| **D-EBV** | **Hot** | `From<&Binding> for bool` runs for every `FILTER(?v)` and every truthiness check. Adding numeric-zero/NaN tests to the `Lit` arm is a few integer comparisons on an already-in-hand value — cheap — but the `EncodedLit` arm must classify zero from `o_kind`/`o_key` **without** a dictionary decode (decoding here would be a real regression). Mitigation: decode-free zero test; gate on `query_hot_bsbm`. | +| D9, D12 | Warm/cold | BNODE/STRLANG are not in BSBM hot loops. Low risk. | +| D6 | Ingest + hot | If solved by lexeme preservation: touches import (`insert_formats`, `import_bulk` budgets) and widens the literal representation — material. This is the main reason to defer/scope D6 separately. | + +No fix in this cluster is *forced* onto the per-row path except D5/D7 and D-EBV, +and both can keep their common cases identical; there is no correctness fix here +that must accept a hot-path regression. + +--- + +## 4. JSON-LD surface parity + +*(Cypher is out of scope for this burn-down: it is openCypher — not Fluree's to +extend — and every fix here is IR/engine-level, so Cypher benefits implicitly +with no assessment required. This section covers the JSON-LD surface only.)* + +All 13 defects except D3/D8/D10 are **IR/engine-level** (value model, comparison +lattice, EBV, arithmetic, storage) and therefore fix the JSON-LD query surface +implicitly — it shares `fluree-db-query` evaluation. D3 (xsd:dateTime cast), D8 +(IRI base), D10 (regex q-flag) are surface-reachable functions; JSON-LD FQL has +`filter`/comparison/function syntax, so the analytical JSON-LD surface must get +the same capability in the same effort. + +Per `sparql-compliance.md` "Query Surface Parity", each fix is done only when the +register entry is removed **and** a JSON-LD regression test exists for the +JSON-LD-expressible behaviour. JSON-LD tests to author (in +`fluree-db-api/tests/it_query*.rs`, run via `grp_query`/`grp_query_sparql`): + +- **D2 / D2b** → `it_query.rs`: `filter` on `datatype()` and on a + cast result; `datatype`/`lang` of an `@id`/IRI value must error, not return a + value. +- **D4** → `it_query_analytical.rs`: arithmetic on mixed xsd:float/decimal/double + literals asserting the result datatype (`float+float=float`, + `double+decimal=double`). +- **D5 / D7** → `it_query.rs`: `filter` `=`/`!=` across + plain/xsd:string/unknown-datatype/lang literals (unknown-vs-known ⇒ excluded; + plain=xsd:string ⇒ included); `in`/`sameTerm` semantics. +- **D-EBV** → `it_query.rs`: `filter` on a bare numeric-zero / empty-string / + ill-typed variable, and unbound-via-optional with negation. +- **D-D1** → `it_query.rs`: a constant/variable-free `filter` (e.g. + `filter` `true` / `1 = 1`) must not drop rows. +- **D6** (if pursued) → `it_query.rs`: `DISTINCT` over `"01"`/`"1"` integer + literals; `str()` of a non-canonical double lexeme. +- **#1319** → `it_query.rs`: a `values`/inline integer grouped/DISTINCT with a + stored integer of the same value collapses to one group. + +The SPARQL-only surfaces (`sameTerm`, `IN` as text, `BNODE`, `REGEX` flags) need +no JSON-LD syntax, but their underlying IR capability should stay reachable +wherever it already is. + +--- + +## 5. Blast radius, PR decomposition, risks, open questions + +**Blast radius.** D5/D7 and D-EBV touch the two hottest evaluation paths +(`compare.rs`, `binding.rs` EBV) and the value model (`ComparableValue`, +`TryFrom`); a mistake here regresses every FILTER and join residual. +D2/D2b/D3/D8/D9/D10/D11/D12/D1/#1319 are localized (one function or one lowering +arm each) and low-radius. D6 is repo-wide (ingest + storage + serialization). + +**Suggested PR decomposition** (this cluster warrants 3, not 1): + +- **PR-A — cheap high-yield, no hot-path change (land first).** D1 (constant + filter placement), D2 + D2b (DATATYPE/LANG argument + type errors), D3 + (dateTime cast), D8 (IRI base), D10 (regex q), #1319 (bare-integer lowering). + This clears **~40 register entries** (all 22 type-promotion + all 7 cast via + D2, plus in01/notin01/boolean-literal, iri01, cast-dT, both regex, + datatype-2/lang-1/2/3) with essentially zero bench risk. Gate: `query_hot_bsbm` + as a sanity check only. +- **PR-B — the equality/EBV/promotion lattice (bench-gated).** D5 + D7 + (datatype-aware comparison, `sameTerm`/`IN`), D-EBV (bare-variable EBV), D4 + (float + double/decimal promotion), D11 (CONCAT type error), D12 (lang case / + langString output). Clears the `expr-equals`, `open-eq-04..12`, + `boolean-effective-value` (bev-1..6), `expr-ops`, most `expr-builtin`, and the + functions `concat02`/`strlang03-rdf11`. Gate: **both** `query_hot_bsbm` and + `query_hot_bsbm_bi`, every commit. +- **PR-C (design-gated) — D6 lexical-form preservation vs documented divergence.** + Decide before coding. Covers `open-eq-01`, `dawg-str-1/2`, `distinct-1/9`, + `sameTerm-*`. Likely a mix: a lexeme column for round-trip fidelity, and a + registered divergence for value-based simple-entailment matching. Do not block + A/B on this. + +**Risks.** +- D5/D7 "reject-more/differ-more": making unknown-datatype `=` a type error and + `!=` exclude will *change results* for existing non-W3C users who currently get + string-value equality across datatypes — a behaviour change, not just a + compliance fix. Flag for review. +- D-EBV changes truthiness of numeric-zero literals in existing filters — same + caveat; some user queries may rely on the current (wrong) permissive behaviour. +- D4 `ComparableValue` widening risks enum-size/perf; measure. +- D2b: the `id_type`-of-IRI result is an intentional JSON-LD extension — the type + error must be **SPARQL-scoped**, not global, or JSON-LD `datatype()` semantics + regress. + +**Open questions.** +1. D6: value-matching vs term-matching — implement lexeme preservation, or + register a documented divergence? (Same class of call as §5.3 dataset + strategy; needs a design decision + owner.) +2. D2b/D11/D5: does Fluree want a strict "SPARQL type-error" mode distinct from + the lenient JSON-LD coercion mode, or one unified strict path? The three + surfaces currently share evaluation; a per-surface strictness flag may be + needed. +3. #1319: fix at lowering (bare integer → xsd:integer) is proposed; confirm no + consumer depends on the current `xsd:long` tag for VALUES-sourced integers. +4. `!=` on incomparable operands: confirm the intended demotion (type error → + excluded) matches how Fluree wants FILTER error semantics to read for the + JSON-LD surface. + +## 6. Register entries that are NOT expression-semantics bugs (reassign) + +Verified by running each; these fail for reasons outside this cluster and should +move to the named owner so the register reflects the true root cause: + +- **Harness — `.rdf` DAWG result-set parser** (`testsuite-sparql/src/result_format.rs` + `parse_rdf_result`): `dawg-sort-3/6/8`. The `.rdf` fixture for `sort-3` has 4 + solutions but the parser returns **1** (it drops solutions containing an + unbound variable — `sort-3` has an `OPTIONAL` mbox). Fluree's sort output is + correct (4 rows, unbound-first ASC). → Phase-A / harness owner, not the engine. +- **Algebra / OPTIONAL & join variable scope**: `algebra/{filter-nested-2, + join-scope-1, join-combo-2, nested-opt-1, nested-opt-2}`, + `optional/{complex-2,3,4}`, `optional-filter/dawg-optional-filter-005`. These + are FILTER-scope (`{ :x :p ?v . { FILTER(?v = 1) } }` → expected 0, actual 1 — + Fluree lets a nested-group FILTER see an enclosing-scope variable) and + nested-OPTIONAL/join-scope semantics — the algebra cluster, not value + semantics. `optional/complex-2` additionally uses `GRAPH ?x` and is blocked by + the known GRAPH-var-bound-as-literal issue (audit §8). +- **GRAPH + equality**: `expr-equals/eq-graph-1/2/4` combine `GRAPH` with `=`; + gated on the GRAPH-var issue (algebra) plus D5. +- **CONSTRUCT/serialization**: `construct/construct-3`, `construct-4` + (`query-reif-*`, reification output) — CONSTRUCT-graph serialization, not value + semantics. +- **Parser / serialization ("basic")**: `basic/{list-1..4}` (RDF collection `( … )` + syntax in patterns — parser, overlaps the parser-syntax burn-down), + `basic/{quotes-3,quotes-4}` (string-escape serialization), `basic/{base-prefix-2, + base-prefix-5}` (relative-IRI/base resolution in output — overlaps D8). + (`base-prefix-1` is explicitly another agent's parser bug.) +- **CSV serialization**: `SPARQL11_CSV_TSV/csv03` — canonical `xsd:double` lexical + form in CSV output (audit §8); a CSV serializer fix (related to D6's canonical + form), not comparison semantics. + +Removing these from the expression cluster's accounting: of ~110 register +entries nominally in scope, **~82 are genuine expression/value-semantics engine +defects** (the 13 D-items + #1319), and **~28 are misattributed** to the six +buckets above. diff --git a/docs/audit/burn-down/lexer-formatter.md b/docs/audit/burn-down/lexer-formatter.md new file mode 100644 index 0000000000..7804e0e8c6 --- /dev/null +++ b/docs/audit/burn-down/lexer-formatter.md @@ -0,0 +1,342 @@ +# Burn-down: Turtle lexer bnode-dot + xsd:double canonical lexical form + +Pre-implementation deep audit for audit §4.2 items 5 (Turtle lexer) and 6 +(CSV double), plus the §8 finding "CSV output does not use canonical +`xsd:double` lexical form (csv03)". Covers registers `SPARQL11_JSON_RES` +(jsonres01–04) and `SPARQL11_CSV_TSV` (csv03). + +**Scope:** two independent, small, spec-correct engine fixes. No source was +modified during this audit; all claims below were verified against the code and +with throwaway probes (winnow 0.7.15 blank-node lexer replica; `rustc` f64 +formatting probes). Baseline: branch `test/sparql-testsuite-full-coverage`, +rdf-tests submodule `efccbc6b8`. + +--- + +## 1. Confirmed root causes + +### 1a. Turtle lexer — blank-node label immediately followed by `.` + +**Defect:** `fluree-graph-turtle/src/lex/lexer.rs:477-490` `parse_blank_node_name` +greedily consumes a trailing `.` into the label, notices the label ends in `.`, +and **returns a hard backtrack error** instead of leaving the dot as the +statement terminator: + +```rust +fn parse_blank_node_name<'a>(input: &mut Input<'a>) -> ModalResult<&'a str> { + let result: &str = ( + take_while(1, |c: char| is_pn_chars_u(c) || c.is_ascii_digit()), + take_while(0.., |c: char| is_pn_chars(c) || c == '.'), // <-- eats trailing '.' + ).take().parse_next(input)?; + if result.ends_with('.') { + return Err(winnow::error::ErrMode::Backtrack(ContextError::new())); // <-- gives up + } + Ok(result) +} +``` + +Called from `parse_blank_node_label` (lexer.rs:470-474). When this backtracks, +the top-level `next_token` `alt` (lexer.rs:188-213) has no other arm that can +start with `_`, so the whole lexer fails with +`unexpected character '_'`. That is exactly the reported failure on +`testsuite-sparql/rdf-tests/sparql/sparql11/json-res/data.ttl` line 10 +(`:s6 :p6 _:o6.`), which blocks the data load for all four json-res tests. + +Per the Turtle grammar, `BLANK_NODE_LABEL ::= '_:' (PN_CHARS_U | [0-9]) +((PN_CHARS | '.')* PN_CHARS)?` — interior dots are allowed but the label MUST +NOT end in a dot. So `_:o6.` is the label `o6` followed by the `.` terminator. +The bug is that the lexer treats "label technically ended in a dot" as a lexical +error rather than as "the dot wasn't part of the label." + +**Verified (standalone winnow 0.7 replica of the two functions):** + +| input after `_:` | current | fixed (below) | +|---|---|---| +| `o6.` | ERR (→ lexer error) | label `o6`, remaining `.` | +| `abc.` | ERR | label `abc`, remaining `.` | +| `a.b ` | OK `a.b` | OK `a.b` (interior dot kept) | +| `a.b.c.` | ERR | label `a.b.c`, remaining `.` | +| `b1 ` / `1 ` / `x-y ` | OK (unchanged) | OK (unchanged) | + +The correct pattern **already exists twice in the same file**: `parse_pn_local` +(lexer.rs:407-460) and `parse_prefixed_name_or_keyword` (lexer.rs:357-368) both +use a single-char dot-lookahead — consume a `.` only if the next char continues +the name, otherwise stop and leave the dot. That is why `xsd:decimal.` on line 9 +of the same data file lexes fine but `_:o6.` on line 10 does not: prefixed-name +local parsing has the lookahead, blank-node parsing does not. + +Nearby-lexer sweep (requested): **no other trailing-dot defect.** Numeric +lexing is already correct — `parse_integer` (lexer.rs:749-754) and +`parse_decimal`/`parse_double` (via the `peek`/`starts_with('.')` guards) +leave a trailing `.` as a terminator, so `5.5.` lexes as `Decimal("5.5")` + +`Dot` and `5.` as `Integer(5)` + `Dot`. The blank-node name parser is the sole +place that greedily eats the dot and then errors. The sparql10 +syntax-lists/forms/qname failures are **parser/grammar**, not lexer (collections +`( )` and blank-node property-list forms) — owned by the parser-syntax work, +not this cluster. + +### 1b. `xsd:double` non-canonical lexical form (csv03) + +**Defect:** every RDF-lexical output path renders a finite `f64` with Rust's +`f64::Display` (`d.to_string()`), which is neither the W3C canonical +`xsd:double` form nor even scientific notation: + +| site | code | +|---|---| +| `fluree-db-api/src/format/sparql.rs:413-425` `scalar_lexical` (SPARQL-JSON DOM) | `d.to_string()` | +| `fluree-db-api/src/format/sparql.rs:547-559` `format_binding` (SPARQL-JSON) | `d.to_string()` | +| `fluree-db-api/src/format/sparql_xml.rs:365-373` `write_double` (SPARQL-XML) | `d.to_string()` | +| `fluree-db-api/src/format/delimited.rs:538-541` native CSV/TSV | `ryu::Buffer::format(d)` | +| `fluree-graph-ir/src/term.rs:90-101` `LiteralValue::lexical()` | `d.to_string()` (→ Term `Display` term.rs:482, `rdf_xml.rs:169`) | + +Rust `Display` produces (probe-verified): `1000000.0 → "1000000"`, +`0.001 → "0.001"`, `1e30 → "1000000000000000000000000000000"`, +`1e-10 → "0.0000000001"`. The W3C canonical `xsd:double` form is +mantissa-in-[1,10) with a mandatory `.` and mandatory uppercase `E` exponent: +`1000000.0 → "1.0E6"`. + +**Why csv03 fails but tsv03 passes** (this is the whole mechanism, and it tells +us the fix is engine-side, not harness-side): + +`data2.ttl` stores `:s6 :p6 "1.0E6"^^xsd:double`. Fluree parses that to +`FlakeValue::Double(1000000.0)` on ingest (the original lexical string is not +retained), so all output is at the mercy of the formatter. + +- **csv03** — the harness formats actual results as SPARQL-JSON, then + `project_to_csv_space` (`result_format.rs:189-227`) **drops the datatype** + (CSV is lossy by design). Comparison in `result_comparison.rs:160-173` then + falls to a **pure lexical string compare** — the numeric-value fallback is + gated on the datatype still being present, which the CSV projection removed. + Fluree emits `"1000000"`; the expected `csvtsv03.csv` has `1.0E6`; strings + differ → fail. +- **tsv03** — TSV is not projected, so the `xsd:double` datatype survives. The + same comparison hits `numeric_values_equal(...,"double")` + (result_comparison.rs:243-255) which parses both sides to `f64` and compares + by value: `1.0e6 == 1000000` numerically → **pass**, regardless of lexical + form. + +So the defect is real and engine-side: Fluree's serialized `xsd:double` +lexical form is wrong (non-canonical). csv03 is simply the only W3C +output-format test whose comparison path is lexical rather than numeric, so +it's the one that surfaces the bug. + +--- + +## 2. Fix design — Turtle lexer (perf-neutral) + +Replace the greedy-then-error body of `parse_blank_node_name` with the same +single-char dot-lookahead loop already used by `parse_pn_local`: + +```rust +fn parse_blank_node_name<'a>(input: &mut Input<'a>) -> ModalResult<&'a str> { + ( + // first char: PN_CHARS_U | [0-9] (exactly one — take_while(1,..) is exactly-1, probe-confirmed) + take_while(1, |c: char| is_pn_chars_u(c) || c.is_ascii_digit()), + |input: &mut Input<'a>| -> ModalResult<()> { + loop { + let _: &str = take_while(0.., is_pn_chars).parse_next(input)?; + if input.starts_with('.') { + let rest = &input.as_ref()[1..]; + if rest.chars().next().is_some_and(is_pn_chars) { + '.'.parse_next(input)?; // interior dot: keep going + continue; + } + } + break; // trailing/terminator dot: leave it + } + Ok(()) + }, + ).take().map(|_| ()).parse_next(input) // span-only token; the &str name is unused downstream +} +``` + +**Exact loop/table touched:** only `parse_blank_node_name` (lexer.rs:477-490). +The char-class predicates in `fluree-graph-turtle/src/lex/chars.rs` +(`is_pn_chars`, `is_pn_chars_u`, `is_pn_chars_base`) are **untouched** — they +are already branch-light `matches!` range tables; the fix reuses them verbatim. + +**Perf-neutrality argument (no added branching on the common path):** + +- The bulk character scan stays a single `take_while(0.., is_pn_chars)`. In fact + the fix **removes** a comparison from the hot per-char predicate: the current + code scans `is_pn_chars(c) || c == '.'`; the fix scans `is_pn_chars` alone. +- The fix also **removes** the post-scan `.take()` materialization and the + `result.ends_with('.')` re-scan of the label. +- It **adds** exactly one `starts_with('.')` branch per label, executed once + (not per character). For the overwhelmingly common blank node — no interior + dot, followed by whitespace/`.`/`;`/`,` — the loop body runs exactly once and + that branch is not taken (or taken once at the terminator and immediately + breaks). Net instruction count on the common path is flat-to-slightly-lower. +- No backtracking, no regex, no allocation, no extra lookahead beyond one byte. + Behavior for every currently-valid input is byte-identical; only inputs that + previously errored (`_:x.`) now lex. +- **Bench guardrails (audit §6/§4.2.5):** blank nodes are frequent in bulk RDF + import, so run `insert_formats` and `import_bulk` (fluree-db-api/benches) under + `regression-budget.json`. Expectation: flat or marginal improvement. + +**Known residual (out of scope, and pre-existing):** consecutive interior dots +(`_:a..b`) still error, because the single-char lookahead only continues a dot +when the *immediately* following char is `PN_CHARS`. This is the identical +limitation that `parse_pn_local` already has for `ex:a..b`; no failing W3C test +exercises it, and matching the sibling parser's behavior is the +consistency-preserving choice for a perf-first codebase. Flag it in a code +comment; do not expand the lookahead. + +--- + +## 3. Fix design — canonical `xsd:double` lexical form + +**Where the canonical mapping lives:** add one shared helper (a +`canonical_xsd_double(d: f64) -> String`, plus a write-into-buffer variant for +the XML/delimited paths). Natural home: `fluree-graph-ir` next to +`LiteralValue` (so `term.rs` can use it too), re-exported for the +`fluree-db-api` formatters. Algorithm (probe-verified against all W3C forms): + +```rust +fn canonical_xsd_double(d: f64) -> String { + if d.is_nan() { return "NaN".into(); } + if d.is_infinite() { return if d.is_sign_positive() { "INF".into() } else { "-INF".into() }; } + let s = format!("{:e}", d); // Rust shortest round-trip: "1e6","2.2e0","1e-3" + let (mant, exp) = s.split_once('e').unwrap(); + let mant = if mant.contains('.') { mant.to_string() } else { format!("{mant}.0") }; + format!("{mant}E{exp}") // uppercase E, no '+', no leading zeros +} +``` + +Verified transforms: `1000000.0→1.0E6`, `1.0→1.0E0`, `2.2→2.2E0`, `0.001→1.0E-3`, +`0.0→0.0E0`, `1e30→1.0E30`, `1e-10→1.0E-10`, `123.456→1.23456E2`, +`6.02e23→6.02E23`, `-3.0→-3.0E0`; `NaN/INF/-INF` preserved. It reuses Rust's +built-in shortest-round-trip float formatter (`{:e}`), so it is correct and +allocation-light; the special-value spellings already match what all four sites +emit today. + +**Which formats change behavior (route these sites through the helper):** + +- `sparql.rs:413-425` and `sparql.rs:547-559` — **SPARQL Results JSON** + (this is the exact path csv03 exercises, since the harness formats actual + results via `FormatterConfig::sparql_json()`). +- `sparql_xml.rs:365-373` `write_double` — **SPARQL Results XML**. +- `delimited.rs:538-541` — **native CSV/TSV** (currently `ryu` → `"1000000.0"`, + also non-canonical; the harness does not hit this path, but real users do, so + fix it for consistency). +- Optionally `LiteralValue::lexical()` (term.rs) → flows to Term `Display` and + RDF/XML (`rdf_xml.rs:169`). Correct to canonicalize, but `lexical()` is a + general-purpose accessor; changing it is a slightly wider blast radius. + Recommended: canonicalize `lexical()` too (it is only consumed by Display and + RDF/XML serialization — grep-confirmed it is **not** used in query/scan/storage + hot paths, indexing, or comparison keys), so all serialized double lexical + forms are consistent. + +**Do NOT touch the JSON-LD path** (`jsonld.rs:357-365` `push_f64`, +`jsonld.rs:506-517` `json!(d)`). JSON-LD emits doubles as **native JSON +numbers**, not lexical strings; forcing a `"1.0E6"` string there would corrupt +JSON-LD number semantics. This is the key parity nuance (see §4). + +**User-visible change → changelog required.** SPARQL-JSON, SPARQL-XML, and +CSV/TSV serialization of `xsd:double` values changes from Rust-Display form +(`"1000000"`, `"0.0000000001"`, `"1000000000000000000000000000000"`) to W3C +canonical form (`"1.0E6"`, `"1.0E-10"`, `"1.0E30"`). This is spec-correct and +matches Jena / RDF4J / Oxigraph, but any downstream consumer that string-matches +Fluree's double output must be told. Add a `CHANGELOG`/compatibility note +(fixed-behavior, not breaking-grammar). JSON-LD numeric output is explicitly +unchanged. + +--- + +## 4. Query-surface parity (JSON-LD) + +Both fixes are **IR/engine-level**, not surface-syntax additions, so per +`docs/contributing/sparql-compliance.md` §"Query Surface Parity" each needs a +JSON-LD regression test authored alongside (the W3C submodule only guards the +SPARQL surface). Cypher is out of scope: Fluree does not own the openCypher +grammar and adds no custom Cypher syntax, so the burn-down does not assess +Cypher parity — JSON-LD is the only owned non-SPARQL surface here. + +**Lexer fix** — this is the shared **Turtle/TriG ingest** path +(`fluree-graph-turtle`), used by data loading for every surface, not a +query-surface feature. Tests: +- Unit (fluree-graph-turtle): `tokenize("_:o6.")` → `[BlankNodeLabel, Dot, Eof]`; + add `_:a.b` (interior dot kept) and confirm `_:o6.` label span is `_:o6`. + Co-locate with the existing `test_blank_node` (lexer.rs:927). +- Integration (fluree-db-api): `insert_turtle` of `:s :p _:o6.` round-trips — + the blank node loads and is queryable. JSON-LD has no `_:x.` surface syntax + (blank-node-dot is Turtle-specific), so the "JSON-LD equivalent" here is: + after the Turtle insert, query the same data through the **JSON-LD query + surface** and confirm the blank-node object is returned. That exercises the + shared IR/engine on ingested `_:x.`-shaped data. + +**Double fix** — the canonical lexical form is a serialization property of the +RDF-oriented output formats. Tests: +- SPARQL surface: `fluree-db-api/tests/it_query_sparql.rs` — SELECT a stored + `xsd:double` (e.g. `1.0E6`), assert SPARQL-JSON `value == "1.0E6"` and + SPARQL-XML `1.0E6`; add a CSV assertion for `1.0E6`. +- JSON-LD surface (parity, `fluree-db-api/tests/it_query.rs`): assert the SAME + double comes back as a **native JSON number** `@value` (not the canonical + string) — i.e. the JSON-LD contract is "same numeric value," and the fix must + NOT regress it into a string. This test is the guardrail that keeps the + canonicalization scoped away from `jsonld.rs`. + +Existing formatter unit tests that will need their expected strings updated to +canonical form (finite values only; NaN/INF unchanged): `sparql.rs` +`parity_double_special_and_normal` (~981) and +`test_format_binding_double_special_values` (~1121); `sparql_xml.rs` +`double_special_values` (~633); `delimited.rs` `test_tsv_boolean_and_double` +(~798). The `jsonld.rs` double tests (`test_format_binding_double` ~764, +`parity_double_special_and_large` ~890) assert **number** output and must stay +unchanged — they double as the parity guard. + +--- + +## 5. Blast radius, PR recommendation, risks + +**Blast radius — lexer:** contained to one function in `fluree-graph-turtle`. +No token-boundary change for any currently-valid input (probe-confirmed); +strictly widens what lexes. Unblocks jsonres01–04 data load. Any user Turtle/ +TriG data of the `_:x.` shape now imports. Removes 4 entries from +`SPARQL11_JSON_RES`. + +**Blast radius — double:** wider but shallow — 3-4 formatter call sites plus a +handful of existing unit-test expectations. Changes user-visible `xsd:double` +serialization in SPARQL-JSON/XML and CSV/TSV. **Does not regress other W3C +output-format tests:** for srj/srx/tsv comparisons the datatype survives and the +numeric-value fallback already makes them pass regardless of lexical form; after +the fix they pass on the exact-lexical fast path instead (strictly tighter, not +looser). Only csv03 (datatype-stripped, lexical compare) is affected — it goes +green. Removes 1 entry from `SPARQL11_CSV_TSV`. + +**Perf:** lexer fix is perf-neutral-to-better on the import hot path (§2); double +fix is output-serialization only (not query/scan/join hot path), a few extra +instructions per double cell, no named hot-path bench covers it. Run +`insert_formats`/`import_bulk` for the lexer; the double change needs no bench +beyond the standard suite. + +**PR recommendation:** these are Phase B engine fixes (audit §7: B4 = Turtle +lexer bnode-dot; the double item is the §8 csv03 finding). They are logically +independent of each other and of the parser-syntax gaps (B3), but all three are +small, off-hot-path, and bench-guarded the same way. Recommended packaging: + +- **Fold the lexer bnode-dot fix into the parser-syntax PR** (B3+B4 together): + both are `fluree-graph-*` grammar/lexing corrections, share the + `insert_formats`/`import_bulk` bench gate, and each drops a small register + block. Shrinks `SPARQL11_JSON_RES` (−4) in the same change. +- **Keep the double-canonicalization as its own small PR** (or a clearly + separate commit): it touches the `fluree-db-api` format layer and + `fluree-graph-ir`, carries a user-visible behavior change + changelog note, + and its review concern (JSON-LD must stay numeric; downstream double + consumers) is different from grammar review. Shrinks `SPARQL11_CSV_TSV` (−1). + +Each PR must remove the register entries it fixes in the same change (CI's +stale-skip detection enforces this) and add the JSON-LD parity tests from §4. + +**Risks / watch-items:** +1. Scoping the double fix away from `jsonld.rs` is load-bearing — a naive + "canonicalize all double sites" sweep would break JSON-LD number output. + The parity test in §4 is the guard. +2. If `LiteralValue::lexical()` is canonicalized, re-confirm no + storage/index/comparison consumer depends on the Display form (grep shows + only Display + RDF/XML today, but re-verify with `--all-features`). +3. Downstream/external consumers string-matching Fluree's `xsd:double` output + change — mitigate with the changelog note; behavior is now spec-aligned. +4. The lexer residual (`_:a..b`) is intentionally left erroring to match + `parse_pn_local`; note it so a future reader doesn't mistake it for an + oversight. diff --git a/docs/audit/burn-down/named-graph-dataset.md b/docs/audit/burn-down/named-graph-dataset.md new file mode 100644 index 0000000000..e90030bebb --- /dev/null +++ b/docs/audit/burn-down/named-graph-dataset.md @@ -0,0 +1,465 @@ +# Burn-down: named-graph (`GRAPH`) + dataset (`FROM`/`FROM NAMED`) semantics + +Pre-implementation deep audit for audit §4.2 (item "Named-graph data / GRAPH ?g +binding") and §5.3 (dataset strategy). Covers the `/graph/` and `/dataset/` +entries of register `SPARQL10_QUERY_EVAL` (24 tests), plus `SPARQL11_BINDINGS` +entry `graph` and `SPARQL11_EXISTS` entry `exists-graph-variable`. + +**Scope:** one small correctness cluster in the correlated `GRAPH` operator +(binds as literal, leaks the default graph, drops the graph-variable join) and +one design decision (single-ledger `FROM`/`FROM NAMED`). No source was modified +during this audit. Every claim below was verified against the code and, for the +two non-obvious cases, reproduced with the built `run-w3c-test` subprocess +binary driven by a hand-written `TestDescriptor`. Baseline: branch +`test/sparql-testsuite-full-coverage`, rdf-tests submodule `efccbc6b8`, +2026-07-06. + +Key code: the whole `GRAPH` cluster lives in one operator, +`fluree-db-query/src/graph.rs` (`GraphOperator`); the dataset rejection is one +guard, `fluree-db-api/src/view/query.rs:556` (`validate_sparql_for_view`). + +--- + +## 1. Confirmed root causes of finding (a) + per-test verdicts + +Finding (a) — "`GRAPH ?g` binds `?g` for default-graph triples too, and binds it +as a plain string literal of the ledger alias" — is **two independent defects** +in `GraphOperator`, plus a **third** that the audit did not name (the +graph-variable join) and a **fourth/fifth** that surface only on the two +SPARQL 1.1 tests. All are in `fluree-db-query/src/graph.rs`. + +### BUG-1 — `?g` is materialized as an `xsd:string` literal, not an IRI + +`GraphOperator::execute_in_graph`, **graph.rs:314-323**, binds the graph +variable like this: + +```rust +if bind_graph_var == Some(*var) { + // Bind ?g to graph IRI using xsd:string + let binding = Binding::Lit { + val: FlakeValue::String(graph_iri.to_string()), + dtc: DatatypeConstraint::Explicit(self.well_known.xsd_string.clone()), + t: None, op: None, p_id: None, + }; + merged_row.push(binding); +} +``` + +This is **materialization-time**, not a formatter problem. `Binding::Lit{String}` +renders as `{"type":"literal",…}` (`fluree-db-api/src/format/sparql.rs:333`), +whereas `Binding::Iri` renders as `{"type":"uri",…}` (sparql.rs:332, 497). The +harness therefore sees `Literal{value:"…data-g1.ttl"}` where the W3C `.ttl` +result declares `Iri(…data-g1.ttl)` (dawg-graph-03 diff). The value string is +correct — the graph registry round-trips the name faithfully; only the **term +kind** is wrong. `Binding::Iri(Arc)` already exists (binding.rs:98) with a +constructor `Binding::iri()` (binding.rs:512) and is the one-line fix. + +The module doc (graph.rs:10) enshrines the wrong behavior: *"?g is bound as +`Binding::Lit { val: FlakeValue::String(...), dtc: Explicit(xsd:string) }`"*. + +### BUG-2 — the default graph leaks into `GRAPH ?g` (intentional extension, W3C-breaking) + +In the unbound `GRAPH ?g` arm, single-db path, **graph.rs:686-711**: + +```rust +// Single-db: bind ?g to each registered user graph …, then to the ledger +// alias for the default graph. +for iri in ctx.single_db_user_graph_iris() { + self.execute_in_graph(ctx, &parent_batch, row_idx, iri, Some(*var)).await?; +} +let alias_iri: Arc = Arc::from(ctx.active_snapshot.ledger_id.as_str()); +self.execute_in_graph(ctx, &parent_batch, row_idx, alias_iri, Some(*var)).await?; // <-- leak +``` + +The trailing `execute_in_graph(alias_iri, …)` enumerates the **default graph** +as if it were a named graph called `w3c:test` (the ledger alias). SPARQL +requires `GRAPH ?g` to range over **named graphs only** +(https://www.w3.org/TR/sparql11-query/#queryDataset). This is the source of +every "extra rows / `g: Literal{"w3c:test"}`" failure. + +**This is a deliberate, documented, regression-tested Fluree extension**, not an +accident. It was added by issue #1279 ("resolve ledger named graphs in single-db +GRAPH patterns"). Two existing tests assert it: + +- `fluree-db-api/tests/it_query_dataset.rs:1591` + `sparql_single_db_graph_variable_unbound` — asserts `GRAPH ?g` binds `?g` to + the alias `"people:main"` (and, incidentally, as a JSON string, encoding + BUG-1 too). +- `it_query_dataset.rs:1752` + `sparql_single_db_graph_variable_discovers_user_graphs` — comment literally + says *"discovers user-registered named graphs (plus the ledger alias for the + default graph)"* and cites "open decision #1". + +So the fix is a **behavior change the team already flagged as open**, and it +requires updating those two tests. The concrete/bound forms (`GRAPH `, +`VALUES ?g { "alias" } GRAPH ?g`) at graph.rs:597-623 / 644-670 also treat +`is_alias` as a valid graph, but **no W3C test binds `?g` to, or names, the +ledger alias**, so those forms are W3C-safe and can stay as a benign +opt-in (see §3). + +### BUG-3 — the graph variable is overwritten, never unified with inner uses + +Still in the merge loop (graph.rs:312-342): for every output var that equals the +graph var, the operator **unconditionally pushes the graph IRI**, discarding +whatever the inner pattern bound to that same variable. `?g` is **not seeded** +into the inner subplan (the seed is only the parent row, graph.rs:259). So for +`GRAPH ?g { ?g :p ?o }` the inner scans `?g :p ?o` with `?g` free (matches every +subject with `:p`), then the merge overwrites the subject with the graph name — +producing rows where subject ≠ graph name. SPARQL treats the two occurrences of +`?g` as one variable: binding it to the graph name must constrain the inner +pattern. This breaks `graph-variable-join` and `graph-optional`. + +### BUG-4 — a zero-variable existence solution is dropped downstream + +`GRAPH {}` (ground body, empty projection) must yield exactly one +empty solution when the graph exists. `GraphOperator` deliberately preserves it +(graph.rs:482-489, `Batch::empty_schema_with_len`), and the sibling +variable-form `GRAPH ?g {}` returns its rows — so the operator is correct. The +one empty-schema row is lost **downstream of `GraphOperator`** (projection / +`SELECT *` serialization collapsing a 0-column, N-row batch to 0 rows). Probe +(`run-w3c-test`, graph-exist): `Expected 1 solution(s), got 0`, `Actual vars: +[]`. This is a narrow, distinct bug (empty-projection existence rows), not the +`GRAPH` binding at all. Exact drop point (projection operator vs. SPARQL-JSON +writer) should be pinned with a one-line probe during implementation. + +### BUG-5 — bound `GRAPH ?g` fails when `?g` is a late-materialized `EncodedSid` + +`extract_graph_iri_from_binding` (graph.rs:157-172) handles `get_iri()` +(`Iri`/`IriMatch`), `Binding::Sid`, and `Binding::Lit{String}` — but **not +`Binding::EncodedSid`** (binding.rs:155) nor `EncodedLit`. A `?g` bound from a +triple scan object (`?s :p ?g`) is, by default, a late-materialized +`EncodedSid`. So the bound arm at graph.rs:627-629 gets `None` → "binding exists +but isn't a string IRI → no output" → the graph never resolves. This is exactly +`exists-graph-variable` (`?s :p ?g . FILTER EXISTS { GRAPH ?g { … } }`). Probe: +`Expected 1 solution(s) [s1], got 0`. Corroborated by the baseline: the +`SPARQL11_BINDINGS` `graph` test binds `?g` to an IRI via `VALUES` (not a scan) +and **partially** works (the IRI-typed rows appear), i.e. IRI-bound `?g` +resolves but scan-bound `EncodedSid` `?g` does not. + +### Per-test verdicts — `/graph/` (12 registered) + +Data setup is from the manifest (`qt:data` → default graph, `qt:graphData` → +named graph); "leak rows" = spurious solutions from BUG-2 binding the default +graph as `w3c:test`. + +| Test | Data setup | Query shape | Now | Expect | Root cause | Greened by | +|---|---|---|---|---|---|---| +| dawg-graph-03 | named data-g1 only | `GRAPH ?g {spo}` | 2 (g=Lit) | 2 (g=Iri) | BUG-1 | PR-A | +| dawg-graph-04 | default data-g1 only | `GRAPH ?g {spo}` | 2 leak | 0 | BUG-2 | PR-A | +| dawg-graph-06 | default g1 + named g2 | `GRAPH ?g {spo}` | 3 | 1 | BUG-1+BUG-2 | PR-A | +| dawg-graph-07 | default g1 + named g2 | `{spo} UNION {GRAPH ?g {spo}}` | 5 | 3 | BUG-1+BUG-2 | PR-A | +| dawg-graph-08 | default g1 + named g2 | `spo . GRAPH ?g {sqv}` | 3 | 1 | BUG-1+BUG-2 | PR-A | +| dawg-graph-09 | default g3 + named g4 (bnodes) | `spo . GRAPH ?g {sqv}` | 2 | 0 | BUG-2 | PR-A | +| dawg-graph-10b | default g3 + named g3-dup | `spo . GRAPH ?g {sqv}` | 2 | 0 | BUG-2 | PR-A | +| dawg-graph-11 | default g1 + named g1,g2 | `{spo} UNION {GRAPH ?g {spo}}` | 10 | 8 | BUG-1+BUG-2 | PR-A | +| graph-empty | named g1,g2 | `GRAPH ?g {}` | 3 (g=Lit) | 2 (g=Iri) | BUG-1+BUG-2 | PR-A | +| graph-exist | default g1 + named g1,g2 | `GRAPH {}` | 0 | 1 | **BUG-4** | PR-D | +| graph-variable-join | named data-variable-join, g1 | `GRAPH ?g { ?g :p ?o }` | 3 | 1 | **BUG-3**+BUG-1 | PR-B | +| graph-optional | named data-optional, g1 | `GRAPH ?g { spo OPTIONAL{ ?s ?p ?g } }` | 4 | 1 | **BUG-3**+BUG-1 | PR-B | + +Already passing (context, not registered): dawg-graph-01/02/05, graph-not-exist, +graph-variable-scope. + +### Per-test verdicts — SPARQL 1.1 graph-variable tests (2) + +| Test | Query | Now | Expect | Root cause | Greened by | +|---|---|---|---|---|---| +| exists (`exists-graph-variable`) | `?s :p ?g . FILTER EXISTS { GRAPH ?g {…} }` | 0 | s1 | **BUG-5** | PR-C | +| bindings (`graph`) | `GRAPH ?g { VALUES(?g ?t){(UNDEF …)( …)} }` | 4 | 3 | BUG-1+BUG-2 **+ empty-named-graph visibility** | PR-A + open Q2 | + +`bindings/graph` is **not** fully greened by PR-A: expected row `g=` +requires an *empty* named graph to be enumerable, and Fluree does not register +empty named graphs (the harness skips empty loads, `query_handler.rs:144-151`, +and the registry only holds graphs with triples). Keep it registered pending the +empty-named-graph decision (open question 2). + +--- + +## 2. Design memo — §5.3 single-ledger `FROM` / `FROM NAMED` + +All 12 `/dataset/` tests fail identically: `validate_sparql_for_view` +(view/query.rs:568) hard-rejects any dataset clause on a single-ledger `GraphDb`: + +```rust +if has_dataset { + return Err(ApiError::query( + "SPARQL FROM/FROM NAMED clauses are not supported on a single-ledger GraphDb. \ + Use query_connection_sparql for multi-ledger queries.")); +} +``` + +Unlike the `/graph/` tests, the `/dataset/` manifest carries **no** `qt:data` +or `qt:graphData` — the dataset is defined **entirely by the query's FROM/FROM +NAMED clauses**, each naming a test file by relative IRI (e.g. +`FROM `, `FROM NAMED `). The tests probe exactly the +delicate semantics: + +- **empty default graph** when only `FROM NAMED` is given (dataset-02 → 0); +- **`FROM` does not create a named graph** (dataset-04: `GRAPH ?g` → 0); +- **default graph = union of multiple `FROM`** (dataset-12b); +- **same IRI in `FROM` and `FROM NAMED`** (dataset-11: data-g1 in both). + +### How multi-ledger datasets work today (for reference) + +`query_connection_sparql` (`fluree-db-api/src/query/connection.rs:688`) → +`extract_sparql_dataset_spec` (query/helpers.rs:388) → `DatasetSpec` +(dataset.rs:91). Each `FROM`/`FROM NAMED` IRI is run through +`parse_ledger_id_time_travel` (dataset.rs:881) and **treated as a ledger +identifier**; `build_dataset_view` (view/dataset_builder.rs:110) resolves each +via `self.db(identifier)` (dataset_builder.rs:212 — a **nameservice ledger +load**). The result becomes a `fluree_db_query::DataSet` of `GraphRef`s +(query/dataset.rs:174), each a `(snapshot, g_id, ledger_id)` triple, fanned out +by `DatasetOperator`. Crucially, **all single-ledger within-graph machinery is +gated on `ctx.dataset.is_none()`** (context.rs:947/961; graph.rs:556/586/…), so +the moment any `FROM` clause is present, that path turns off and every IRI is +resolved as a **separate ledger**. There is no resolver from a `FROM NAMED` IRI +to a `g_id` **inside** the current ledger. + +The within-ledger registry that Option A needs already exists end-to-end: +`GraphRegistry` (`fluree-db-core/src/graph_registry.rs`), `FIRST_USER_GRAPH_ID=3` +(graph_registry.rs:41), `graph_id_for_iri`/`iter_entries` (graph_registry.rs:315, +327), surfaced as `ExecutionContext::single_db_user_graph_id` / +`single_db_user_graph_iris` (context.rs:946, 960). Issue #1279 wired it for the +**no-FROM** path only. + +### Option A — within-ledger datasets (recommended) + +When a single-ledger query carries `FROM`/`FROM NAMED`, resolve each clause IRI +against the ledger's own graph registry (user graph g_id, the ledger alias → +default g_id 0, or an R2RML/graph source) and build a **single-snapshot** +`DataSet`: `default_graphs` = the `FROM` g_ids (empty if there is no `FROM` +clause), `named_graphs` = the `FROM NAMED` IRI→g_id map. Pass it via the same +`ContextConfig.dataset` the multi-ledger path uses; `DatasetOperator` already +unions the defaults and scopes `GRAPH` to the named map. Because every `GraphRef` +shares one snapshot/ledger, `spans_multiple_ledgers()` is false → **no +cross-ledger provenance stamping, binary store stays enabled** (the fast scan +path is untouched). Fall back to the connection path (or today's rejection) only +when a clause IRI is not resolvable within the ledger. + +- **W3C semantics:** exact. No `FROM` → `default_graphs` empty → dataset-02/04 + green. Multiple `FROM` → union → dataset-12b. Same IRI in both → same g_id in + default and named → dataset-11. +- **g_id model:** native — a named graph *is* a g_id; this is the Fluree data + model, not an impedance-mismatched ledger-per-graph. +- **Perf:** plan-time only (resolve IRIs → g_ids, build `GraphRef`s). Single + snapshot ⇒ none of the multi-ledger slow paths engage. +- **Reuse:** the runtime `DataSet` + `DatasetOperator` + `dataset_query.rs` + execution path are reused as-is; only dataset *construction* is new. +- **Product value:** fixes the actual gap — a real user can `SELECT … FROM + FROM NAMED ` against one ledger's named graphs. +- **Greens:** all 12 `/dataset/` tests, and `SPARQL11_CONSTRUCT` `constructwhere04` + (same rejection, per its register comment). Requires a **harness** change: + for dataset tests, pre-load each `FROM`/`FROM NAMED`-referenced file as a + named graph in the single ledger (the harness already loads named graphs this + way, `query_handler.rs:141-172`; the file list would come from the parsed + dataset clause instead of `qt:graphData`). + +### Option B — harness maps graph URLs → ledgers (`query_connection_sparql`) + +Load each referenced file into its **own** in-memory ledger aliased by the file +IRI, then call `query_connection_sparql`. **Zero engine change.** + +- **W3C semantics:** achievable — the existing multi-ledger path already makes + the default the union of `FROM` and leaves it empty when only `FROM NAMED` is + present. +- **Costs:** requires ledger aliases to admit arbitrary `https://…` IRIs; spins + up N ledgers per query (8 for dataset-12b); needs the harness to switch from + `fluree.query(&db, …)` to `query_connection_sparql` **for dataset tests only**, + diverging them from every other eval test; and it **does not fix the product** + (single-`GraphDb` `FROM` still rejected). +- **Greens:** the same 12 tests, harness-only. + +### Recommendation + +**Option A.** Both options green the same 12 tests, so the tie-breakers are +product value, reuse, and perf — all of which favour A. B is a harness-only +fallback that leaves the user-facing gap and forks the harness. A extends +#1279's within-ledger registry the natural way and keeps the scan hot path +byte-identical (single snapshot). The one extra cost is the harness pre-load, +which is small and reuses the existing named-graph loader. + +--- + +## 3. Fix design for finding (a) — no per-row hot-path branching + +All of (a) is fixed at **materialization / plan / seed time**; nothing lands in +a per-scan-row loop. + +- **BUG-1 (IRI typing):** at graph.rs:314-323 replace the `Binding::Lit{String, + xsd:string}` with `Binding::iri(graph_iri.clone())`. Update the module doc + (graph.rs:10) and the assertion in + `it_query_dataset.rs:1591`. Cost: one enum construction per *matched GRAPH + solution* (already being built), not per scan row. + +- **BUG-2 (default-graph leak):** delete the alias enumeration at + graph.rs:700-709 in the unbound single-db arm — `GRAPH ?g` then ranges over + `single_db_user_graph_iris()` only. Update `it_query_dataset.rs:1591` and + `:1752`. + + *Intentional-extension handling.* The extension has two halves: (i) implicit + **enumeration** of the default graph via unbound `GRAPH ?g`, and (ii) + **explicit addressing** of the default graph via a concrete/bound alias IRI + (`GRAPH `, `VALUES ?g {"alias"} GRAPH ?g`). Only (i) breaks W3C. Drop + (i) (implicit); **keep (ii)** — no W3C test names the alias, so explicit + addressing stays W3C-safe and preserves the useful "query the default graph by + name" affordance. If (i) has real product value, gate it behind an opt-in + query option rather than making it the default — the natural home is a bool on + `QueryOpts`/`ExecutionContext` (e.g. `graph_var_includes_default`, default + `false` = W3C), checked once where the arm decides whether to append the alias + (graph.rs:700). Recommendation: **just fix it** (drop implicit, keep explicit); + do not ship a toggle unless a consumer asks — the divergence is the kind #1279 + left explicitly open, and W3C-by-default is the right default. + +- **BUG-3 (graph-var unification):** seed the graph var into the inner subplan. + In `execute_in_graph`, when `bind_graph_var == Some(v)`, add `v → + Binding::iri(graph_iri)` to the `SeedOperator` row (graph.rs:258-259) so inner + patterns referencing `?g` are scanned with it bound; then the merge already + carries the consistent value (the special-case push becomes redundant, or a + cheap assert). This is **seed-time**, per parent row of a GRAPH scope, and + *improves* perf (it constrains the inner scan instead of overwriting after a + full scan). + +- **BUG-5 (EncodedSid bound `?g`):** extend `extract_graph_iri_from_binding` + (graph.rs:157-172) with an `EncodedSid`/`EncodedLit`-string arm that + materializes the value against the active graph view before comparing. This + runs **per parent row of a correlated GRAPH**, only in the bound-var arm, and + only adds a decode when `?g` is actually bound and still encoded — off any hot + scan path. + +- **BUG-4 (empty existence row):** `GraphOperator` is already correct + (graph.rs:482-489). Pin and fix the downstream 0-column-batch drop + (projection or SPARQL-JSON writer); ensure the fix is preserve-only, adding no + per-row cost. + +The **dataset** fix (§2 Option A) replaces the guard at view/query.rs:568 with +a within-ledger dataset build; that is plan-time construction feeding the +existing `dataset_query.rs` execution path. + +--- + +## 4. Hot-path classification + bench guards + +| Fix | Stage | Hot path? | +|---|---|---| +| BUG-1 IRI typing | materialization (per matched GRAPH solution) | no | +| BUG-2 drop alias enumeration | enumeration/plan (removes one iteration) | no | +| BUG-3 seed graph var | seed-time (per parent row); constrains inner scan | no (net win) | +| BUG-4 preserve empty row | projection/format (preserve-only) | no | +| BUG-5 EncodedSid decode | per parent row, bound-var arm only | no | +| Dataset (Option A) | plan-time dataset construction; single snapshot | no | + +None of these touch `BinaryScanOperator`, the join inner loop, or filter +evaluation. **Bench guards:** `query_hot_bsbm` and `query_hot_bsbm_bi` +(`fluree-db-api/benches/`) use BSBM data with **no named graphs and no `GRAPH`/ +`FROM`**, so `GraphOperator` and dataset construction are never instantiated on +those paths — the fixes are off the guarded hot path by construction. Still run +both per PR (per §6 of the audit) to confirm the shared `context.rs` helpers +(`single_db_user_graph_iris`, `with_active_graph`) and any projection change +don't regress; expect flat within `regression-budget.json`. The single-ledger +`DataSet` guarantees the multi-ledger provenance/eager-materialization slow +paths stay dormant, so scan throughput under `GRAPH`/`FROM` is unchanged from +the concrete-graph baseline. + +--- + +## 5. Query-surface parity (SPARQL / JSON-LD) + +Fluree owns the JSON-LD query syntax, so the "SPARQL-possible ⇒ JSON-LD-possible" +rule applies, with a JSON-LD regression test named per fix. Cypher is +openCypher — Fluree does not own the grammar and will not add custom syntax — +so Cypher is **out of scope for this burn-down** and is not analyzed here. + +- **JSON-LD / FQL has GRAPH patterns.** `["graph", , {…}]` lowers to the + same `Pattern::Graph` and runs through the same `GraphOperator` (tests + `fql_graph_pattern_basic` it_query_dataset.rs:1017, `fql_graph_pattern_with_alias` + :1070). Therefore **BUG-1/2/3/5 are IR/engine-level fixes** (parity class 1): + one change fixes both surfaces, but the W3C submodule only guards SPARQL, so + each PR **must add FQL regression tests**. Author in + `fluree-db-api/tests/it_query_dataset.rs` (alongside the existing FQL graph + tests): + 1. `["graph","?g",{…}]` over a ledger with a user named graph → `?g` comes + back as an **IRI** term (`@id`), and the ledger's default graph is **not** + enumerated (mirror of the BUG-1/BUG-2 SPARQL fix); + 2. `["graph","?g",{"@id":"?g", …}]` — graph variable also used inside (BUG-3); + 3. bound `["graph","?g",…]` where `?g` came from a triple scan (BUG-5). +- **JSON-LD `from`/`fromNamed` datasets already exist** (dataset.rs:76-77, the + `ExtractedDataset` path) and flow through the same `DataSet`/`DatasetOperator`. + Option A's within-ledger construction should be reachable from the JSON-LD + `from`/`fromNamed` surface too, within one ledger — add an FQL within-ledger + `from`/`fromNamed` regression test alongside the SPARQL dataset fix. + +Definition of done per the team guideline: register entry removed **and** the +JSON-LD regression test authored. + +--- + +## 6. Blast radius, PR composition, risks, open questions + +### Blast radius + +- `fluree-db-query/src/graph.rs` (`GraphOperator`) — BUG-1/2/3/5. Shared by + SPARQL + FQL graph patterns. Existing behavior is pinned by + `it_query_dataset.rs:1526-1765`; **two tests must be updated** (1591, 1752) — + they encode the extension being removed. +- Projection / SPARQL-JSON writer — BUG-4. Shared by all queries; keep surgical. +- `fluree-db-api/src/view/query.rs` (`validate_sparql_for_view` + a new + within-ledger dataset build) and `testsuite-sparql/src/query_handler.rs` + (pre-load FROM-referenced files) — dataset (Option A). + +### Suggested PR composition + +- **PR-A — GRAPH ?g W3C conformance (BUG-1 + BUG-2).** IRI typing + drop alias + enumeration; update tests 1591/1752; add FQL regression. Greens graph-03, 04, + 06, 07, 08, 09, 10b, 11, graph-empty (9 tests). Small, self-contained. +- **PR-B — graph-variable unification (BUG-3).** Seed `?g` into the inner + subplan. Greens graph-variable-join, graph-optional. Depends on PR-A (needs + IRI typing). +- **PR-C — bound EncodedSid `?g` (BUG-5).** Extend + `extract_graph_iri_from_binding`. Greens exists-graph-variable. Tiny. +- **PR-D — empty-projection existence row (BUG-4).** Fix the downstream drop. + Greens graph-exist. May be broader than the GRAPH cluster (projection/format); + isolate. +- **PR-E — single-ledger FROM/FROM NAMED (Option A).** Within-ledger dataset + construction + harness pre-load. Greens all 12 `/dataset/` tests and + `constructwhere04`. Largest (engine view layer + harness). Independent of + PR-A–D. + +Each PR shrinks its register entries in the same change (audit §5.1 +both-direction enforcement). + +### Risks + +1. **Removing the #1279 extension** (BUG-2) is a semantics change with two + regression tests asserting it — needs the 2-reviewer sign-off the audit + requires. Mitigation: keep explicit `GRAPH ` addressing; only drop + implicit enumeration. +2. **`?g` literal → IRI** changes any consumer that relied on the string form. + The back-compat string-literal acceptance in + `extract_graph_iri_from_binding` stays (so `VALUES ?g {"alias"}` still works); + only the *output* term kind changes, which is the correct behavior. +3. **Option A must route through the existing dataset execution path** + (`dataset_query.rs`, including `apply_reasoning_to_dataset`, policy, and + R2RML graph sources) — not a parallel new path — or it will silently diverge + on policy/reasoning. Reuse `as_runtime_dataset` (view/dataset.rs:184). +4. **BUG-4** touches shared projection/format; a careless fix could resurrect + dropped-vs-kept empty rows elsewhere (ASK, zero-var subqueries). Add targeted + tests. + +### Open questions + +1. Keep the default-graph-discovery extension behind an opt-in flag, or drop it + outright? (Recommend: drop implicit enumeration, keep explicit alias + addressing, no toggle unless a consumer needs it.) +2. **Empty named graphs.** Fluree does not track a declared-but-empty named + graph (harness skips empty loads; registry holds only graphs with triples). + This blocks `bindings/graph` (needs `g=`) and is the same gap + behind CLEAR-vs-DROP in update-eval (query_handler.rs:440-460 comment). A + product decision is needed: does Fluree model empty named graphs? +3. Harness: confirm Option A's pre-load of `FROM`/`FROM NAMED`-referenced files + is acceptable (harness parses the query's dataset clause), vs. the Option B + per-file-ledger fallback. +4. BUG-4: pin the exact 0-column-batch drop point (projection operator vs. + SPARQL-JSON writer) with a one-line probe. +5. Cross-check **#1317** (GRAPH leaks default graph after `WITH DELETE WHERE`): + likely shares the default-vs-named confusion behind BUG-2 and should be + verified once BUG-2 lands. diff --git a/docs/audit/burn-down/parser-syntax-validation.md b/docs/audit/burn-down/parser-syntax-validation.md new file mode 100644 index 0000000000..c195d5dbbb --- /dev/null +++ b/docs/audit/burn-down/parser-syntax-validation.md @@ -0,0 +1,623 @@ +# Burn-down: SPARQL query-side parser syntax + validation gaps + +**Cluster owner deliverable — pre-implementation deep audit.** No source was +modified. Parent audit: `docs/audit/2026-07-sparql-testsuite-audit.md` (§4.2.3, +§4.2.4, §4.2.6). Register: `testsuite-sparql/tests/registers/mod.rs` +(`SPARQL10_SYNTAX`, `SPARQL11_SYNTAX_QUERY`, plus the negative-syntax entries in +`SPARQL11_AGGREGATES` / `SPARQL11_GROUPING`). Baseline rdf-tests submodule +`efccbc6b8`. Every root cause below was reproduced against the live parser with +a throwaway probe (`parse_sparql` + `validate`); the verdicts quoted are actual +parser output. + +Spec references are to *SPARQL 1.1 Query Language* +(https://www.w3.org/TR/sparql11-query/), production numbers per its +§19.8 grammar. + +## 0. Scope: 62 tests + +| Sub-cluster | Tests | Kind | Fix locus | +|---|---|---|---| +| **P1** RDF collections `( … )` in patterns | 13 | positive-rejected | parser (desugar) | +| **P4** empty/relative IRIREF `<>` in PREFIX/BASE | 2 | positive-rejected | lexer + (lower for eval) | +| **P5a** extension-function call with `NIL` arg list `f()` | 3 | positive-rejected | expr parser | +| **P5b** bare `Constraint` as `ORDER BY` condition | 1 | positive-rejected | modifier parser | +| **P6** `SubSelect` placement (bare / OPTIONAL / after-UNION) | 3 | positive-rejected | pattern parser | +| **P7** `VALUES` with `NIL` var list `VALUES () { … }` | 2 | positive-rejected | pattern parser | +| **P8** property path as verb inside `[ … ]` | 1 | positive-rejected | term parser | +| **V1** BGP `.` (dot) structural validity | 12 | negative-accepted | parser (grammar) | +| **V2** `FILTER` requires a `Constraint` (not bare expr) | 1 | negative-accepted | pattern parser | +| **V3** blank-node label scope = single BGP | 11 | negative-accepted | **new validate pass** | +| **V4** GROUP BY / aggregate projection scope | 9 | negative-accepted | **new validate pass** | +| **V5** `BIND` target var must not be in-scope | 3 | negative-accepted | **new validate pass** | +| **V6** duplicate `AS` alias in `SELECT` | 1 | negative-accepted | **new validate pass** | + +Positives (P*) are **accept-more** fixes (near-zero regression risk). +Negatives (V*) are **reject-more** fixes: V1/V2 tighten the grammar and V3–V6 +add semantic validation — both can reject queries Fluree currently accepts, so +they carry regression risk for non-W3C users (see §5). + +Per-test → sub-cluster map: + +- **P1**: `syntax-sparql1#{syntax-lists-01..05, syntax-forms-01, syntax-forms-02}`, + `syntax-sparql2#{syntax-lists-01..05}`, `syntax-query#test_pp_coll` + (`test_pp_coll` also needs P8). +- **P4**: `syntax-sparql1#syntax-qname-05`, `basic#base-prefix-1` + (the latter is registered under `SPARQL10_QUERY_EVAL` but is parser + territory; see §3). +- **P5a**: `syntax-sparql2#{syntax-function-01, -02, -03}`. +- **P5b**: `syntax-sparql1#syntax-order-07`. +- **P6**: `syntax-query#{test_21, test_23, test_64}`. +- **P7**: `syntax-query#{test_35a, test_36a}`. +- **P8**: `syntax-query#test_63`. +- **V1**: `syntax-sparql3#{syn-bad-02, -03}` (missing dot) + + `syntax-sparql3#{syn-bad-05..14}` (stray/leading/doubled dot). +- **V2**: `syntax-sparql3#filter-missing-parens`. +- **V3**: `syntax-sparql3#{blabel-cross-graph-bad, -optional-bad, -union-bad}` + + `syntax-sparql4#{syn-bad-34..38, syn-bad-GRAPH-breaks-BGP, + syn-bad-OPT-breaks-BGP, syn-bad-UNION-breaks-BGP}`. +- **V4**: `aggregates#{agg08, agg09, agg10, agg11, agg12}`, + `grouping#{group06, group07}`, `syntax-query#{test_43, test_44}`. +- **V5**: `syntax-query#{test_60, test_61a, test_62a}`. +- **V6**: `syntax-query#test_45`. + +--- + +## 1. Per-cluster root cause (file:line evidence) + +### P1 — RDF collections `( … )` in triple patterns + +Grammar: `Collection ::= '(' GraphNode+ ')'` (obj/subj position); empty `()` +is `NIL` = the IRI `rdf:nil`. Spec §4.2.4 mandates desugaring to +`rdf:first`/`rdf:rest`/`rdf:nil` triples with fresh blank nodes. + +Root cause: collections are **explicitly stubbed out**. `parse_subject` +(`fluree-db-sparql/src/parse/query/term.rs:87-94`) and `parse_object` +(`term.rs:220-227`) both do: + +```rust +if self.stream.check(&TokenKind::LParen) || self.stream.check(&TokenKind::Nil) { + self.stream.error_at_current("RDF collection (list) syntax is not yet supported"); + self.skip_collection(); // term.rs:487-500 + return None; +} +``` + +`is_term_start` already routes `(`/`Nil` into the triples-block parser +(`fluree-db-sparql/src/parse/stream.rs:399-420`, includes `LParen` and `Nil`), +so a lone `( ?z )` reaches `parse_subject` and hits the same stub. Probe: +every P1 query → `PARSE-REJECT "RDF collection (list) syntax is not yet +supported"`. `syntax-forms-01/02` are the same production with blank-node +items (`( [ ?x ?y ] )`), which the collection parser must accept as +`GraphNode`s. + +### P4 — empty / relative IRIREF `<>` in PREFIX/BASE + +Grammar: `IRIREF ::= '<' ([^ control | <>"{}|^`\ ])* '>'` — the character +class permits **zero** characters, so `<>` is a valid (empty, relative) IRI +reference that resolves against `BASE` (spec §4.1.1). + +Root cause: the SPARQL lexer rejects an empty IRI body. +`parse_iri_content` (`fluree-db-sparql/src/lex/lexer.rs:199-201`): + +```rust +if result.is_empty() { + return Err(winnow::error::ErrMode::Backtrack(ContextError::new())); +} +``` + +So `<>` produces no `Iri` token; `consume_iri` (`stream.rs:254-266`) returns +`None`; and `parse_prefix_decl` (`parse/query/mod.rs:264-286`) emits +`expected IRI after prefix namespace`. Probe: both `PREFIX : <>` and +`BASE PREFIX : <>` → +`PARSE-REJECT "expected IRI after prefix namespace"`. (This is the same string +the audit records for `basic#base-prefix-1`.) This lexer lives only in +`fluree-db-sparql`; it is **not** the Turtle/import lexer, so the fix is +SPARQL-query-surface-local. + +### P5a — extension-function call with `NIL` arg list `f()` + +Grammar: `iriOrFunction ::= iri ArgList?`; `ArgList ::= NIL | '(' … ')'`; +`NIL ::= '(' WS* ')'`. + +Root cause: the lexer tokenizes `()` / `( )` / `(\n)` as a single `Nil` +token (`lexer.rs` NIL rule, tests at `lexer.rs:1139-1141`). But the primary +expression parser only treats an IRI as a function call when the **next token +is `LParen`** — it never checks for `Nil`: + +```rust +// parse_primary_expr — fluree-db-sparql/src/parse/expr.rs +if let Some((prefix, local, pn_span)) = tokens.consume_prefixed_name() { // :260 + ... + if tokens.check(&TokenKind::LParen) { // :265 ← misses Nil + return parse_function_call_with_iri(tokens, iri, start); + } + return Ok(Expression::iri(iri)); // :269 falls through, leaves `()` +} +``` + +Same at the full-IRI branch (`expr.rs:252`) and the `PrefixedNameNs` branch +(`expr.rs:281`). The downstream helper `parse_expression_list` +(`expr.rs:769-774`) *already* handles `Nil`, and built-ins like `NOW()` work +because they go through it — only the extension-IRI path is missing the `Nil` +check. Probe: `FILTER (q:name())` → `PARSE-REJECT "Expected ')' at position +63"` (the `Nil` is left un-consumed and the enclosing bracketed expression +then fails to find its `)`). + +### P5b — bare `Constraint` as an `ORDER BY` condition + +Grammar: `OrderCondition ::= ( ('ASC'|'DESC') BrackettedExpression ) | +( Constraint | Var )`; `Constraint ::= BrackettedExpression | BuiltInCall | +FunctionCall`. So `ORDER BY str(?o)` (a bare `BuiltInCall`) is valid. + +Root cause: `parse_order_condition` +(`fluree-db-sparql/src/parse/query/modifier.rs:239-300`) accepts only +`ASC(...)`/`DESC(...)`, a bare `Var`, or a parenthesized expression; anything +else falls to `return None` (`modifier.rs:289-292`) → +`expected ordering condition`. The bare-builtin/function form is missing. Note +`parse_group_condition` already implements exactly this branch for `GROUP BY` +(`modifier.rs:142-165`, with a `position()`/`restore()` guard) — it is the +template for the fix. Probe: `ORDER BY str(?o)` → `PARSE-REJECT "expected +ordering condition"`. + +### P6 — `SubSelect` placement + +Grammar: `GroupGraphPattern ::= '{' ( SubSelect | GroupGraphPatternSub ) '}'`. +A group's entire content may be a sub-select. Three placements fail, two +distinct causes: + +- **Bare sub-select as group content** (`test_21` + `{ SELECT * { … } }`; `test_23` `{ {} OPTIONAL { SELECT * { … } } }`). + `parse_group_graph_pattern` + (`fluree-db-sparql/src/parse/query/pattern.rs:39-206`) only recognizes a + sub-select when it is preceded by an explicit inner `{` + (`pattern.rs:115-131`: `check(LBrace)` → advance → `check_keyword(KwSelect)`). + When `SELECT` is the *first* token inside the group's own braces (opened by + `WHERE`/`OPTIONAL`), no branch matches `KwSelect`, so it falls to the `else` + (`pattern.rs:165-169`) → `unexpected token in graph pattern`. Probe: both → + `PARSE-REJECT "unexpected token in graph pattern"`. + +- **`UNION` after a sub-select group** (`test_64` + `{ SELECT (1 AS ?X){} } UNION { SELECT (2 AS ?X){} }`). Here the sub-selects + *are* brace-wrapped, so they parse — but the subquery branch + (`pattern.rs:122-131`) pushes the sub-select and does **not** check for a + trailing `UNION`, unlike the nested-group branch + (`pattern.rs:132-141`, which calls `parse_union_continuation`). The next loop + iteration sees `UNION` at `pattern.rs:58-61` → `UNION must follow a pattern`. + Probe: → `PARSE-REJECT "UNION must follow a pattern"`. + +Sub-select *execution* is otherwise working (only `subquery02/04/12` are result +mismatches in the subquery suite), so this is purely a parse-placement gap. + +### P7 — `VALUES` with `NIL` variable list + +Grammar: `InlineDataFull ::= ( NIL | '(' Var* ')' ) '{' ( '(' DataBlockValue* +')' | NIL )* '}'`. So `VALUES () { }` (zero vars, zero rows) and +`VALUES () { () }` (zero vars, one empty row) are valid. + +Root cause: `parse_values_variables` +(`fluree-db-sparql/src/parse/query/pattern.rs:487-523`) recognizes only +`LParen` (multi-var) or a single bare `Var`; the `Nil` token (`()`) matches +neither → `expected variable or '(' after VALUES`. The zero-row/`NIL`-row form +in the data block (`parse_values_row`, `pattern.rs:528-566`) is likewise not +reached. Probe: both → `PARSE-REJECT "expected variable or '(' after VALUES"`. +Note `zero vars` also breaks the `multi_var = vars.len() > 1` dispatch at +`pattern.rs:438` (0 is treated as single-var), so the fix is a small +dedicated zero-variable path, not just a token check. + +### P8 — property path as verb inside a blank-node property list + +Grammar: inside `[ … ]` (`BlankNodePropertyList`), the verb is +`VerbPath | VerbSimple`, so `[ :p|:q|:r ?X ]` (an alternative path) is valid. + +Root cause: `parse_blank_node_property_list` +(`fluree-db-sparql/src/parse/query/term.rs:438-481`) explicitly rejects paths: + +```rust +Verb::Path(_) => { + self.stream.error_at_current( + "property paths inside a blank-node property list \ + ('[ path obj ]') are not yet supported"); + return None; // term.rs:453-459 +} +``` + +Structural note: the blank-node-property-list desugarer surfaces its inner +triples via `pending_bnpl_triples: Vec` (`parse/query/mod.rs:180`), +which cannot carry a `GraphPattern::Path` (paths are not `TriplePattern`s). +Supporting paths here therefore needs a parallel `pending_bnpl_patterns` +channel (or a small refactor of how `[ … ]` results are drained). Probe: +`[ :p|:q|:r ?X ]` → `PARSE-REJECT "property paths inside a blank-node property +list … are not yet supported"`. `test_pp_coll` combines P1 + P8 (a collection +of blank-node lists each holding a path) and currently fails on P1 first. + +### V1 — BGP dot (`.`) structural validity + +Grammar: `GroupGraphPatternSub ::= TriplesBlock? ( GraphPatternNotTriples '.'? +TriplesBlock? )*` and `TriplesBlock ::= TriplesSameSubjectPath ( '.' +TriplesBlock? )?`. A `.` is a **separator that must follow a complete +TriplesSameSubject** (or, once, a `GraphPatternNotTriples`). Leading, doubled, +standalone, or missing dots are syntax errors. + +Root cause: the parser treats `.` as a freely-skippable token. +`parse_group_graph_pattern` skips any dot anywhere +(`fluree-db-sparql/src/parse/query/pattern.rs:162-164`): + +```rust +} else if self.stream.check(&TokenKind::Dot) { + self.stream.advance(); // "Skip dots between patterns" +} +``` + +and `parse_triples_block` consumes at most one *optional* trailing dot then +returns (`term.rs:563-564`), re-entering the group loop for the next subject. +Consequences (all probed → `ACCEPT`): + +- *stray/leading/doubled dot* (`syn-bad-05..14`): `{ . }`, `{ . ?s ?p ?o }`, + `{ ?s ?p ?o . . }`, `{ ?s ?p ?o .. }` — the dot(s) are silently skipped. +- *missing dot between triples* (`syn-bad-02/03`): + `{ :s1 :p1 :o1 :s2 :p2 :o2 . }` — `parse_triples_block` returns after + `:s1 :p1 :o1` (no dot required), the loop re-enters on `is_term_start` and + parses `:s2 :p2 :o2` as a second block with no separator. + +This is the **highest-risk** item in the cluster because dot handling is +central to every BGP. + +### V2 — `FILTER` requires a `Constraint`, not an arbitrary expression + +Grammar: `Filter ::= 'FILTER' Constraint`; +`Constraint ::= BrackettedExpression | BuiltInCall | FunctionCall`. A bare +`Var` (or bare relational expression) is **not** a `Constraint`. + +Root cause: `parse_filter_pattern` +(`fluree-db-sparql/src/parse/query/pattern.rs:355-370`) calls +`parse_expression` directly, accepting anything the expression grammar +accepts — including a bare `?x`. Probe: `{ ?s ?p ?o FILTER ?x }` → `ACCEPT`. + +### V3 — blank-node label scope = a single BGP + +Spec §19.6 grammar note: *"the same blank node label cannot be used in two +basic graph patterns in a query."* A `GroupGraphPattern` boundary +(`GRAPH`, `OPTIONAL`, `UNION`, `MINUS`, a nested `{ }`, a sub-select) starts a +new BGP; `FILTER`/`BIND` do **not**. + +Root cause: `validate/mod.rs` has **no** blank-node-scope pass at all +(`validate_graph_pattern`, `validate/mod.rs:307-370`, only recurses and never +inspects blank-node labels). Probe: `_:who … OPTIONAL { … _:who }` and +`_:a ?p ?v . { _:a ?q 1 }` → `ACCEPT`. All 11 V3 tests reuse `_:a`/`_:who` +across `GRAPH`/`OPTIONAL`/`UNION`/nested-group boundaries. + +### V4 — GROUP BY / aggregate projection scope + +Spec §11 and the `SelectClause` grammar note: when a query groups (explicit +`GROUP BY` **or** an aggregate in the projection = implicit single group), +every projected variable must be a **group key** (a bare `GROUP BY ?v`, or the +`?v` of a `GROUP BY (expr AS ?v)`) or appear only **inside an aggregate**; and +`SELECT *` is **not** permitted with `GROUP BY`. + +Root cause: no such pass exists (`validate_select`, `validate/mod.rs:102-104`, +validates only the WHERE pattern). Probe (all → `ACCEPT`): + +| test | shape | why invalid | +|---|---|---| +| `test_43` | `SELECT * … GROUP BY ?s` | `*` with `GROUP BY` | +| `test_44`, `agg09` | project `?o`/`?P`, `GROUP BY ?s`/`?S` | projected var not a key | +| `group06` | project `?s ?v`, `GROUP BY ?s` | `?v` not a key (no aggregate) | +| `group07` | project `?eventName ?venue ?photo`, `GROUP BY ?event` | non-key vars | +| `agg10` | project `?P` + `COUNT(?O)`, no `GROUP BY` | implicit group, `?P` not aggregated | +| `agg08`, `agg11` | project `(?O1+?O2)`, `GROUP BY (?O1+?O2)` / `(?S)` | `?O1/?O2` not keys (the *expression* is the key, not its vars) | +| `agg12` | project `?O1`, `GROUP BY (?O1+?O2)` | `?O1` not a key | + +Requires (a) an "expression uses only group-keys-or-aggregates" walk over +`Expression` (an aggregate-detection predicate + a free-variable collector) and +(b) the group-key set built from `GroupByClause` conditions +(`fluree-db-sparql/src/ast/query.rs` `GroupCondition::{Var, Expr{alias}}`). + +### V5 — `BIND` target variable must not be in-scope + +Spec §10.1 / grammar note on `Bind`: *the variable assigned by `BIND(expr AS +?v)` must not already be in-use in the group graph pattern up to that point.* + +Root cause: no pass (`GraphPattern::Bind` is a no-op in the validator, +`validate/mod.rs:347-349`). Probe (all → `ACCEPT`): + +- `test_60` — `?o1` bound by a preceding triple in the same group, then + `BIND(… AS ?o1)`. +- `test_61a` — `?o1` bound inside a *nested* group, then `BIND` in the outer + group (in-scope propagates out of a nested group). +- `test_62a` — `?Y` bound in a `UNION` branch, then `BIND(1 AS ?Y)`. + +Requires an ordered walk of each group's children accumulating in-scope +variables (from BGPs, paths, `VALUES`, prior `BIND`s, and the projected/visible +variables of nested groups / unions / sub-selects per the §18.2.1 in-scope +definition), checking each `BIND` target against the set accumulated *before* +it. + +### V6 — duplicate `AS` alias in `SELECT` + +Spec §9.1 / `SelectClause` note: an `AS` variable must not be assigned twice +(nor collide with a variable already in scope). Root cause: `parse_select_variables` +(`fluree-db-sparql/src/parse/query/select.rs:87-95`) just collects items; no +uniqueness check, and no validate pass. Probe: `SELECT (1 AS ?X) (1 AS ?X) {}` +→ `ACCEPT`. + +--- + +## 2. Fix design (grouped by production / rule) + +### Parser-accept fixes (P*) — additive, low risk + +**P1 collections (desugar; no new AST/IR).** Add `parse_collection()` to +`term.rs`, invoked from `parse_subject`/`parse_object` (replacing the two +stubs) and reachable at group level via the existing `is_term_start` routing. +Desugar per spec §4.2.4 using the **existing blank-node machinery** +(`self.bnode_counter`, `self.pending_bnpl_triples`, `parse/query/mod.rs:176-188`): + +- `()` / `NIL` → the IRI `rdf:nil` (`fluree_vocab::rdf::NIL`), a plain term; no + triples. +- `( g1 g2 … gn )` → fresh bnodes `_l1.._ln`; emit `_li rdf:first gi . _li + rdf:rest _l(i+1) .` and `_ln rdf:rest rdf:nil .` into `pending_bnpl_triples`; + the collection *term* is `_l1`. Items `gi` are full `GraphNode`s (vars, + IRIs, literals, blank-node lists → recursion, nested collections → + recursion), covering `syntax-forms-01/02`. + +This adds only ordinary triples over `rdf:first`/`rdf:rest`/`rdf:nil` +(constants already in `fluree-vocab/src/lib.rs:70,73,76`) — **no** new +`GraphPattern`, **no** IR, **no** engine change. `Iri::rdf_type` +(`ast/term.rs:89`) is the pattern to copy for `Iri::rdf_first/rest/nil` +helpers. + +**P4 empty IRIREF.** Delete the `result.is_empty()` rejection at +`lexer.rs:199-201` so `<>` lexes as `Iri("")`. Parse+validate then succeed for +the syntax test; the relative-IRI *resolution* needed for the `base-prefix-1` +eval test is a lowering concern (see §3). + +**P5a extension-function `NIL` arg list.** In `parse_primary_expr`, change the +three `if tokens.check(&TokenKind::LParen)` guards +(`expr.rs:252, 265, 281`) to also accept `TokenKind::Nil`. `parse_expression_list` +(`expr.rs:769-774`) already consumes `Nil` → empty args. One-token change per +branch. + +**P5b bare `Constraint` in `ORDER BY`.** In `parse_order_condition` +(`modifier.rs:239-300`), before the final `return None`, add a +`BuiltInCall | FunctionCall` branch that tries `parse_expression` with the +same `position()`/`restore()` guard `parse_group_condition` +(`modifier.rs:149-164`) uses, wrapping the result as `OrderExpr::Expr`. + +**P6 sub-select placement.** In `parse_group_graph_pattern`: +(a) when the loop body's first token is `KwSelect` and nothing has been +accumulated yet, parse the whole group as a `SubSelect` (the grammar's +`'{' SubSelect '}'` alternative) — this covers `test_21`/`test_23`; +(b) after the `{`-detected subquery branch (`pattern.rs:122-131`), mirror the +nested-group branch and check for a trailing `KwUnion`, calling +`parse_union_continuation` — this covers `test_64`. + +**P7 `VALUES` `NIL` var list.** In `parse_values_variables` +(`pattern.rs:487-523`) accept a leading `Nil` token as "zero variables"; add a +zero-variable data-block path in `parse_values_pattern` (`pattern.rs:432-482`) +that reads `NIL`/`()` rows (each an empty row) until `}`. Guard the +`multi_var` dispatch (`pattern.rs:438`) for the empty case. + +**P8 path in `[ … ]`.** Replace the `Verb::Path(_)` rejection +(`term.rs:453-459`) with a real lowering: emit a `GraphPattern::Path` for the +`[ path obj ]` triple. Because `[ … ]` currently only surfaces +`TriplePattern`s, add a `pending_bnpl_patterns: Vec` companion to +`pending_bnpl_triples` and drain it wherever the triples are drained +(`parse_triples_block` `term.rs:522-567`, `parse_object_list` +`term.rs:677-679`, `parse_path_object_list` `term.rs:946-952`). This also +unblocks `test_pp_coll` once P1 lands. + +### Parser-tighten fixes (V1, V2) — reject-more, grammar + +**V1 dot structure.** Make dots load-bearing: +- In `parse_triples_block` (`term.rs:522-567`), after each + `TriplesSameSubjectPath`, require a `.` before another same-subject block: + if the next token `is_term_start()` without an intervening consumed `.`, emit + a "missing '.'" error (fixes `syn-bad-02/03`). Keep a single trailing `.` + optional. +- Remove the "skip any dot" branch in `parse_group_graph_pattern` + (`pattern.rs:162-164`). Per `GroupGraphPatternSub`, a `.` is legal only + immediately after a `GraphPatternNotTriples`; allow exactly one optional `.` + there and treat a `.` in any other position (leading, doubled, standalone) as + an error (fixes `syn-bad-05..14`). + +**V2 `FILTER` `Constraint`.** In `parse_filter_pattern` +(`pattern.rs:355-370`), require the next token to start a `Constraint` +(`LParen` → bracketed; or a `BuiltInCall`/`FunctionCall` keyword/IRI) and +reject a bare `Var`/literal/relational expression. Reuse the same +`Constraint` predicate introduced for P5b so the two stay consistent. + +### New semantic-validation passes (V3–V6) — reject-more, `validate/mod.rs` + +All four are new passes on the parsed AST inside `validate()` +(`validate/mod.rs:72-76`), off the query hot path. Add `DiagCode` variants +(`diag/mod.rs`) and wire them from `validate_select` (and, where applicable, +`validate_construct`/`validate_ask`/`validate_describe` and sub-selects). + +- **V3 blank-node scope.** New walk that assigns a BGP scope id per + `GroupGraphPattern` boundary (`GRAPH`/`OPTIONAL`/`UNION`/`MINUS`/nested + group/sub-select start a new scope; `FILTER`/`BIND` do not), collects the + labeled blank nodes appearing in each scope, and errors if any label appears + in ≥2 scopes. Watch the FILTER/BIND nuance (see §5 risk). +- **V4 projection scope.** In `validate_select`: detect grouping + (`modifiers.group_by.is_some()` or any aggregate in the projection); build + the group-key var set; then reject `SELECT *`-with-`GROUP BY`, and reject any + projected `Var`/expression-free-var that is neither a key nor inside an + aggregate. Needs an `Expression::contains_aggregate()` predicate and a + free-variable collector (memory notes a `variables()` helper already exists + on expressions — reuse it). +- **V5 BIND scope.** Ordered in-scope-variable accumulation per group; reject a + `BIND` whose target is already in scope. Reuse the §18.2.1 in-scope + definition; nested groups / unions / sub-selects contribute their visible + variables. +- **V6 duplicate alias.** Collect `SELECT` `AS` targets; reject a repeat (and, + per spec, a collision with a WHERE-visible variable — the test only needs the + repeat case). + +--- + +## 3. Hot-path classification + +**The cluster is 100% parse-time / validate-time for the engine.** No shared +runtime (scan/join/filter/aggregate execution) code is touched by any fix. Two +lowering/prepare-time caveats, neither on the per-row path: + +1. **P1 collections lower to `rdf:first`/`rdf:rest`/`rdf:nil` BGP triples** — + ordinary triples the engine already executes. Confirmed the eval side + matches: Fluree's Turtle ingest *also* desugars collections to the same + predicates (`fluree-graph-turtle/src/parser.rs:18` `RDF_FIRST`, `:298` + `rdf_first`, `:308` `rdf_rest`). So the parse-time desugaring is spec-correct + **and** eval-compatible: it additionally turns the currently-registered eval + failures `basic#list-1..4` (`SPARQL10_QUERY_EVAL`) and + `construct#constructlist` (`SPARQL11_CONSTRUCT`) green. **Cross-cluster + coordination:** those register entries live in other clusters and must be + removed in the same PR that lands P1, or the both-directions register check + will flag them as stale passes. + +2. **P4 `base-prefix-1` eval** needs relative-IRI resolution against `BASE` + (`<>` → the base IRI; `:x` → base + `x`) at **lower/prepare time**, not + per-row. The lexer fix (mine) makes it parse; the resolution overlaps the + eval cluster's `basic#base-prefix-2/5` (`SPARQL10_QUERY_EVAL`, result + mismatch). The *syntax* test `syntax-qname-05` needs only the lexer fix + (parse+validate). Flag base-relative resolution as shared with the eval + cluster. + +No `regression-budget.json` benches (`query_hot_bsbm*`, `insert_formats`, +`import_bulk`) exercise this cluster; the lexer change is SPARQL-query-only (not +the Turtle import lexer), so `import_bulk` is unaffected. No bench guardrail is +required beyond the standard suite run. + +--- + +## 4. JSON-LD parity (per `sparql-compliance.md` §"Query Surface Parity") + +Scope note: **Cypher is out of scope** for this burn-down — it is openCypher +(Fluree does not own the grammar and will add no custom syntax); it benefits +implicitly from any IR/engine-level fix and needs no assessment or +support-matrix work. Fluree **does** own the JSON-LD query syntax, so the +"SPARQL-possible ⇒ JSON-LD-possible" rule stands and JSON-LD regression tests +are named per fix below. + +Grammar-tightening and validation fixes (V1–V6) **reject** invalid input; they +add no capability. Most (V1, V2, V3, V6) are SPARQL-surface-syntax concerns +with no JSON-LD analogue. Two carry genuine cross-surface semantics: + +- **V4 (group/projection scope)** and **V5 (BIND scope)** are semantic rules + that also apply to JSON-LD *analytical* queries (which share the IR and can + express `groupBy`/`bind`). Decision to record: implement the check in the + SPARQL `validate()` pass now (satisfies the W3C cluster), **and** author + JSON-LD analytical regression tests asserting the same rejection + (`fluree-db-api/tests/it_query_analytical.rs`, + `it_query_grouping.rs`) — or, preferably, factor the scope/projection check + into a shared checker the JSON-LD lowerer also calls. This is the parity + "definition of done" per the team guideline. + +The one capability-adding fix is **P1 collections**: + +- It is SPARQL **surface sugar** that desugars to `rdf:first`/`rdf:rest` + triples the engine already supports — **no new IR capability**. JSON-LD can + already express the equivalent RDF list via `@list` / explicit + `rdf:first`-`rdf:rest`; add a JSON-LD regression test + (`fluree-db-api/tests/it_query.rs`) asserting a `@list`/first-rest pattern + matches the same data a SPARQL `( … )` pattern matches, to guard the shared + engine path. + +P4/P5/P6/P7/P8 are SPARQL-only surface syntax (relative IRIs, function-call +`NIL`, sub-select placement, `VALUES` `NIL`, path-in-`[ ]`) with no JSON-LD +syntax equivalent and no new engine capability — no parity work, record as +SPARQL-surface-only. + +--- + +## 5. Blast radius, PR composition, risks, open questions + +### Files touched (all in `fluree-db-sparql/`, plus register + parity tests) + +| Fix | Files | LOC (ballpark) | +|---|---|---| +| P1 | `parse/query/term.rs` (collection parser + drain), `ast/term.rs` (rdf list Iri helpers) | ~120 | +| P4 | `lex/lexer.rs` (1 guard) | ~5 | +| P5a | `parse/expr.rs` (3 guards) | ~6 | +| P5b | `parse/query/modifier.rs` | ~20 | +| P6 | `parse/query/pattern.rs` | ~40 | +| P7 | `parse/query/pattern.rs` | ~40 | +| P8 | `parse/query/{term.rs,mod.rs}` (add `pending_bnpl_patterns`) | ~40 | +| V1 | `parse/query/{pattern.rs,term.rs}` | ~60 | +| V2 | `parse/query/pattern.rs` (+ shared `Constraint` predicate) | ~25 | +| V3 | `validate/mod.rs`, `diag/mod.rs` | ~90 | +| V4 | `validate/mod.rs`, `ast/expr.rs` (aggregate/var helpers), `diag/mod.rs` | ~120 | +| V5 | `validate/mod.rs`, `diag/mod.rs` | ~100 | +| V6 | `validate/mod.rs`, `diag/mod.rs` | ~30 | + +Plus register edits (`testsuite-sparql/tests/registers/mod.rs`: remove the +62 entries as they go green, plus the P1 cross-cluster `list-1..4`/`constructlist` +entries) and parity tests (§4). + +### Suggested PR composition + +This cluster is the first, lowest-risk burn-down PR — but the risk is **not** +uniform, so split it into three landable PRs, safest first: + +- **PR-1 "parser accepts valid syntax" (P1, P4, P5a, P5b, P6, P7, P8).** + Pure accept-more; cannot reject any currently-accepted query. Clears all 25 + positive-rejected tests plus (via P1) the cross-cluster `list-1..4` / + `constructlist` eval entries. Ships the collection/`@list` and function-`NIL` + JSON-LD parity tests. Lowest risk; land first. +- **PR-2 "semantic validation" (V3, V4, V5, V6).** New reject-more passes in + `validate()`. Clears 24 negative tests (V3:11, V4:9, V5:3, V6:1). Ships the + JSON-LD analytical group/BIND-scope parity tests. Moderate risk (rejects + previously-accepted invalid queries — see below). +- **PR-3 "dot + FILTER grammar tightening" (V1, V2).** Highest regression + surface; isolate so a bisect is clean. Clears 13 negative tests (V1:12, V2:1). + +If a single PR is mandated, land in that internal order and call out PR-3's +scope explicitly in the description. + +### Risks (regression to non-W3C users) + +- **V1 (dots) — highest.** Dot handling is central to BGP parsing. Real users + may rely on Fluree's current leniency (trailing/omitted dots, `. .`). The fix + changes those from silently-accepted to errors. Must run the full W3C suite + + `cargo test -p fluree-db-sparql` + the JSON-LD suite, and grep app + corpora for lenient dot usage before landing. +- **V2 (FILTER)** — Fluree currently accepts `FILTER ?x` and + `FILTER ?x > 5` (no parens). Tightening rejects them. Legitimate per spec but + a behavior change; announce it. +- **V3–V6 validation** — each converts a currently-accepted (spec-invalid) + query into an error: reused blank-node labels, non-grouped projections, + BIND-over-bound-var, duplicate aliases. These are correctness improvements but + are still breaking for anyone who leaned on the old behavior (esp. V4: + `SELECT ?x ?y … GROUP BY ?x` currently returns *something*). Gate behind a + clear diagnostic + release note; consider whether any should be a *warning* + under a capability flag rather than a hard error. +- **P4/P5a/P6/P7 (accept-more)** — negligible; they can only turn prior errors + into successes. One watch-item: P6's "bare `SELECT` = sub-select" must fire + only when the group is genuinely a sub-select (SELECT is a reserved keyword + that cannot begin a triple, so ambiguity is nil). +- **P8 structural** — adding `pending_bnpl_patterns` touches the shared + `[ … ]` drain sites (`term.rs:522-567, 677-679, 946-952`); keep the existing + triple path byte-identical and only *add* the pattern channel. + +### Open questions + +1. **V3 BGP boundary + FILTER/BIND.** The correct scope unit is the *BGP* + (join scope), not each `GraphPattern::Bgp` node — the parser emits a fresh + `Bgp` node across a `FILTER`/`BIND` in the *same* group, so a naive + per-`Bgp`-node check would wrongly reject `_:a … FILTER(…) … _:a` (legal, + one BGP). No failing test exercises this, but the pass must merge + same-group `Bgp` nodes to avoid a false-positive regression. Confirm the + intended boundary set (GRAPH/OPTIONAL/UNION/MINUS/nested-group/sub-select + only). +2. **V4/V5 parity locus.** Implement group/BIND-scope validation in the + SPARQL-only `validate()` (fastest for the W3C cluster) or in a shared + IR-level checker the JSON-LD lowerer also invokes (parity across SPARQL + + JSON-LD)? Recommendation: shared checker if cheap; otherwise SPARQL pass + + authored JSON-LD analytical regression tests. Needs a call from the team. +3. **P4 base-relative resolution ownership.** The `base-prefix-1` *eval* pass + depends on relative-IRI-against-`BASE` resolution at lower time, shared with + the eval cluster's `base-prefix-2/5`. Confirm which PR owns that resolution + so `base-prefix-1`'s eval-register entry is removed by the right change (the + *syntax* `syntax-qname-05` is fully mine via the lexer fix). +4. **V4 aggregate detection** needs `Expression::contains_aggregate()` and a + reliable free-variable collector. Confirm the existing expression + `variables()` helper covers aggregates/nested function args before building + on it. diff --git a/docs/audit/burn-down/residual-eval.md b/docs/audit/burn-down/residual-eval.md new file mode 100644 index 0000000000..9229fa6d17 --- /dev/null +++ b/docs/audit/burn-down/residual-eval.md @@ -0,0 +1,494 @@ +# Residual 1.1 Eval Mismatches — Deep Audit (burn-down) + +**Scope:** the residual SPARQL 1.1 query-evaluation failures assigned to this +cluster — CONSTRUCT (`constructwhere04`, `constructlist`), SUBQUERY +(`subquery02/04/12`), EXISTS (`exists03`), PROJECT_EXPRESSION (`projexp05`), +AGGREGATES (`agg02`, `agg-err-01`, `agg-empty-group-count-graph`, +`agg-count-rows-distinct`), PROPERTY_PATH (`pp16`, `pp34`, `pp35`, `pp36`), and +the ENTAILMENT framing. Companion to +`docs/audit/2026-07-sparql-testsuite-audit.md` (§4.2, §6). No source was +modified producing this document; every root cause below was verified by reading +engine code and/or reproduced against a live in-memory ledger via the CLI. + +Baseline: branch `test/sparql-testsuite-full-coverage`, rdf-tests submodule +`efccbc6b8`. + +## 1. Executive summary + +The 15 residual tests decompose into **four** root-cause families, not the +"per-category" grouping the register implies. The single most important finding +is a **reframing of the property-path work**: the audit (§6 item 3) frames +`pp16/pp34/pp35/pp36` as "path multiplicity semantics" needing a "plan-time +bag-vs-set selection." That is not what these tests need. Sequence-path +multiplicity is **already correct and already regression-guarded** +(`fluree-db-api/tests/it_query_seq_path_count_repro.rs`); no plan-time +bag/set switch is required, and the `*`/`+` distinct fast path is **untouched** +by any fix here. The real property-path defects are (a) an incomplete +zero-length node set (`pp16`) and (b) an empty-schema batch that drops the unit +row (`pp36`); `pp34/pp35` are not property-path bugs at all — they are blocked by +named-graph IRI resolution and belong to the graph cluster. + +Family map (detail in §3): + +| Family | Tests | Owner PR | +|---|---|---| +| **A. Named-graph resolution / GRAPH-var semantics** | pp34, pp35, exists03, subquery02, subquery04, agg-empty-group-count-graph | Graph PR (audit C4) — shared with the graph cluster | +| **B. Property-path operator** | pp16, pp36 | Property-path PR (audit C3) | +| **C. Expression / aggregate semantics** | projexp05, agg02, agg-err-01, agg-count-rows-distinct, subquery12 | Expression-semantics PR (audit C1/C2) | +| **D. Parser / dataset gaps** | constructlist, constructwhere04 | constructlist → syntax PR (B3); constructwhere04 → dataset PR (C4/§5.3) | + +Of the 15, **6 are already latent passes** waiting only on the graph fix +(Family A), **2 are the genuine property-path centerpiece** (Family B), **5 are +expression/aggregate fixes** (Family C), and **2 are parser/dataset** (Family D). + +--- + +## 2. The property-path reframing (centerpiece) + +The team brief asked for a "plan-time selection of bag-semantics path counting" +that "preserves the `*`/`+` distinct fast path." I verified this is **not +needed** for any test in scope, and documenting why is the most load-bearing +result here. + +### 2.1 Sequence multiplicity is already correct + +A sequence path `?s a/b+ ?o` lowers to a **BGP chain joined by fresh +path-internal variables** — not to a single distinct-pair path operator. +`fluree-db-sparql/src/lower/path.rs:362-426` (`lower_sequence_chain`) emits +`Triple(?s, a, ?__pp0)` + `PropertyPath(?__pp0, b+, ?o)`, i.e. the intermediate +`?__pp0` is a real join variable. Per SPARQL 1.1 §18.2.2.4 the join over that +variable **preserves multiplicity**: an `?o` reachable through two intermediates +is counted twice. This is exactly the spec-correct "bind-`?mid`" cardinality. + +This is not theoretical — it is pinned by a dedicated regression test: +`fluree-db-api/tests/it_query_seq_path_count_repro.rs` asserts `COUNT(*)` equals +the bind-`?mid` join cardinality (e.g. `ex:s ex:p1/ex:p2+ ?o` = 4, not the +distinct-pair 3) on **both** the in-memory generic operator and the indexed +fast path (the file documents "Defect 1"/"Defect 2" that were already fixed). + +**Consequence:** there is no bag-vs-set decision to make at plan time. The +`*`/`+` transitive operator correctly emits **distinct nodes** per source +(`fluree-db-query/src/property_path.rs`, BFS with a `visited` set), and sequence +multiplicity comes from the surrounding BGP join, already in place. None of the +fixes below alters this operator's traversal or its distinct semantics, so there +is **zero perf change for `*`/`+`** and no new plan-time branch. + +### 2.2 What pp34/pp35 actually are + +`pp34` = `GRAPH { ?s :p1* ?t }`; `pp35` = `GRAPH ?g { ?s :p1* ?t } +FILTER(?g = )`. Both currently return **0 rows** (expected `[a,b,b]`). +The named graph `ng-01.ttl` **is** loaded (via `qt:graphData`), so the `[a,b,b]` +result is producible today *if the GRAPH block matched*: within `ng-01` +(`:a :p1 :b`) the closure emits the distinct pairs `(a,a),(b,b),(a,b)`, and +projecting `?t` (no `DISTINCT`) preserves the bag → `[a,b,b]`. The multiplicity +is therefore **already correct**; the blocker is that `GRAPH ` never +resolves to the loaded graph (Family A, §3.1). These are graph-cluster tests +mis-filed under property-path. After the graph fix they should pass with no +property-path change — a claim to **verify** post-fix, not assume. + +--- + +## 3. Per-test root causes (with evidence) + +### 3.1 Family A — named-graph resolution / GRAPH-var semantics + +All five share one or more of three engine defects in +`fluree-db-query/src/graph.rs` (confirmed by code read). These are the **graph +cluster's** fixes; listed here because these tests can't pass without them and +because the audit filed them under my categories. + +**Defect A1 — constant graph IRI never matches the loaded graph.** +Lowering base-expands `GRAPH ` by naive string concat +(`fluree-db-sparql/src/lower/term.rs:416-420`, via `lower/pattern.rs:232`) into +`IrGraphName::Iri("{base}ng-01.ttl")`. Runtime does an **exact** `HashMap` +key match (`graph.rs:587`/`633` → `dataset.rs:218-219` +`named_graphs.contains_key`, no IRI normalization). The loader registers the +named graph under the file URL from `qt:graphData`; the query's base-expanded +string differs, so the lookup misses and the block silently returns empty +(`graph.rs:597`). +→ **Blocks pp34, pp35, exists03** (all use `GRAPH `). + +**Defect A2 — GRAPH graph-variable bound as a plain literal, not an IRI.** +`GraphOperator::execute_in_graph` binds `?g` as +`Binding::Lit { val: String(graph_iri), dtc: Explicit(xsd:string) }` +(`graph.rs:314-323`; the wrong behavior is even codified in the module doc at +`graph.rs:10`). Per SPARQL `?g` must be an IRI term. +→ Contributes to **agg-empty-group-count-graph** (result shows +`g: Literal("…singleton.ttl")` instead of `Iri(...)`). + +**Defect A3 — default graph leaks into `GRAPH ?g` enumeration (single-db).** +The unbound-`?g` fan-out appends the ledger alias (== default graph) to the +enumerated set (`graph.rs:700-709`); dataset mode is correct (`graph.rs:675-685` +iterates only `named_graph_iris()`). +→ **Blocks subquery04** ("default graph does not apply"): the extra solution +`x=instance#no` comes from `sq04.rdf`, which is loaded into the **default** +graph and must not appear under `GRAPH ?g`. + +**Defect A4 — GRAPH graph-var not correlated with a subquery projecting the +same variable.** For `GRAPH ?g { { SELECT * WHERE { ?x ?p ?g } } }` the inner +subquery is seeded from the parent row only, which does **not** contain `?g` +(`graph.rs:258-266`); `SubqueryOperator` therefore computes empty +`correlation_vars` for `?g` (`subquery.rs:112-124`) and runs uncorrelated, and +`GraphOperator` then **overwrites** `?g` with the graph IRI instead of joining +on it (`graph.rs:312-323`). Result: every triple in the graph survives with `?g` +stamped over it. +→ **Blocks subquery02**: expected 1 row (`x=c`, the only triple whose object +equals the graph name); Fluree returns 2 (`x=a` and `x=c`) because the +`?g == graph-name` join is never applied. + +**exists03 — additional sub-check.** Beyond A1, `exists03` specifically tests +that `FILTER EXISTS { ?s ?p ex:o2 }` is evaluated **within** the enclosing named +graph (`mf:name` = "Exists within graph pattern"). Even after A1 is fixed, the +EXISTS sub-plan must inherit the active named graph, not fall back to the default +graph. This is a distinct correctness point to verify once GRAPH resolution +lands; it is not separately reproducible until then. + +**agg-empty-group-count-graph** additionally needs empty named graphs to be +enumerable: expected `(empty.ttl, 0)` requires the empty named graph to yield a +`count=0` row, but Fluree does not track empty named graphs as first-class +graph-store entries (noted in `query_handler.rs:441-446`). This is a +graph-model limitation on top of A2/A3; likely deferred with a register entry +even after the core graph fix. + +### 3.2 Family B — property-path operator (the genuine fixes) + +**pp16 — zero-length node set is incomplete.** Query `?X foaf:knows* ?Y`; +expected 15, got 13. Missing exactly the two reflexive pairs `(h,h)` and +`("test","test")`. Root cause: the both-variables `*`/`?` closure builds its node +set **only from edges of the path predicate** and **only from ref objects**: + +``` +// fluree-db-query/src/property_path.rs:616-623 (compute_closure) +let mut ingest = |flake| { + if let FlakeValue::Ref(o) = flake.o { // literal objects skipped + nodes.insert(flake.s); nodes.insert(o); + adj.entry(flake.s).or_default().push(o); + } +}; +// ...scan is restricted to self.pattern.predicates (foaf:knows) — :644-660 +``` + +The reflexive pairs are emitted per node in `nodes` (`property_path.rs:733-735`). +So `h` (object of `foaf:homepage`, a different predicate) and the literal +`"test"` (object of `foaf:name`) never enter `nodes`, and their `(n,n)` pairs are +lost. Per SPARQL 1.1 §18/§9.3, a zero-length path with both endpoints variable +ranges over **every term in subject or object position of the active graph**, +regardless of predicate, **including literals**. This is a distinct-node +completeness bug, **not** a multiplicity bug. + +**pp36 — matching unit row dropped by empty-schema batch.** Query +`:a0 (:p)* :a1` (both endpoints constant); expected 1 empty solution (ASK-true), +got 0. The both-constants arm correctly computes reachability and stores a dummy +pair to signal "1 row" (`property_path.rs:954-968`), but `next_batch` then +materializes columns from the **empty** `in_schema` and calls `Batch::new`, +which infers `len` from the (absent) first column and collapses to `len = 0`: + +``` +// property_path.rs:1184-1213 (unseeded) and :1259-1272 (correlated) +let columns = self.in_schema.iter().map(...).collect(); // [] — zero columns +let batch = Batch::new(self.in_schema.clone(), columns)?; // empty schema => len 0 +``` + +`Batch::new`'s len-loss on empty schema is a pinned invariant +(`fluree-db-query/src/binding.rs`, test `test_batch_new_loses_len_for_empty_schema`). +The correct idiom already exists and is used by sibling correlated operators: +`GraphOperator::drain_buffer` (`graph.rs:482-489`) and +`SubqueryOperator::drain_buffer` (`subquery.rs:488-495`) guard `num_cols == 0` +and return `Batch::empty_schema_with_len(n)` (`binding.rs:1265`). The +property-path operator lacks that guard. This is the "zero-variable `SELECT *` +projection" symptom in the brief, but the drop happens **inside the path +operator**, not in projection — projection of a wildcard with zero vars is a +no-op (`ir/projection.rs:230-232`; `execute/operator_tree.rs:3351-3361` builds no +`ProjectOperator`) and would preserve a `len=1` empty-schema batch if one reached +it. + +### 3.3 Family C — expression / aggregate semantics + +**projexp05 — `DATATYPE()` on an IRI returns `@id` instead of erroring.** +Query `SELECT ?x ?l (datatype(?l) AS ?dt)` over data where `?l` binds to both an +integer and an IRI. Expected: for the IRI row, `?dt` **unbound** (type error). +Actual (reproduced): `?dt = @id`. Source: `eval_datatype`'s deliberate Fluree +extension for IRI/ref arguments: + +``` +// fluree-db-query/src/eval/rdf.rs:89-92 +// Fluree extension: DATATYPE of an IRI/ref reports the `@id` ref type. +Binding::Sid { .. } | Binding::IriMatch { .. } | Binding::Iri(_) + => Ok(Some(ComparableValue::Sid(WELL_KNOWN_DATATYPES.id_type.clone()))), +``` + +Returning `Ok(Some(@id))` binds `?dt` in the project-expression; the spec +requires a type error (unbound). The very next arm (`rdf.rs:94-96`) already +raises `InvalidExpression` for other non-literals — the IRI case was carved out +above it. **Reproduced** via CLI: `datatype()` → `{"@id":"@id"}`; +`datatype(1)` → `xsd:integer`. + +> **This is a deliberate divergence, not an accident.** Fixing it is a +> SPARQL-conformance vs Fluree-extension decision (see §6 blast radius): the +> `@id` return is used by JSON-LD-surface queries. The fix must be +> **SPARQL-mode-scoped** (error only under SPARQL semantics) or the extension +> must be dropped and documented. + +**agg02 — grouped `COUNT(?var)` is typed `xsd:long`, must be `xsd:integer`.** +Query `SELECT ?P (COUNT(?O) AS ?C) … GROUP BY ?P`. The SPARQL-JSON formatter is +datatype-driven (`fluree-db-api/src/format/sparql.rs:333-335` decodes +`dtc.datatype()`), so the binding's `dtc` really is `xsd:long`. **Isolated by +reproduction** (CLI, in-memory ledger): + +| query | datatype | +|---|---| +| `SELECT (COUNT(*) AS ?C) …` (scalar) | `xsd:integer` ✓ | +| `SELECT ?P (COUNT(*) AS ?C) … GROUP BY ?P` (streaming) | `xsd:integer` ✓ | +| `SELECT ?P (COUNT(?O) AS ?C) … GROUP BY ?P` (streaming) | **`xsd:long`** ✗ | +| same + a non-streamable agg → non-streaming path | `xsd:integer` ✓ | + +The plan for the failing case is `ProjectOperator > GroupAggregateOperator` +(streaming). Every *visible* count-finalization site specifies `xsd:integer` +(`group_aggregate.rs:340` `AggState::Count`; `aggregate.rs:715`; the streaming +`CountAll` pushdown `group_aggregate.rs:797`), and the projection layer does not +re-type (`project.rs` has no datatype logic). The only production `xsd:long` +count site is the indexed top-K path (`fast_group_count_firsts.rs:1548/1588`), +which this query cannot reach (it needs `LIMIT` + `ORDER BY DESC`, +`operator_tree.rs:735-744`). **The defect is therefore localized to the streaming +`GroupAggregateOperator` variable-count path (`input_col = Some`) —** +`COUNT(*)` (input_col `None`) is correct through the same operator, and the +non-streaming operator is correct for `COUNT(?var)`. Fix target: +`fluree-db-query/src/group_aggregate.rs` streaming Count-of-variable +finalization must emit `xsd:integer` to match the other three paths. (The +finalize at `:340` reads `xsd_integer`; because the reproduction contradicts a +purely static read, the implementer should confirm the exact re-typing site with +a one-line probe before patching — the observable contract and the trivial fix +are unambiguous.) + +**agg-err-01 — numeric aggregate over a non-numeric member must error → unbound.** +Query groups `?g :p ?p` and computes `AVG(?p)` / `(MIN+MAX)/2`. Group `#y` +contains a blank node among the numbers; expected: `?avg`/`?c` **unbound** for +`#y`. Actual: `?avg = 2.666…` (computed over the numeric members only). Source: +the numeric aggregates **skip** non-numerics rather than erroring — +`aggregate.rs:737-742` (SUM) and `:750-755` (AVG) iterate +`values.iter().filter_map(binding_to_numeric)`, and `binding_to_numeric` returns +`None` for a blank node / IRI / string (`aggregate.rs:620-640`, final `_ => None`). +The module doc even states the behavior: `aggregate.rs:13` "Numeric aggregates … +skip non-numeric values." MIN/MAX likewise never error (they compare by term +order, `aggregate.rs:759-786`). SPARQL 1.1 §18.5: a type error in an aggregated +expression makes the aggregate an error → the variable is unbound. The streaming +path mirrors the same skip (`group_aggregate.rs:267-273`). + +**agg-count-rows-distinct — `COUNT(DISTINCT *)` lowering not implemented.** +Explicit bail: `fluree-db-sparql/src/lower/aggregate.rs:232` +(`return Err(LowerError::not_implemented("COUNT(DISTINCT *)", …))`) in the +`(Count, None)` arm — `input_var` is `None` iff the argument was `*`. Plain +`COUNT(*)` and `COUNT(DISTINCT ?v)` both work (`aggregate.rs:228-249`). +`COUNT(DISTINCT *)` counts distinct whole solution rows, so it needs a new IR +aggregate that dedups over the full in-scope tuple (not a single column). + +**subquery12 — CONSTRUCT over a sub-SELECT with `CONCAT` yields 0 triples.** +Query: `CONSTRUCT { ?P foaf:name ?FullName } WHERE { SELECT ?P (CONCAT(?F," ",?L) +AS ?FullName) WHERE { ?P foaf:firstName ?F ; foaf:lastName ?L } }`. Expected 1 +triple, got 0. No GRAPH involved — this is a CONSTRUCT-template + subquery-alias +interaction: the outer template references `?FullName`, which is produced only by +the inner sub-SELECT's project-expression. The likely cause is that the sub-SELECT +alias `?FullName` is not visible to / not joined into the CONSTRUCT template +binding (analogous to A4's subquery-projection scoping, but on the CONSTRUCT +path). This one I could not fully isolate by static read; **flag for a focused +repro** during the expression-semantics PR (build the two-column sub-SELECT, dump +the pre-template solution to confirm `?FullName` is bound before template +instantiation). + +### 3.4 Family D — parser / dataset gaps + +**constructlist — RDF collection `( … )` syntax unsupported.** Query +`CONSTRUCT { (?s ?o) :prop ?p } WHERE { ?s ?p ?o }`. The `(?s ?o)` collection in +subject position is rejected at parse time: +`fluree-db-sparql/src/parse/query/term.rs:87-94` (subject) and `:220-227` +(object) emit "RDF collection (list) syntax is not yet supported" and +`skip_collection` (`term.rs:483-500`). The tokens (`LParen`/`Nil`) are +recognized but there is no expansion to `rdf:first`/`rdf:rest`/`rdf:nil`. Affects +both CONSTRUCT templates and WHERE clauses. + +**constructwhere04 — `FROM` on a single-ledger GraphDb.** Query +`CONSTRUCT FROM WHERE { ?s ?p ?o }`. Fails with "SPARQL FROM/FROM NAMED +clauses are not supported on a single-ledger GraphDb." This is the dataset +(`FROM`/`FROM NAMED`) gap (audit §5.3); folds into the dataset PR, not a +CONSTRUCT-specific fix. + +--- + +## 4. Hot-path classification per fix (perf-safety, audit §6) + +| Fix | Phase | Per-row hot path? | Perf note | +|---|---|---|---| +| A1 graph-name resolution | prepare (lowering + lookup) | No | Off hot path; string/normalization at plan/lookup time | +| A2 GRAPH-var IRI binding | per-graph (once per enumerated graph) | No | Constant work per graph, not per row | +| A3 default-graph exclusion | per-graph enumeration | No | Removes one graph from a set; strictly cheaper | +| A4 subquery↔GRAPH-var join | prepare (seed schema) + per-row join | Correlated join only | Adds `?g` to the subquery seed; join already exists for other correlated vars | +| **B/pp16 zero-length node set** | per-query (both-var `*`/`?` closure) | No (not `+`, not bound-endpoint) | Adds a full-graph node scan **only** for both-variable `*`/`?`, already the guarded expensive path (`DEFAULT_MAX_VISITED`, `property_path.rs:49`). `*`/`+`/`?` with a bound endpoint and all `+` closures are byte-identical. | +| **B/pp36 empty-schema batch guard** | operator emit | No | One `num_cols == 0` branch at batch build; mirrors existing `graph.rs:482`/`subquery.rs:488` | +| C/projexp05 DATATYPE | per-row (FILTER/expr eval) | Only the IRI-arg arm | Changes one match arm in a builtin; the literal fast path is unchanged | +| C/agg02 COUNT datatype | per-group finalize | No | Result-literal datatype tag; once per group | +| C/agg-err-01 numeric-agg error | per-row accumulation | Only the non-numeric arm | Replace "skip" with "poison the accumulator"; numeric inputs unchanged | +| C/agg-count-rows-distinct | lowering + per-row dedup | New aggregate only | No impact on existing aggregates | +| D/constructlist | parse time | No | Off hot path entirely | +| D/constructwhere04 | prepare (dataset) | No | Dataset construction, once per query | + +**Bench guardrails.** No existing bench exercises property-path closures — the +hot benches (`query_hot_bsbm`, `query_hot_bsbm_bi`, `insert_formats`, +`import_bulk`, `vector_query`, `fulltext_query` in `regression-budget.json`) do +not touch `PropertyPathOperator`. The pp16/pp36 changes are consequently invisible +to the regression budget and are off the BSBM join/filter hot paths by +construction. **Recommendation:** add a small `query_hot_property_path` bench +(bound-subject `+` closure + a both-variable `*` closure) so the zero-length +node-set change is measured; gate the property-path PR on it and on +`query_hot_bsbm`/`query_hot_bsbm_bi` staying within budget. The expression/aggregate +fixes should gate on `query_hot_bsbm`/`query_hot_bsbm_bi` (FILTER + GROUP-BY hot +paths). + +--- + +## 5. Query-surface parity (SPARQL + JSON-LD) + +Per `docs/contributing/sparql-compliance.md` ("Query Surface Parity"), each fix +must be classified and JSON-LD regression coverage authored where the semantics +are expressible. All fixes here are **IR/engine-level** (they change shared +operators/eval, not SPARQL-only syntax), so they fix the JSON-LD surface +implicitly — but nothing guards it unless we write the test. + +**Cypher is out of scope** for this burn-down: openCypher owns that grammar and +we add no custom syntax, so parity coverage below is SPARQL + JSON-LD only. + +| Fix | JSON-LD expressible? | Regression test to author | +|---|---|---| +| A4 subquery↔GRAPH-var correlation | Yes (JSON-LD sub-selects + `graph`/`from`) | `it_query.rs`: named-graph sub-select whose projected var equals the graph var; assert the join | +| A3 default-graph exclusion | Yes | `it_query.rs`: default + named data, `graph` query must exclude default | +| pp16 zero-length node set | **SPARQL-only** surface (property-path syntax) | Engine fix; no JSON-LD path syntax, so no JSON-LD parity test | +| pp36 zero-var path match | **SPARQL-only** | As above; consider whether JSON-LD should express "path exists between two constants" and record the decision | +| projexp05 DATATYPE | Yes (`datatype()` in JSON-LD) | `it_query.rs`: `datatype(?l)` where `?l` is an IRI → var unbound **under SPARQL mode**; JSON-LD may keep the `@id` extension (record the mode split) | +| agg02 COUNT type | Yes | `it_query_grouping.rs`: grouped `COUNT(?v)` asserts `xsd:integer` (this is the surface that currently regresses — the JSON-LD `count` path shares the operator) | +| agg-err-01 numeric-agg error | Yes | `it_query_grouping.rs`: `avg`/`min`/`max` over a group with a non-numeric member → unbound | +| agg-count-rows-distinct | Yes | `it_query_grouping.rs`: JSON-LD `countDistinct` over `*` / all-vars | +| subquery12 CONSTRUCT-from-subselect | Partial | SPARQL `it_query_sparql.rs`; JSON-LD has no CONSTRUCT — the shared bit is sub-select alias visibility, coverable in `it_query.rs` | +| constructlist | SPARQL-only (RDF collection syntax) | SPARQL only | +| constructwhere04 (dataset) | SPARQL-only (`FROM`) | Dataset PR owns it | + +Files: SPARQL → `fluree-db-api/tests/it_query_sparql.rs`; JSON-LD → +`it_query.rs` / `it_query_grouping.rs` / `it_query_analytical.rs`. + +--- + +## 6. Entailment framing verdict + candidate wins + +**Verdict on the 20/50 split: confirmed, with a sharper mechanism than "declares +no regime."** The 20 passing tests are answerable by matching **asserted** +triples — no materialized inference required — even when they *declare* an RDFS/D +regime. Verified examples: `rdfs08` (regime `ent:RDFS ent:D`) passes because it +queries `ex:d rdfs:range ?x` and the `rdfs:range` triple is asserted verbatim; +`parent2` passes because it queries the asserted `:hasChild` property. The +`bind0x` group is BIND-driven (no inference). So "simple-entailment-answerable" +is correct; the accurate phrasing is "the expected answer coincides with the +no-reasoning answer." + +**The 50 failing genuinely need materialized entailment** (spot-checked 5): +`rdfs04` (subClassOf), `rdfs09` (3-level subClassOf transitivity), `rdfs03` +(subPropertyOf + domain), `parent3` (owl:Restriction someValuesFrom), plus the +RIF/OWL-DL families. Each requires triples that are not asserted. + +**Candidate wins — YES, but gated.** Fluree ships an **OWL2-RL forward-chaining +materializer** (`fluree-db-reasoner/`) that already implements the rules these +tests need: **cax-sco** (subClassOf, `execute/class_rules.rs:21`, transitive via +fixpoint), **prp-spo1** (subPropertyOf), **prp-dom** (domain), **prp-rng** +(range), plus cls-\* restriction rules and eq-\* sameAs (`compile.rs:184-339`). +Reasoning is opt-in and can be enabled on the **same** `Fluree::query(&db, +sparql)` path the harness uses — via a `# PRAGMA reasoning: owl2rl` comment in +the query text (`fluree-db-sparql/src/parse/query/mod.rs:86-116`), a +`.with_reasoning` view wrapper, or a ledger-config default. `owl2rl` mode does +query-time **materialization** (a derived-facts overlay, +`reasoning_prep.rs:232-296`), so a generic SELECT sees the inferred triples. + +Concrete candidate wins with the **existing** reasoner (no reasoner code): +`rdfs04`, `rdfs09`, `rdfs03`, and — pending per-test check — `rdfs05/06/07` +(subproperty/domain/range family), `rdfs10/11` (subClassOf variants), and the +`parent*` restriction cases (via cls-svf/hv). + +**Three hard caveats:** +1. **A harness change is required** — inject `# PRAGMA reasoning: owl2rl` per + test (or wrap the db) in `query_handler.rs:342-349`, which today does zero + reasoning. This is harness plumbing, not reasoner code, but it is not + zero-touch. +2. **Use `owl2rl`, not `rdfs`** — the `rdfs` mode only *rewrites* `rdf:type`/ + subproperty patterns (`rewrite.rs`) and produces nothing for a generic + `?s ?p ?o` scan; domain/range live only in `owl2rl`. +3. **OWL2-RL ≠ full RDFS entailment** — the materializer does not emit RDFS + axiomatic-closure triples (reflexive subClassOf, `rdf:type rdfs:range + rdfs:Class`, container-membership axioms). A test expecting the full RDFS + closure can still fail with `owl2rl` on, and reasoning **cannot be enabled + globally** — it would add inferred rows that break some of the 20 currently + passing. The realistic plan: keep the 50-test register, but pull a **named + subset** (start with `rdfs04`, `rdfs09`, `rdfs03`) into a small + reasoning-enabled harness variant that injects the pragma per test, and + burn them down there. **Not winnable at all:** RIF (`rif01/03/04/06`) and + full OWL-DL (`paper-sparqldl-*`, `sparqldl-*`) — out of the OWL2-RL profile. + +--- + +## 7. Blast radius + PR mapping + +**Graph PR (audit C4) — Family A (pp34, pp35, exists03, subquery02, +subquery04, agg-empty-group-count-graph).** High blast radius: A1–A4 touch every +named-graph query. Owned by the graph cluster; this cluster contributes the +**test-level acceptance criteria** and the two extra sub-checks (EXISTS +active-graph scoping for `exists03`; empty-named-graph enumeration for +`agg-empty-group-count-graph`). After the graph fix, **re-verify** pp34/pp35 +yield `[a,b,b]` (multiplicity is already correct, §2.2) and that subquery02 +drops the uncorrelated row. Register: move pp34/pp35 out of the property-path +register into the graph register (they are graph tests). + +**Property-path PR (audit C3) — Family B (pp16, pp36).** Contained blast radius: +touches only `PropertyPathOperator` (`property_path.rs`) — pp16 the both-variable +`*`/`?` closure node set (`:616-661`, `:733`) and the correlated both-var path; +pp36 the two empty-schema batch builds (`:1212`, `:1271`). No change to `+`, to +bound-endpoint traversal, to sequence lowering, or to the distinct fast path. +Add `query_hot_property_path` and gate on it. **The centerpiece needs no +plan-time bag/set selection** — that framing is retired (§2). + +**Expression-semantics PR (audit C1/C2) — Family C (projexp05, agg02, +agg-err-01, agg-count-rows-distinct, subquery12).** +- projexp05: **decision required** — SPARQL-conformant type error vs. Fluree's + `@id` extension. Recommend a SPARQL-mode-scoped error so the JSON-LD extension + survives; blast radius otherwise touches every JSON-LD query relying on + `datatype() = @id`. +- agg02: low blast radius — one datatype tag on the streaming variable-count + path; other three count paths already correct. +- agg-err-01: medium — changes results for any group mixing numeric and + non-numeric values; poison-on-non-numeric in SUM/AVG (and MIN/MAX + arithmetic). Confine to numeric aggregates. +- agg-count-rows-distinct: additive (new IR aggregate + lowering); no impact on + existing aggregates. +- subquery12: needs a focused repro first (§3.3); likely the same + subquery-alias-visibility class as A4 but on the CONSTRUCT path. + +**Syntax PR (audit B3) — constructlist.** Parser feature (RDF collection +expansion to `rdf:first`/`rest`/`nil`); moderate parser blast radius, off hot +path. + +**Dataset PR (audit C4/§5.3) — constructwhere04.** Folds into the +`FROM`/`FROM NAMED` dataset-strategy work. + +--- + +## 8. Appendix — reproductions (CLI, in-memory ledger) + +All against `target/debug/fluree` on a fresh in-memory ledger. + +- **agg02 datatype isolation** (data `:s :p1 :o1,:o2,:o3; :s :p2 :o1,:o2`): + `SELECT (COUNT(*) AS ?C)` → `xsd:integer`; + `SELECT ?P (COUNT(*) AS ?C) … GROUP BY ?P` → `xsd:integer`; + `SELECT ?P (COUNT(?O) AS ?C) … GROUP BY ?P` → **`xsd:long`**; + same + `GROUP_CONCAT` (forces non-streaming) → `xsd:integer`. Plan of the + failing case: `ProjectOperator > GroupAggregateOperator > DatasetOperator`. +- **projexp05 DATATYPE** (data `in:a ex:p 1 ; ex:p ex:a`): + `SELECT (datatype(?l) AS ?dt) WHERE { ?x ex:p ?l }` → + IRI row `{"@id":"@id"}`, integer row `xsd:integer`. +- **pp16 / pp34 / pp35 / pp36 / sq02 / sq04 / exists03**: root-caused by code + read against `property_path.rs`, `graph.rs`, `subquery.rs`, and the W3C test + data/manifests; pp34/35 multiplicity confirmed by hand-tracing the closure + over `ng-01.ttl` and the existing `it_query_seq_path_count_repro.rs` guard. diff --git a/docs/audit/burn-down/sparql12-wave1.md b/docs/audit/burn-down/sparql12-wave1.md new file mode 100644 index 0000000000..5800fd5333 --- /dev/null +++ b/docs/audit/burn-down/sparql12-wave1.md @@ -0,0 +1,518 @@ +# SPARQL 1.2 Wave 1 — Turtle-star Ingest + Mini-features Burn-down + +**Clusters (this doc owns these registers):** `SPARQL12_VERSION` (3), +`SPARQL12_LANG_BASEDIR` (10), `SPARQL12_CODEPOINT_ESCAPES` (6), +`SPARQL12_RDF11` (3), `SPARQL12_GROUPING` (1), `SPARQL12_EXPRESSION` (1), +`SPARQL12_SYNTAX` (2), `SPARQL12_EVAL_TRIPLE_TERMS` (41). Total **66** tests. + +**Boundary with siblings:** `docs/audit/burn-down/sparql12-wave2-triple-terms.md` +owns the two `SPARQL12_SYNTAX_TRIPLE_TERMS_*` registers (query-side `<< >>` / +`<<( )>>` parsing) and the D3 triple-terms-as-values decision. That doc +explicitly defers `SPARQL12_EVAL_TRIPLE_TERMS` (data-load) to this doc. The two +meet at exactly one seam: **most eval tests need BOTH wave-1 ingest (data loads) +AND wave-2 query syntax (`<< >>` patterns parse) to go green.** Only a small +subset passes on ingest alone (§3.4). + +**Method:** every claim below is grounded in the baseline error JSONs +(`scratchpad/w3c-baseline2/sparql12_*.json`), the W3C `.rq`/`.ttl`/`.srj` files +under `testsuite-sparql/rdf-tests/sparql/sparql12/`, and the current Rust +source. Where I reversed the audit's diagnosis I say so and give the evidence. + +--- + +## 0. Two headline corrections to the §4.3 audit + +Before the per-register detail, two register comments are **wrong** and should +be fixed in the same PR that touches them: + +1. **`SPARQL12_VERSION` is not a VERSION-declaration gap.** `VERSION` is already + lexed (`fluree-db-sparql/src/lex/token.rs:790` → `KwVersion`) and parsed + (`parse/query/mod.rs:240` `parse_version_decl`, "lex-and-accept, ignore the + value"). Proof: version-03/04/06 — which all carry `VERSION "..."` — **pass + today**; version-01/02/05 fail. The only difference is the WHERE body: + the three failures all contain `<< ?s ?p ?o >>` (a bare reified-triple + pattern), the passes contain plain `?s ?p ?o`. These 3 are **wave-2 + query-syntax** tests (they are `PositiveSyntaxTest`s that go green the moment + wave-2's PR-A makes the parser accept bare `<< >>` patterns). Recommendation: + re-point the register comment at wave-2 and drop the "VERSION declaration" + rationale. **Net wave-1 work for VERSION: zero.** + +2. **`SPARQL12_LANG_BASEDIR` is not a "parser-local and cheap" mini-feature.** + The §4.3 table lumps it with VERSION/codepoint as cheap parser-locals. It is + the **second-largest item in this cluster** after Turtle-star ingest: it + needs a base-direction **representation decision** (the literal has no room + for direction anywhere in the stack — §4.3 below), **4 new functions** + (`LANGDIR`, `STRLANGDIR`, `hasLANG`, `hasLANGDIR`), langtag lexer changes in + **both** the SPARQL and Turtle lexers, and evaluation-semantics work. Treat + it as a design question, not a quick win. + +--- + +## 1. Per-register root cause + evidence + +### 1.1 `SPARQL12_CODEPOINT_ESCAPES` (6) — global `\u` unescape pre-pass — CLEAN WAVE 1 + +| test | kind | file content | today | +|---|---|---|---| +| codepoint-esc-01 | positive | `ASK {}` (whole `ASK {}` escaped) | parser rejects (valid) | +| codepoint-esc-02 | positive | `ns:id\=123` (`\`→`\`, PN_LOCAL_ESC) | parser rejects | +| codepoint-esc-06 | positive | `og:audio%3Atitle` (`%`→`%`, %-encoding in PN_LOCAL) | parser rejects | +| codepoint-esc-07 | positive | `SELECT *\U00000009WHERE` (escaped inter-token whitespace) | parser rejects | +| codepoint-esc-08 | positive | `?s ?p "value"` (`"`→`"` starts a literal) | parser rejects | +| codepoint-esc-bad-03 | **negative** | `"\\u0041"` → decodes to `\A` = invalid string escape | parser **accepts** (should reject) | + +**Root cause.** SPARQL 1.2 requires `\uXXXX`/`\UXXXXXXXX` to be unescaped as a +**global first pass over the whole query string, before tokenizing** — so an +escape may produce a token boundary (esc-07), a delimiter (esc-08), or the whole +query (esc-01). Fluree's lexer only handles `\u` *inside* IRI tokens +(`lex/lexer.rs:181`) and string tokens (`:701`); there is no global pre-pass. +bad-03 is the tell: `"\\u0041"` must unescape codepoints **first** (→ `"\A"`, +then the string-escape pass rejects `\A`); Fluree instead consumes `\\`→`\` as a +string escape and accepts. The manifest comment says it verbatim: "requires +handling codepoint escaping before backslash escaping." + +**Fix.** Add a pre-lex pass `unescape_codepoints(&str) -> Cow` run at the +top of `Lexer::tokenize` before the winnow token pass. Fast path: if the input +contains no `\`, return `Cow::Borrowed` (no allocation). It only rewrites `\u`/ +`\U`; every other `\` is left byte-identical so the downstream string/IRI escape +logic is unchanged. esc-02/06 additionally exercise PN_LOCAL `\=` / `%3A`, which +the prefixed-name lexer already accepts once the `\u` is resolved (esc-04/05, +same family, pass today). This is **parse-time, SPARQL-surface, off every hot +path** (queries are parsed once; not import-hot). Clean wave-1 green: all 6. + +### 1.2 `SPARQL12_SYNTAX` (2) — missing negative-syntax validation — CLEAN WAVE 1 + +| test | query | required behavior | today | +|---|---|---|---| +| nested-aggregate-functions | `SELECT (COUNT(COUNT(*)) AS ?c) WHERE {}` | reject (aggregate inside aggregate is illegal) | accepts | +| duplicated-values-variable | `SELECT * WHERE { VALUES (?a ?a) { (1 1) } }` | reject (duplicate var in VALUES list) | accepts | + +**Root cause.** Two missing checks in the validation pass +(`fluree-db-sparql/src/validate/mod.rs`, `validate()` → diagnostics). Neither is +in the grammar; both are semantic constraints. + +**Fix.** In `validate_select`: (a) walk `SELECT`-expression aggregates and error +if an aggregate argument transitively contains another aggregate; (b) in the +VALUES handler, error on a repeated variable in the var-list. Both are AST +walks, **parse-time, zero engine risk**. JSON-LD parity: JSON-LD query has a +`values` clause — add the duplicate-variable check there too if it is +expressible; nested aggregates likely aren't reachable via the JSON-LD surface +(note the decision either way). + +### 1.3 `SPARQL12_RDF11` (3) — DATATYPE-on-literal + simple-literal identity + +| test | query | expected | today error | +|---|---|---|---| +| langstring-datatype | `DATATYPE("foo"@en)` | `rdf:langString` | `Query error: DATATYPE requires a variable argument` | +| plain-string-datatype | `DATATYPE("foo")` | `xsd:string` | `DATATYPE requires a variable argument` | +| plain-string-same | `ASK { FILTER(sameTerm("foo", "foo"^^xsd:string)) }` | `true` | result mismatch (Fluree ≠ true) | + +**Root cause.** (1) `DATATYPE` (and, per the lang-basedir `datatype` test below, +the general expression evaluator) rejects a **constant literal** argument, +requiring a bound variable. This is an over-narrow guard in expression +lowering/eval. (2) `sameTerm("foo", "foo"^^xsd:string)` must be `true`: RDF 1.1 +makes a simple literal **identical** to its `xsd:string` typed form. Fluree +treats them as distinct terms. + +**Fix.** (1) Allow `DATATYPE`/other unary literal functions over constant +literal operands (evaluate at the constant). This is an IR/engine fix — add a +JSON-LD regression test. (2) Canonicalize plain literals to `xsd:string` at the +term level (RDF 1.1 "simple literal = xsd:string") so `sameTerm` and term +equality collapse them. **Caution:** #2 is a term-identity change that ripples +through equality, DISTINCT, GROUP BY, and index keys — bench-guard and verify it +does not regress the `sparql10` open-world/type-promotion cluster. Schedulable +in wave 1 but it is a semantics fix, not a parser tweak. + +### 1.4 `SPARQL12_GROUPING` (1) — group key uses value, must use term + +`group01`: `SELECT ?v (COUNT(*) AS ?cnt) { ?s :p ?v } GROUP BY ?v` over data +`"1"^^xsd:integer` ×2, `"001"^^xsd:integer`, `"1"^^xsd:string`. **Expected 3 +groups:** `1^^integer` (cnt 2), `001^^integer` (cnt 1), `1^^string` (cnt 1). + +**Root cause.** GROUP BY partitions by **RDF term** (lexical form + datatype +sensitive), so `"1"^^integer` and `"001"^^integer` — same *value*, different +*term* — are distinct groups. Fluree groups by canonical numeric value (or +normalizes `001`→`1`), collapsing them → result mismatch. Interacts with §1.3 +#2: term identity, not value identity, is the grouping key. + +**Fix.** Make the GROUP BY key a term key (preserve lexical form for numerics), +not a value key. **Hot-path sensitive:** grouping is on the aggregation path; +keep the common single-datatype grouping fast and only widen the key +representation. Bench `query_hot_bsbm_bi`. IR/engine fix → JSON-LD parity test. + +### 1.5 `SPARQL12_EXPRESSION` (1) — EBV over mixed types + +`not-not`: `BIND(!!?v AS ?ebv)` over `VALUES ?v { true 1 "a" false 0 "" "a"@en +"z"^^xsd:boolean "2020-...T..."^^xsd:dateTime :a }`. Expected: `true` for +`{true,1,"a"}`, `false` for `{false,0,""}`, and **`?ebv` unbound (error)** for +`"a"@en`, invalid `"z"^^xsd:boolean`, `dateTime`, and the IRI `:a`. + +**Root cause.** Effective-boolean-value is defined only for `xsd:boolean`, +numerics, and plain/`xsd:string`. A language-tagged string, an ill-typed +boolean, a `dateTime`, and an IRI must each raise an EBV type error (→ unbound +after `!!`). Fluree diverges on at least one row (same family as the `sparql10` +`boolean-effective-value` / `dawg-bev-*` failures). Result mismatch. + +**Fix.** Correct EBV type-domain handling in the expression evaluator (shared +with the sparql10 bev cluster — coordinate with the Phase C1 owner to avoid two +diverging fixes). IR/engine → JSON-LD parity test. + +### 1.6 `SPARQL12_LANG_BASEDIR` (10) — base direction + 4 new functions + +Two distinct lexer failures plus an evaluation/representation layer: + +| test | query gist | today error | needs | +|---|---|---|---| +| datatype | `DATATYPE("foo"@en--ltr)` | lexer: `unexpected character: 'l'` | base-dir langtag lexing + `rdf:dirLangString` datatype + DATATYPE-on-literal (§1.3) | +| concat | `CONCAT("a"@en--ltr, ...)` | `'l'` | base-dir langtag; dir-propagation rules in CONCAT | +| contains | `CONTAINS("abc"@en--ltr, "b"@en--ltr)` | `'l'` | base-dir langtag; arg compat | +| langdir | `LANGDIR(?object)` | `'L'` | **new fn** LANGDIR + dir stored on data | +| langdir-literal | `LANGDIR(?v)` over `VALUES { "a"@en "l"@en--ltr "r"@en--rtl }` | `'L'` | new fn + inline base-dir langtag + dir represented | +| strlangdir | `STRLANGDIR("abc","en","ltr")` | `'S'` | **new fn** STRLANGDIR | +| haslang | `hasLANG(?object)` | `'h'` | **new fn** hasLANG | +| haslangdir | `hasLANGDIR(?object)` | `'h'` | **new fn** hasLANGDIR | +| lang | `LANG(?object)` over base-dir data | result mismatch | data must load; LANG strips direction | +| strlang | `STRLANG("abc","")` etc. | result mismatch | `STRLANG(_, "")` must error/unbind; type-guard args | + +Root causes, decomposed: + +- **Base-direction langtag lexing.** Both lexers stop the langtag at `en`. The + SPARQL grammar is `'@' [a-zA-Z]+ ('-' [a-zA-Z0-9]+)*` + (`fluree-db-sparql/src/lex/lexer.rs:840`, `parse_lang_tag`); the Turtle lexer + is identical (`fluree-graph-turtle/src/lex/token.rs`, LangTag). `@en--ltr`: + after `en`, the loop sees `-`, peeks the next char (`-`), it is not + alphanumeric → stop. The residual `--ltr` then fails to lex. RDF 1.2 extends + the production with an optional `'--' [a-zA-Z]+` base-direction suffix; both + lexers need it, and both must still **reject** an invalid direction + (`langdir-literal-invalid`: `"foo"@en--foo` is a `NegativeSyntaxTest` that + passes "for free" today only because the lexer rejects all `--`; after the + fix it must fail on `foo` ≠ `ltr`/`rtl`). +- **Four new functions** (`LANGDIR`, `STRLANGDIR`, `hasLANG`, `hasLANGDIR`). + These are not keywords, and the SPARQL lexer has **no general identifier + token** — a bare word that is neither a known keyword nor a `prefix:local` + is unlexable, which is exactly the `unexpected character: 'h'/'L'/'S'` error. + So each needs: a lexer keyword, a parser builtin-call arm, an AST node, and + an evaluator. (`LANG`, `DATATYPE`, `CONCAT`, `CONTAINS`, `STRLANG` already + exist; they only need base-dir-aware behavior.) +- **Representation** — the design question in §4.3. + +**Data:** `data-lang.ttl` uses `"abc"@en--ltr`, `"تصميم..."@ar--rtl`. Expected +serialization (`langdir-literal.srj`) renders direction as +`"its:dir": "ltr"` alongside `"xml:lang": "en"`, and `datatype.srj` expects the +datatype IRI `rdf:dirLangString`. + +--- + +## 2. Turtle-star ingest — the centerpiece (`SPARQL12_EVAL_TRIPLE_TERMS`, 41) + +### 2.1 Ground truth: 40/41 fail at DATA LOAD, not query execution + +Bucketing the 41 baseline errors: **40 fail while loading the `qt:data` file** +(`Turtle parse error: ... invalid or unterminated IRI` on `<<`, or +`expected Dot, found LBrace` on `{|`, or `expected subject, found KwGraph` on +TriG `GRAPH`). Exactly **one** (`expr-2`) fails at query parse +(`unexpected character: 'i'` = `isTriple`). The Turtle lexer treats `<<` as the +start of an IRI `<...>` and never finds the closing `>` — hence "unterminated +IRI." Confirmed: `fluree-graph-turtle/src/lex/token.rs` has **zero** star tokens +(no `<<`, `>>`, `<<(`, `)>>`, `{|`, `|}`, `~`); `fluree-graph-ir::Term` +(`term.rs:228`) is `Iri | BlankNode | Literal` only. + +### 2.2 Construct inventory (read from every data file) + +| construct | example (data file) | RDF 1.2 meaning | Fluree mapping | +|---|---|---|---| +| **reified triple, anon reifier** | `<<:a :b :c>> :q :z` (data-1) | assert `:a :b :c`; mint anon reifier `r`; `r :q :z` | base edge + minted reifier node + `f:reifies*` | +| **reified triple, named reifier** | `<<:s :p1 :o ~ :reifier>>` (data-2) | reifier is `:reifier` (IRI) | base edge + `:reifier` node + `f:reifies*` | +| **reified triple, bnode reifier** | `<< :a :b 9 ~ _:bnodereifier >>` (data-7) | reifier is the bnode | base edge + bnode reifier + `f:reifies*` | +| **reified triple as object** | `:z1 :q << :s1 :p :o >>` (data-5) | object slot = the reifier | ordinary edge to reifier node | +| **nested reified triple** | `<< <<:s :p2 :o>> :p3 :z >>` (data-2) | recursive | recurse: inner reifier is subject of outer | +| **annotation block** | `:a :b :c {| :q :z |}` (data-3, data-8) | assert `:a :b :c`; reifier `r`; `r :q :z` | **exact `@annotation` analog** | +| **triple term** | `:a :q <<(:a :b :c)>>` (data-0-tripleterms, data-10) | object is the triple **term**; base **not** asserted | ⚠ no Fluree representation (see D3) | +| **nested triple term** | `<<( :s :p <<(:x2 :y3 123)>> )>>` (data-0-tt) | recursive term | ⚠ D3 | +| **TriG GRAPH block** | `GRAPH :g { <<:s :p :o1>> :q1 :z1 }` (data-4/6.trig) | named graph | ⚠ **separate gap** (see 2.5) | + +The first six rows are the **asserting / reifier-model** forms. They map +one-to-one onto Fluree's existing edge-annotation model. The **triple-term** +rows (`<<( )>>`) do not — that is the D3 decision the wave-2 doc owns. + +### 2.3 Design: how Turtle-star should feed the reifier pipeline + +**Key architectural fact:** Turtle ingest is NOT the JSON-LD path. +`insert_turtle` (`testsuite-sparql/src/query_handler.rs:149`) calls +`fluree_graph_turtle::parse(ttl, &mut sink)` — a **streaming sink** that emits +IR triples to a `FlakeSink`/`ImportSink` (`fluree-db-transact/src/import.rs`). +Only the JSON-LD path runs `edge_annotations::lower_edge_annotations` +(`parse/jsonld.rs:144`). So "feed the same reifier pipeline" does **not** mean +routing Turtle through `edge_annotations.rs` (that rewrites a `serde_json::Value` +document; Turtle streams `Term`s). It means **producing byte-identical +`f:reifies*` flakes**, and there is already one canonical encoder for that: + +> `EdgeKey::to_reifies_facts(&self, ann: &Sid, t, op)` — `fluree-db-core/src/edge.rs:139` +> (and `to_reifies_facts_jsonld_compatible` at `:250`, which omits the optional +> `f:reifiesDatatype` flake exactly as the JSON-LD lowering does). + +`edge_annotations.rs` is documented as **mirroring** this builder in JSON. The +Turtle path should call it **directly**. Recommended structure: + +1. **Lexer (`fluree-graph-turtle/src/lex`):** add tokens `TripleStart <<`, + `TripleEnd >>`, `TripleTermStart <<(`, `TripleTermEnd )>>`, + `AnnotationOpen {|`, `AnnotationClose |}`, `Tilde ~`. Mirror the SPARQL + lexer, which already has all of these (`lexer.rs:878-899, 946`) — so the + token vocabulary and disambiguation (`<<(` before `<<` before `<`; `{|` + before `{`) are already worked out on the SPARQL side and can be copied. +2. **Parser (`parser.rs`):** in `parse_subject` (`:473`) and `parse_object` + (`:608`), add a `TripleStart` arm → `parse_reified_triple` returning the + reifier `TermId`; after an object in `parse_object_list` (`:570`), add an + optional `AnnotationOpen` tail. `parse_reified_triple` recursively parses + `subject predicate object [~ reifier]`, emits the **base** triple to the + sink, obtains/mints the reifier `TermId`, and signals the reifier facts. +3. **Sink boundary (the reifier encoding lives here, once):** add sink events + `on_reified_edge(base_s, base_p, base_o, reifier)` and + `on_annotation(base_s, base_p, base_o, reifier, props)`. The + fluree-db-transact sink implementation resolves SIDs, builds an `EdgeKey`, + and calls `EdgeKey::to_reifies_facts` (or the `_jsonld_compatible` variant — + pick the one the JSON-LD path uses so both surfaces are bit-identical). The + generic `fluree-graph-turtle` crate stays encoding-agnostic (it only knows + "this triple has a reifier"), so the `f:reifies*` schema is never duplicated. + +This keeps the reifier encoding in exactly one place (core `EdgeKey`), shared by +JSON-LD and Turtle — which is precisely the parity property §4 asks for. + +The **triple-term** forms (`<<( )>>`) are deliberately **not** in this design: +they denote a term without asserting the base triple and require a first-class +triple-term value (a new `Term` variant or a durable term encoding). That is the +D3 epic owned by the wave-2 doc. Wave-1 ingest should **reject `<<( )>>` in the +Turtle parser with a clear "triple terms as values are deferred (D3)" error**, +the same posture the SPARQL parser takes (`parse/query/term.rs:699` restricts +`<<( )>>` to `rdf:reifies` object position). + +### 2.4 Wave-1 vs wave-2 split of the 41 eval tests + +A test goes green only if **data loads** (ingest) AND **query parses/evaluates** +AND **results serialize/compare**. Classifying by the binding constraint: + +**(a) Green on wave-1 ingest alone — 2 tests.** Plain-BGP query, IRI-only +output, data uses only asserting/reifier forms: +- `pattern-3` — query `?s :b ?o . ?o :b ?z`; data-2 named reifier `:reifier` + becomes a queryable node so `:a1 :b :reifier . :reifier :b :a2` matches + (expected single row `:a1, :reifier, :a2`). +- `pattern-3-nomatch` — same data, `:b2` variant, expected empty. + + Caveat: data-2.ttl must fully ingest (it also contains anon reifiers and + **nested** reified triples), so these two need the *complete* asserting-form + ingest, not a subset. + +**(b) Blocked on wave-2 QUERY syntax (bare `<< >>` patterns) — ~19.** Data +ingests (wave-1) but the query uses `<< >>` reified-triple patterns as BGP +terms, which the legacy query path does not support for arbitrary predicates +(see 2.6): `basic-2, basic-3, basic-4, basic-5, basic-6, pattern-1, pattern-2, +pattern-4, pattern-5, pattern-6, pattern-7, pattern-8, pattern-8-nomatch, +pattern-9, pattern-10` (partial), `graphs-1, graphs-2, update-1, update-2`. + +**(c) Blocked on D3 triple-terms-as-values (VALUES/IN/BIND/binding/ordering) — +~12.** Query and/or data use `<<( )>>` as a value: `basic-8, basic-9, +pattern-11, op-1, op-2, expr-1, order-1, order-2` and the data files +`data-7/9/10/order/order-kind`. `op-1`/`op-2` have plain-BGP queries but their +**bindings and expected output are triple terms** (`op-1.srj` shows +`"type":"triple"`), so they need value support + result serialization. + +**(d) Blocked on triple-term FUNCTIONS (D2) — 1.** `expr-2` (`isTriple`, +`SUBJECT`, `PREDICATE`, `OBJECT`) — the only query-parse failure. + +**(e) Blocked on triple-term result SERIALIZATION (harness + `result_format`) — +overlaps b/c.** `results-tripleterms-1j/1x`, `results-reifiedtriples-1j/1x`, +`basic-7`, and every construct/op/order test whose expected `.srj`/`.srx`/ +`.trig` embeds `"type":"triple"` (or `<< >>` in TriG). The harness's +`result_format.rs`/`result_comparison.rs` and the expected-file parser must +handle triple-term result values — otherwise even a correct engine result won't +compare. Note `basic-7` uses the **supported** `{| ?p ?o |}` query form yet is +still wave-2: its expected output binds `?o` to the `rdf:reifies` **triple term** +(one row), which needs both triple-term serialization AND exposing `rdf:reifies` +as a queryable predicate over the annotation model — Fluree hides the base edge's +reifier link behind `f:reifies*`. + +**(f) CONSTRUCT annotation/triple-term projection (D4) — 5.** `construct-1..5` +build `<< >>`/`{| |}` in the template; `lower/construct.rs` returns +`UnsupportedFeature` for annotation projection today (audit §4.3 item 4). + +**(g) TriG multi-graph — 5 (overlaps b/c).** `graphs-1, graphs-2, expr-1, +update-1, update-2` load `.trig` with `GRAPH` blocks (2.5). + +**Bottom line for the register:** wave-1 ingest turns **2** eval tests green +(`pattern-3`, `pattern-3-nomatch`). The other 39 stay registered, re-pointed at +the specific wave-2 dependency (query-syntax / D3 / D2 / serialization / D4 / +TriG). **Recommended sequencing: land wave-1 ingest first, then re-run the eval +suite** — with data loading, the 39 will surface their *actual* query/eval +errors, giving a precise, code-verified wave-2 boundary instead of the +inferred-from-source one above. (Today no eval query has ever executed, so the +wave-2 query behavior is reasoned from source, not observed.) + +### 2.5 TriG `GRAPH` blocks — a separate, orthogonal gap + +`data-6.trig` is pure `GRAPH :g1 { :s1 :p1 :o1 }` with **no star at all** and +still fails `expected subject, found KwGraph`. `fluree-graph-turtle`'s +`parse_statement` (`parser.rs:364`) dispatches only prefix/base/triples — there +is **no** handling for the TriG `GRAPH` keyword block *or* the label form +`:g { }`. So the crate cannot parse multi-graph TriG at all. (Phase A's +"named graphs load via the transact builder" wraps each graph separately; it +does not parse a multi-graph `.trig` document.) This blocks graphs-1/2, expr-1, +update-1/2 independently of star support, and should be tracked as its own +work item (TriG named-graph parsing in `fluree-graph-turtle`), not folded into +the star PR. + +### 2.6 Why the legacy `<< >>` query path does not cover the eval queries + +`fluree-db-sparql/src/lower/rdf_star.rs` handles `<< s p o >> f:t ?t ; f:op ?op` +— the **old Fluree metadata form** (extract `f:t`/`f:op` from a quoted-triple +subject) — and explicitly **rejects** an RDF 1.2 annotation tail on a +quoted-triple subject (`not_implemented`). The eval queries use `<<:s :p :o>> +:q :z` with arbitrary predicates and expect reifier semantics, which this path +does not provide. Hence bucket (b) is genuine wave-2 work, not a +lowering-config away. + +--- + +## 3. Hot-path classification (§6 discipline) + +| change | phase | hot path? | guardrail | +|---|---|---|---| +| SPARQL codepoint pre-pass | parse-time | **No** — query parse, not import; `Cow` fast path when no `\` | none needed; add a "no-escape returns borrowed" unit test | +| SPARQL negative-syntax validators | parse-time | No | none | +| base-dir langtag in **SPARQL** lexer | parse-time | No | none | +| **new functions** (LANGDIR etc.) | parse+eval | eval per-row only when the fn is used | keep un-used-fn path untouched | +| DATATYPE-on-literal / EBV / simple-literal identity | per-row eval | **Yes** (expression + equality) | `query_hot_bsbm`, `query_hot_bsbm_bi`; keep common-type path byte-identical, route only new cases slow | +| GROUP BY term-key | aggregation | **Yes** | `query_hot_bsbm_bi`; widen key only, keep single-datatype fast | +| **Turtle-star lexer tokens** | import lexing | **YES — import-hot** | `insert_formats`, `import_bulk`; the `<`/`{`/`~` dispatch gains one peek each — must stay within `regression-budget.json` | +| Turtle-star parser/sink | import parse | import-hot but **only** when star tokens present; non-star data unaffected | same benches; verify non-star import is byte-for-byte unchanged | +| base-dir representation (§4.3) | ingest + storage | depends on option | see §4.3 cost table | + +The Turtle lexer is the one genuinely import-hot change. Every IRI begins with +`<`; recognizing `<<` adds a single second-byte peek on the `<` branch. That is +cheap but not free — it is the same perf-neutrality bar as any lexer change and +must clear `insert_formats`/`import_bulk`. Design the lexer so the non-star +common path (plain ``, `{` as TriG-only, no `~`) does the minimum extra +work, and assert unchanged output on a non-star corpus. + +--- + +## 4. JSON-LD parity (§6.6 / sparql-compliance.md) + +**Scope:** JSON-LD only. Cypher is out of scope for this burn-down — Fluree does +not own the openCypher grammar and will not add custom syntax to it, so there is +no Cypher parity obligation here. (Cypher still benefits automatically from the +IR/engine-level fixes below, but that is incidental, not a deliverable.) + +The parity direction here is **reversed** from the usual guideline: JSON-LD is +the *reference* implementation and the new SPARQL/Turtle surfaces must match it. + +- **Turtle-star ↔ JSON-LD `@annotation` (must be bit-identical).** Both must + produce the same `f:reifies*` flakes. Enforce by construction: route both + through `EdgeKey::to_reifies_facts` (§2.3). **Equivalence tests to author** + (new): for each asserting form (anon reifier, named reifier, bnode reifier, + reified-triple-as-object, nested, annotation block), load the Turtle form and + the equivalent JSON-LD `@annotation`/`@edge` document and assert **identical + flake sets** (subject/predicate/object/dt/graph/lang, same reifier identity + rules). Put these next to the existing `it_edge_annotations.rs` + `edgekey_roundtrip_*` gate tests, which already pin the JSON-LD side. +- **Base direction has no JSON-LD story.** Grep confirms + `fluree-graph-json-ld` has **zero** `@direction` / `dirLangString` support. + So the lang-basedir work is not "add SPARQL syntax to an existing capability" + — the capability is absent on every surface. If base direction is in scope, + JSON-LD 1.1 `@direction` (and an equivalent Fluree query-syntax path) must be + added in the **same effort**, per the guideline. This materially raises the + cost of `SPARQL12_LANG_BASEDIR` and is a reason to treat it as its own epic. +- **Codepoint escapes / negative-syntax validators** are SPARQL-surface-only + (text-form concerns with no JSON-LD analog) — record the decision; no JSON-LD + work. The RDF11 / grouping / expression fixes are IR/engine-level → they fix + all surfaces implicitly but still need JSON-LD regression tests + (`fluree-db-api/tests/it_query*.rs`). + +--- + +## 5. The base-direction representation decision (§4.3 — options + cost) + +**The gap.** Nothing in the stack has room for base direction: +`fluree-graph-ir::Term::Literal` (`term.rs:236`) has `value` / `datatype` / +`language: Option>` — **no direction**; `FlakeMeta.lang` +(`fluree-db-core/src/flake.rs:34`) and `commit::Value::LangString { value, lang }` +(`commit.rs:94`) likewise; JSON-LD has none. RDF 1.2 needs `ltr`/`rtl` on a +language-tagged string and the `rdf:dirLangString` datatype. + +| option | mechanism | cost | pros / cons | +|---|---|---|---| +| **A — first-class direction field** | add `direction: Option` to IR `Term::Literal`, `FlakeMeta`, `commit::Value`, thread through JSON-LD `@direction`, SPARQL result (`its:dir`), comparison/ordering | **High, cross-cutting** — IR + core flake meta + **on-disk commit format** + json-ld + both lexers + result_format + new fns; touches `EdgeKey`/`f:reifiesLang` (needs an `f:reifiesDir` companion for annotated dir-strings) and a **storage-format version bump** | RDF-1.2-correct; clean; but a durability-affecting change and the largest surface | +| **B — encode direction in the lang string** | store `en--ltr` as the `lang` value; `LANG` strips `--…`, `LANGDIR` extracts it, `DATATYPE` returns `dirLangString` when `lang` contains `--` | **Low–medium** — no schema/storage change (`lang` is already a string); every LANG/langMatches/equality/order site must learn to split on `--` | cheapest; term identity ("en--ltr" ≠ "en--rtl" ≠ "en") falls out for free and is *correct*; risk of leaking the encoded form through un-updated call sites; JSON-LD/result serialization must re-split | +| **C — documented divergence (defer)** | reject `@…--…` with a clear error; register all 10 lang-basedir tests as reviewed divergence | **~zero** | keeps the RDF-1.2 conformance claim honest-but-incomplete; 10 tests stay skipped; matches the wave-2 doc's "accept-then-defer" posture for triple terms | + +**Recommendation.** If base direction is in product scope, **Option B** is the +pragmatic wave-1 encoding (no storage-format change, correct term identity), +with the explicit follow-up that JSON-LD `@direction` and result `its:dir` +serialization are part of the same PR (§4). If it is not a near-term product +need, **Option C** (defer + documented register) is defensible and costs +nothing — this is a **product/architecture call**, not an engineering detail, +and should be made before any lang-basedir code is written. + +--- + +## 6. Blast radius, PR composition, risks + +### 6.1 PR composition (each shrinks its register in the same change; CI enforces both directions) + +1. **PR-1 Codepoint escapes** (`SPARQL12_CODEPOINT_ESCAPES` −6). Global `\u` + pre-pass + PN_LOCAL escape confirmation. Parse-time, isolated, no engine + risk. Independent — land first. +2. **PR-2 Negative-syntax validators** (`SPARQL12_SYNTAX` −2). Two checks in + `validate/mod.rs` + JSON-LD `values` duplicate-var check. Parse-time. +3. **PR-3 RDF11 literal semantics** (`SPARQL12_RDF11` −3). DATATYPE-on-literal + + simple-literal = `xsd:string`. **Bench-guarded** (term identity); coordinate + with the sparql10 type-promotion/open-world owner. Also unblocks + lang-basedir `datatype`. +4. **PR-4 EBV + GROUP BY term-key** (`SPARQL12_EXPRESSION` −1, + `SPARQL12_GROUPING` −1). Shared with sparql10 bev cluster; bench-guarded. +5. **PR-5 Turtle-star ingest (asserting forms)** — the centerpiece. + `SPARQL12_EVAL_TRIPLE_TERMS` −2 (`pattern-3`, `pattern-3-nomatch`). + Lexer tokens + parser hooks + sink events → `EdgeKey::to_reifies_facts` + + the JSON-LD/Turtle flake-equivalence tests (§4). **Import-hot; bench + `insert_formats`/`import_bulk`.** Explicitly rejects `<<( )>>` (D3) and TriG + `GRAPH` (2.5) with clear deferred errors. Sizeable — keep it to the + asserting forms only. +6. **PR-6 base direction** — gated on the §5 decision. If Option B: + `SPARQL12_LANG_BASEDIR` −10 (langtag lexing ×2, 4 new functions, encode/ + decode, JSON-LD `@direction`, `its:dir` serialization, LANG/STRLANG semantics + incl. the empty-lang error). If Option C: rewrite the 10 register comments as + documented divergence. Largest or smallest PR depending on the call. +7. **VERSION:** no PR — fix the `SPARQL12_VERSION` register comment to reference + wave-2 PR-A (bare `<< >>` syntax); the 3 go green there. + +Independent, landable now: PR-1, PR-2. Semantics (bench-guarded): PR-3, PR-4. +Big: PR-5. Decision-gated: PR-6. + +### 6.2 Risks + +- **Turtle lexer perf regression** (import-hot). Mitigate: minimal peek on `<`/ + `{`/`~`; assert byte-identical output on a non-star corpus; bench gate. +- **Term-identity ripple** (RDF11 simple-literal + GROUP BY term-key + Option-B + lang encoding). Each changes what "same term" means and can silently move + DISTINCT/GROUP BY/equality/index results. Verify against the whole sparql10 + eval suite, not just the target tests. +- **Reifier-flake divergence** between Turtle and JSON-LD. Mitigate by + construction (single `EdgeKey` encoder) + the equivalence tests; do **not** + re-implement the `f:reifies*` schema in the Turtle crate. +- **Inferred wave-2 boundary.** Bucket (b)/(c)/(d) rest on source reading, since + no eval query has executed. The plan de-risks this by running the eval suite + immediately after PR-5 to observe real query errors. +- **base-direction storage-format change** (Option A only) — durability/version + risk; a reason to prefer B or C. + +### 6.3 Open design questions (need an owner decision) + +1. **Base direction: A / B / C?** (§5) — product + architecture call; blocks + `SPARQL12_LANG_BASEDIR`. Pairs with the D3 triple-terms-as-values decision + in the wave-2 doc (both are "first-class RDF-1.2 value" questions). +2. **`SPARQL12_VERSION` register** — confirm the reclassification (VERSION done; + 3 tests belong to wave-2 syntax) and correct the comment. +3. **TriG multi-graph parsing** in `fluree-graph-turtle` (§2.5) — own it + separately or fold into a graph epic? Blocks 5 eval tests independent of star. +4. **Which `to_reifies_facts` variant** the JSON-LD path actually uses at + ingest (`to_reifies_facts` vs `_jsonld_compatible`) — the Turtle sink must + call the *same* one for bit-identical flakes; confirm before writing PR-5. diff --git a/docs/audit/burn-down/sparql12-wave2-triple-terms.md b/docs/audit/burn-down/sparql12-wave2-triple-terms.md new file mode 100644 index 0000000000..22aaab5f88 --- /dev/null +++ b/docs/audit/burn-down/sparql12-wave2-triple-terms.md @@ -0,0 +1,415 @@ +# SPARQL 1.2 Wave 2 — Triple Terms Burn-down & D3 Decision Memo + +**Cluster:** `SPARQL12_SYNTAX_TRIPLE_TERMS_POSITIVE` (87 failing) + +`SPARQL12_SYNTAX_TRIPLE_TERMS_NEGATIVE` (1 failing). +**Scope:** these two registers only. The sibling `SPARQL12_EVAL_TRIPLE_TERMS` +(41) is a *different* cluster and is referenced only where it bears on the D3 +decision. +**Status:** pre-implementation audit. No source changed. +**Baseline:** branch `test/sparql-testsuite-full-coverage`, rdf-tests submodule +`efccbc6b8`. Verified against parser HEAD. + +--- + +## 0. The single fact that reframes this whole cluster + +Both registers are **syntax** suites. The harness worker +(`testsuite-sparql/src/bin/run_w3c_test.rs:83-116`) runs **only** +`parse_sparql()` + `validate(ast, Capabilities::default())`. **It never lowers +to IR and never touches storage or the query engine.** A positive test passes +iff parse+validate produce no error; a negative test passes iff they do. + +Consequence: **every one of the 87 positive failures is greenable with +parser + validation work alone.** None of them requires a `FlakeValue` +variant, an index encoding, a comparator arm, or an evaluator. The heavyweight +"first-class triple term" machinery that D3 is nominally about is required only +by the *eval* cluster, which is out of scope here. This is confirmed +empirically: `basic-anonreifier-01` (`<< :a :b :c >> :p1 :o1 .`, a quoted-triple +subject with a non-`f:t` predicate) **passes today** even though that shape +hits `LowerError::not_implemented` at lower time — because syntax tests never +lower. + +That fact turns D3, for *this* cluster, from "implement first-class triple +terms vs not" into a much cheaper question: **do we extend the parser to +accept SPARQL 1.2 triple-term syntax (and defer/register its evaluation), or do +we register the syntax itself as a documented divergence?** The full +first-class-value implementation is a real decision, but it belongs to the eval +cluster and is analyzed in §5/§6 so the team can price it. + +--- + +## 1. Bucketed analysis of the 87 positive failures + +Every failure is a parser rejection. I read all 113 positive `.rq`/`.ru` +files and bucketed by the exact syntactic construct the parser chokes on, and +by whether the construct is expressible in Fluree's existing edge-annotation +(reifier) model or genuinely needs a triple-term *value*. + +| Bucket | What it needs | Count | Model fit | +|---|---|---|---| +| **A/D — reifier-model syntax** | `<< s p o >>` / `~ reifier` / `{\| \|}` forms in positions the parser doesn't yet accept | **60** | Expressible via existing reifier/edge-annotation model | +| **B — triple-term functions** | `TRIPLE` / `SUBJECT` / `PREDICATE` / `OBJECT` / `isTRIPLE` parse as builtins | **3** | Parse-only here; eval semantics need a decision | +| **C — triple terms as values** | `<<( s p o )>>` in subject / bare-object / `VALUES` / `BIND` | **24** | Genuinely needs a triple-term *value* (or syntax-accept-then-defer) | + +Total 87 = 60 + 3 + 24. + +### 1.1 Bucket A/D — reifier / annotation syntax (60) + +These use only the `<<>>` reifier form, the `~` reifier operator, and the +`{| |}` annotation block — the exact constructs Fluree already committed to. +They fail because the parser accepts these constructs in a *narrower* set of +positions than SPARQL 1.2 requires. Sub-gaps, with the parser site that +rejects each (`fluree-db-sparql/src/parse/query/term.rs` unless noted): + +| Sub-gap | Example test(s) | Why it fails today | Count | +|---|---|---|---| +| **Reifier `<<>>` in object position** | `basic-anonreifier-02/04`, `annotation-*reifier-03/04` | `parse_object` (~199-231) has no `TripleStart` branch — `<<>>` accepted in *subject* only (`parse_subject` ~72-75 → `SubjectTerm::QuotedTriple`) | ~8 | +| **`~ reifier` *inside* `<<>>`** | all `basic-reifier-*` (12), `bnode-reifier-*` (3) | `parse_quoted_triple` (~100-122) parses `s p o` then demands `>>`; a `~` before `>>` is unexpected | ~15 | +| **Reifier `<<>>` standalone (no predicate-object) + nested** | `basic-anonreifier-08/09/10/11/12/13`, `nested-anonreifier-02`, `nested-reifier-*` | parser requires a predicate-object list after a subject; a lone `<< s p o >> .` and a `<<>>`-inside-`<<>>` are unhandled. NB: standalone reifier **is** valid (asserts+reifies) — contrast bucket C where standalone triple-term is *invalid* (§2) | ~9 | +| **Multiple reifiers / multiple annotation blocks per triple** | `annotation-*-multiple-*` (12) | `parse_annotation_tail` (~784-829) hard-narrows to v1 "at most one reifier" (~798-801) and "at most one annotation block" (~809-813) | 12 | +| **Richer annotation-block content** (paths, nested bnode lists) | `annotation-*reifier-06/07`, `update-reifier-07` (`:q1+`), `update-*reifier-04` (`<<>>` in block) | `parse_annotation_block` (~857-924) accepts a plain predicate-object list; property paths (`:r/:q`, `:q1+`) and nested reifiers inside the block are rejected | ~8 | +| **Reifier inside collections `()` / bnode lists `[]`** | `inside-anonreifier-01/02`, `inside-reifier-01/02` | `<<>>` as an element of `( … )` or `[ … ]` object flows through `parse_object`, same object-position gap | 4 | +| **Update templates/DATA carrying the above** | `update-anonreifier-04/05`, `update-reifier-01/03/04/05` | same gaps via the CONSTRUCT/template path (`parse_construct_predicate_object_list`) | ~4 | + +**All 60 are parser-grammar extensions of a model Fluree already owns.** For +the syntax suite they need parser acceptance only. For *eval* parity (separate +cluster) most map cleanly: a reifier in object position binds a plain +reifier node (an IRI/bnode) — **no new object kind** — because a reifier is a +resource, not a value. + +**One design snag to flag (not blocking):** `<<>>` is overloaded. Fluree's +*legacy* `<< s p ?o >> f:t ?t` history-metadata form is lowered specially in +`lower/rdf_star.rs` (`f:t`/`f:op` → `Function::T`/`Op`); RDF 1.2 uses the same +`<<>>` tokens for a *reifier*. Extending object-position `<<>>` must reconcile +these two readings in lowering (parse is fine — the tokens are identical). This +is an eval-time concern; it does not affect greening the syntax suite. + +### 1.2 Bucket B — triple-term functions (3) + +`expr-tripleterm-03` (`TRIPLE(?s,?p,?o)`), `-04` (`TRIPLE(?s,?p,str(?o))`), +`-05` (`isTriple`/`SUBJECT`/`PREDICATE`/`OBJECT`). Confirmed **absent from all +four function registries**: + +1. lexer keyword table — `fluree-db-sparql/src/lex/token.rs` (`TokenKind` + + `keyword_from_str` ~733 + `keyword_str` ~550) +2. AST `FunctionName` — `fluree-db-sparql/src/ast/expr.rs:266` + `parse` ~348 +3. token→`FunctionName` dispatch — `fluree-db-sparql/src/parse/expr.rs:501` + (`check_builtin_function_keyword`) +4. IR `Function` — `fluree-db-query/src/ir/expression.rs:715` + +Unqualified builtin names must be recognized or the parser tries to read +`TRIPLE` as a bad prefixed name and errors. **For this syntax cluster, only +registries 1–3 (parse-time) are needed** — parse `TRIPLE(...)`/`isTriple(...)` +as builtin calls and validate arity. The IR variant + evaluator (registry 4) +is eval work, deferable. + +*Can these be implemented against the reifier model without first-class terms?* +Partially, and only with divergent semantics: if `?t` binds a **reifier node** +(not a triple value), `SUBJECT(?t)` could read the reifier's +`f:reifiesSubject` flake, `isTRIPLE(?t)` could test "is a reifier resource." +That answers a different question than the spec (which operates on triple-term +*values*), so it would green parse but diverge on eval-conformance. Recommend: +parse now, tie eval semantics to the D3 value decision. + +### 1.3 Bucket C — triple terms as values (24) + +`<<( s p o )>>` used as a *value*: subject, bare object (predicate ≠ +`rdf:reifies`), `VALUES`, or `BIND`. The parser rejects `<<(` in every position +except object-of-`rdf:reifies`, with explicit deferred-feature errors: + +- bare object — `parse_object_list` term.rs ~636-642: *"triple terms + (`<<( s p o )>>`) are only allowed as the object of rdf:reifies in v1; + arbitrary triple-term values are deferred"* +- subject / nested — term.rs ~81-85, ~708-720: *"nested triple terms are not + supported in v1"* +- template/DATA — term.rs ~1064-1071; inside annotation block — ~873-879 +- `BIND` — `parse/expr.rs` `parse_primary_expr` has no `TripleTermStart` branch + (and `parse/stream.rs::is_term_start` whitelists `TripleStart` but not + `TripleTermStart`) +- `VALUES` — `parse/query/pattern.rs::parse_values_term` accepts only UNDEF / + IRI / literal + +The 24: `basic-tripleterm-01..07`, `bnode-tripleterm-01..03`, +`compound-all`, `compound-tripleterm`, `compound-tripleterm-subject`, +`inside-tripleterm-01/02`, `nested-tripleterm-01/02`, `subject-tripleterm`, +`expr-tripleterm-01` & `-06` (BIND), `update-tripleterm-01/03/04/05`. +`basic-tripleterm-05` is the `VALUES` case. + +This is the bucket D3 is really about. Two honest routes (§4). + +--- + +## 2. The grammar constraints a parser extension must respect (guardrail) + +The negative suite (64/65 passing) already encodes the RDF 1.2 rules. Any +parser extension that accepts triple terms as values **must keep rejecting** +these, or it regresses passing negatives: + +- **Triple term subject may not be a triple term** — `tripleterm-subject-01/02/03` + (`<<( <<( … )>> :q :z )>>`) are NEGATIVE. Only the *object* position of a + triple term may nest. +- **Triple term subject may not be a literal** — `tripleterm-subject-04/05/06`. +- **A bare triple term may not stand alone** — `tripleterm-separate-01..06` + (`<<( ?s ?p ?o )>> .`) are NEGATIVE. Critically this **differs from the + reifier** form: `basic-anonreifier-08/09` (`<< ?s ?p ?o >> .` standalone) are + POSITIVE. A reifier asserts+reifies; a triple term is only a value and cannot + be a statement. +- **No paths / no collections inside a triple term** — + `alternate-path-tripleterm`, `quoted-path-tripleterm`, + `quoted-list-subject-tripleterm`, `list-tripleterm-01`. +- **Reifiers are not expressions; triple terms are** — `bind-reified` / + `bind-anonreified` (`BIND(<< … >> AS ?t)`) are NEGATIVE, while + `expr-tripleterm-01` (`BIND(<<( … )>> AS ?t)`) is POSITIVE. The parser must + accept `<<(` in `BIND` but reject `<<` there. +- **No blank nodes in a triple term used in an expression** — + `bindbnode-tripleterm` (`BIND(<<( [] ?p ?o )>> AS ?t)`) is NEGATIVE. + +These are precisely the fiddly cases that make bucket C real grammar work +rather than a token flip. The negatives are the acceptance test for getting it +right. + +### The one negative failure + +`syntax-update-anonreifier-02` (`mf:NegativeUpdateSyntaxTest`): +``` +DELETE DATA { :s :p :o1 {| :added 'Test' |} } ; INSERT DATA { :s :p :o2 {| :added 'Test' |} } +``` +Should be rejected (annotation blocks minting anonymous reifiers are not +permitted in `DELETE DATA`/`INSERT DATA` ground quad-data); Fluree accepts it. +Fix is a small validation pass, independent of everything above. Contrast +`syntax-update-anonreifier-01` (a `DELETE … WHERE` with annotations), correctly +rejected today. + +--- + +## 3. JSON-LD surface parity + +Per `docs/contributing/sparql-compliance.md` § Query Surface Parity, scoped to +the surfaces Fluree owns. **Cypher is explicitly out of scope for this +burn-down** — it is openCypher, Fluree does not own the grammar and will not add +custom triple-term/reifier syntax to it, so there is no Cypher parity obligation +here. JSON-LD query syntax is Fluree-owned and is the only cross-surface +concern below. + +- **Bucket A/D reifier syntax** is a **SPARQL-only surface feature** (category + 3, like property-path text and RDF-star syntax). The *underlying* capability + (edge annotations) already exists in the shared IR and is already reachable + from JSON-LD via `@annotation` + (`fluree-db-transact/parse/edge_annotations.rs`). No JSON-LD **syntax** parity + is owed for the `<<>>`/`~`/`{| |}` surface; record the decision and move on. + Where a bucket-A/D fix touches *IR/eval* (object-position reifier as a bound + node), add a JSON-LD regression test that the same annotation is reachable — + but no new JSON-LD syntax. +- **Bucket B functions**: if `SUBJECT/PREDICATE/OBJECT/isTRIPLE/TRIPLE` become + real evaluable functions (not just parsed), that is a **surface-syntax + addition** — they must be exposed in JSON-LD query function syntax in the same + effort, with tests. +- **Bucket C first-class values**: JSON-LD has **no** native "triple term as a + value" today. If Option 1 (§4) lands, JSON-LD query needs a way to *bind and + return* a triple-term value (a reserved object shape) and the JSON-LD result + formatter needs to emit it; that is net-new JSON-LD surface work bundled into + the first-class-value epic, not into wave-2 syntax. `@annotation` is the + transact-side analogue of reifiers, **not** of triple-term values — do not + conflate them. + +--- + +## 4. D3 decision memo — bucket C (and the B eval tail) + +The centerpiece. Three options, priced for a team decision. Test counts are +**this cluster's syntax register** unless a row says "eval." + +### Option 1 — Full first-class triple terms (implement values end-to-end) + +Add a triple term as a real RDF term/value everywhere. + +- **Greens (syntax):** all 24 bucket-C + makes bucket-B meaningful → 27. +- **Greens (eval, separate cluster):** unblocks most of + `SPARQL12_EVAL_TRIPLE_TERMS` (up to ~41), incl. the value-identity tests + `results-tripleterms-1x/1j`, `expr-*`, `pattern-*`. This is the *only* option + that greens those. +- **Cost:** a new object **kind** ripples through eight closed enums and their + `Ord`/`Hash`/`Display`/encode/decode arms — `fluree-graph-ir::Term` + (`term.rs:227`), `FlakeValue` (`fluree-db-core/src/value.rs:117` + discriminant + 202 + Ord 805 + Hash/canonical-hash), `ir::triple::Term` + (`fluree-db-query/src/ir/triple.rs:117`), `Binding` + (`fluree-db-query/src/binding.rs:45`), `OType` (`o_type.rs:30` + `decode_kind_*` + + `DecodeKind`/`from_u8`), `ObjKind` + `ObjKey` + `ValueTypeTag` + (`value_id.rs:41/184/561`), flake size (`flake.rs:376,421`) — **plus a new + content-addressed arena** (see §5: `ObjKey` is a fixed 64-bit inline payload, + too small to hold a composite triple, so a triple term must be an arena handle + like `RDF_JSON`/`VECTOR`), plus index-selection logic, result formatters, and + the JSON-LD surface (§3). This is a multi-crate epic, not a wave-2 PR. +- **Perf risk:** **medium, bounded — if done as an arena handle.** The on-disk + comparators (`run_record.rs:233-281`) compare the raw `o_kind` byte then + `o_key`, so they gain **no per-kind branch**; a new kind just needs a + discriminant slot placed to sort correctly. The real hot-path exposure is + enum width: `FlakeValue` and `Binding` are matched per-row in scan/filter/join + and copied into rows — a triple term must ride as a **single Sid/u64 arena + handle**, never an inline `[Sid;3]+dt`, or it widens the row and regresses + `query_hot_bsbm`/`_bi`. The in-memory `FlakeValue::cmp`/`type_discriminant` + and `graph_ir::Term::cmp` *do* switch on the closed enum and gain an arm + (cold relative to the byte comparators). Bench guardrails per audit §6. + +### Option 2 — Desugar bare triple terms to the reifier model + +Parse `<<( )>>` and rewrite to the existing reifier/edge machinery. + +- **Greens (syntax):** all 24 — parsing + a lowering that rewrites to reifier + nodes. (For the syntax suite you don't even need the rewrite; see Option 4.) +- **Greens (eval):** **wrong answers, not greens.** A triple term is a *value* + (the triple itself); a reifier is a *resource* standing for an occurrence. + Desugaring `?t = <<( s p o )>>` to a reifier bnode makes `?t` bind a node, so + `isTRIPLE(?t)`, `SUBJECT(?t)`, term-equality, and `ORDER BY` over `?t` all + diverge from spec. Eval tests keyed on triple-term identity + (`results-tripleterms-1x/1j`, `expr-2`, `pattern-*`) **fail** under desugaring. +- **Verdict:** viable as a *syntax-only* convenience, useless as an eval + strategy. It buys nothing Option 4 doesn't buy more cheaply, and it risks + masquerading as eval support. **Not recommended.** + +### Option 3 — Documented-divergence register (reject syntax, keep skips) + +Leave the 24 (and the 3 functions) rejected with the current clear +"deferred/only-as-rdf:reifies-object" errors; keep them registered as a +deliberate divergence consistent with "Fluree uses an edge-annotation model, +not first-class triple terms." + +- **Greens:** 0 of bucket B/C. Bucket A/D still gets fixed separately. +- **Cost:** ~zero (write the register rationale + team sign-off). +- **Perf risk:** zero. +- **Verdict:** honest and defensible, but it permanently stops Fluree from + **parsing** standard SPARQL 1.2 and leaves 27 register entries. Choose it only + if the team is committing to *never* offering triple-term values. + +### Option 4 — Syntax-accept-then-defer (recommended for wave 2) + +Extend the parser to **accept** all of bucket B and C (respecting the §2 +grammar guardrails), building AST nodes; let **lowering** reject the +not-yet-evaluable value forms with a clean `not_implemented`. Because the +harness never lowers syntax tests, this **greens all 27** with **parser + +validation work only — zero storage/index/eval/perf risk**. + +- **Greens (syntax):** 24 (C) + 3 (B) = 27, plus the 60 in A/D → the entire + 87-entry positive register. +- **Greens (eval):** 0 — `SPARQL12_EVAL_TRIPLE_TERMS` stays registered, + explicitly tied to the Option-1 decision. +- **Cost:** bounded parser grammar work + arity validation. The negatives (§2) + are the guardrail against over-acceptance. +- **Perf risk:** zero (parse-time only). +- **Tension to name:** the parser then accepts `BIND(<<( )>> …)` etc. that error + at query time. That is standard "syntax supported, evaluation pending" and is + exactly what a syntax-conformance suite rewards. If the team would rather fail + fast at parse time for product clarity, that is Option 3 for bucket B/C — + a genuine either/or the team must pick. + +### Recommendation + +**Wave 2 = Option 4 for the syntax cluster** (green all 87 positive + 1 +negative with parser/validation only), and **book Option 1 as a separately +scheduled first-class-value epic** owned by the engine team, gated on and +measured by `SPARQL12_EVAL_TRIPLE_TERMS`, where the perf-critical object-kind +work and its bench guardrails live. The one thing the team must actually decide +now is the **either/or for bucket B/C**: *accept-syntactically* (Option 4, +recommended — parses standard SPARQL 1.2, defers eval) **vs** +*documented-divergence* (Option 3 — rejects the syntax on principle). Bucket +A/D proceeds either way. Option 2 is dominated; drop it. + +| Option | Syntax greens (this cluster) | Eval greens (other cluster) | Storage/perf risk | One-line | +|---|---|---|---|---| +| 1 Full values | 27 | up to ~41 | medium (arena handle; bench-guard) | The only real triple-term support; an epic | +| 2 Desugar | 24 | wrong answers | low | Dominated — masquerades as eval | +| 3 Register divergence | 0 | 0 | none | Honest "we don't do triple terms" | +| **4 Accept-then-defer** | **27** | 0 | **none** | **Recommended wave-2: parse now, eval later** | + +--- + +## 5. Hot-path / index-encoding risk (for Option 1 only) + +Zero relevance to greening this syntax cluster; priced here so the team can +schedule the eval epic. Verified against the core: + +- **Object value is a closed enum with a discriminant-ordered sort.** + `FlakeValue` (22 variants, `value.rs:117`) with `type_discriminant` + (`value.rs:202`) driving `Ord` (`value.rs:805`). A new arm is required and + touches every `match FlakeValue` — many are per-row hot (scan/filter/join). +- **Two type-tag encodings must stay in lockstep:** `OType` u16 + (`o_type.rs:30`, with reserved ranges `0x0020–0x3FFF` / `0x800D–0xBFFF` + explicitly for new kinds) and `ObjKind` u8 + `ObjKey` u64 + (`value_id.rs:41/184`). **`ObjKey` is 64-bit inline** — a composite + `(s,p,o,dt)` triple cannot fit, so a triple term must be a **new + content-addressed arena** (mirror `RDF_JSON`/`VECTOR`/`NUM_BIG`), i.e. the + object column stores an arena handle, not the triple. +- **Comparators favor us.** On-disk `cmp_*` (`run_record.rs:233-281`) and the + overlay merge (`types.rs:110-141`) compare the raw `o_kind` byte then `o_key` + — **no per-kind switch to extend**; cross-kind order is set by the numeric + discriminant you assign. So the *comparator functions* need no new arm; only + discriminant *placement* matters. In-memory `cmp_object` + (`comparator.rs:123`) rides `FlakeValue::cmp`, which does gain the arm above. +- **The genuine hot-path exposure is enum width**, not branch count: keep the + triple term a single `Sid`/`u64` arena handle inside `FlakeValue`/`Binding` so + the row width and cache footprint of the scan/join path don't grow. Guard with + `query_hot_bsbm` / `query_hot_bsbm_bi`. +- **`Opst` (object-leading index) is documented refs-only** + (`comparator.rs:30,79-83`). Looking up a triple-term object by value needs + index-selection work too. + +**Net:** comparator functions are largely safe (raw-byte compare); the cost and +risk are in the **enum/encoder/decoder/hash/Display arms + a new arena + enum +width discipline + formatters + JSON-LD surface**. Bounded and known, but a +multi-crate epic — correctly kept out of wave 2. + +--- + +## 6. Blast radius summary + +| Change | Touches | In wave-2 syntax scope? | +|---|---|---| +| Bucket A/D parser (object-pos `<<>>`, `~`-in-`<<>>`, standalone/nested reifier, multi-reifier/-annotation, richer blocks) | `fluree-db-sparql` parse + AST + (lower to existing `EdgeAnnotation`/`AnnotationTarget` or clean `not_implemented`) | **Yes** — parse-time, zero engine risk | +| Bucket B function parsing | `fluree-db-sparql` lexer keyword + AST `FunctionName` + parse dispatch | **Yes** — parse-time | +| Bucket C triple-term-value *syntax* (Option 4) | `fluree-db-sparql` parse + AST, guarded by §2 negatives; lower = `not_implemented` | **Yes** — parse-time, zero storage risk | +| Negative `syntax-update-anonreifier-02` | `fluree-db-sparql` validation pass (annotations in `*_DATA`) | **Yes** — validate-time | +| Bucket C triple-term *values* end-to-end (Option 1) | `fluree-graph-ir`, `fluree-db-core` (value/o_type/value_id/flake/comparator), `fluree-db-binary-index` (run_record + new arena), `fluree-db-query` (ir/binding/eval/index-select), formatters, JSON-LD surface | **No** — separate epic, gated on eval cluster | +| Bucket B function *eval* semantics | IR `Function` + lowering + evaluator (+ JSON-LD parity) | **No** — tied to Option 1 decision | +| Legacy-`<<>>` vs RDF-1.2-reifier reconciliation | `fluree-db-sparql/src/lower/rdf_star.rs` | **No** — eval-time only | + +--- + +## 7. PR composition (wave 2) + +Ordered; each shrinks its register in the same change (CI enforces both +directions). All are parse/validate-time — no bench guardrails required (none +touch the engine). + +1. **PR-A — reifier-form parser extensions.** Green the 60 bucket-A/D positives. + Extend `parse_object`/quoted-triple for object-position and nested `<<>>`, + `~`-inside-`<<>>`, standalone reifier; relax the "at most one + reifier/annotation" narrowing; allow paths + nested reifiers in annotation + blocks. Lower to existing `EdgeAnnotation`/`AnnotationTarget` where the model + already evaluates; otherwise clean `not_implemented`. Remove 60 entries from + `SPARQL12_SYNTAX_TRIPLE_TERMS_POSITIVE`. **Ships regardless of the D3 + either/or.** +2. **PR-B — triple-term function parsing.** Add `TRIPLE`/`SUBJECT`/`PREDICATE`/ + `OBJECT`/`isTRIPLE` to lexer keyword table + AST `FunctionName` + parse + dispatch + arity validation; lowering `not_implemented`. Removes the 3 + bucket-B entries. **Gated on the D3 either/or** (only if "accept"). +3. **PR-C — triple-term-value syntax acceptance (Option 4).** Accept `<<( )>>` + in subject / object / `VALUES` / `BIND`, strictly honoring the §2 grammar + (subject not a triple term/literal, no standalone, no paths/collections, no + `<<` in expressions, no bnodes in expression triple terms). Lowering + `not_implemented`. Removes the 24 bucket-C entries. **Gated on the D3 + either/or.** *If the team picks Option 3 instead, skip PR-B/PR-C and instead + rewrite the 24+3 register comments as a reviewed documented-divergence + rationale.* +4. **PR-D — negative validation.** Reject annotation blocks in + `INSERT DATA`/`DELETE DATA`. Removes the sole + `SPARQL12_SYNTAX_TRIPLE_TERMS_NEGATIVE` entry. + +**Separate epic (not wave 2):** first-class triple-term values (Option 1) + +bucket-B eval semantics + CONSTRUCT annotation projection +(`lower/construct.rs:92` `not_implemented`, which bites only the eval cluster's +`construct-1..5`, none in this syntax cluster), gated on and scored by +`SPARQL12_EVAL_TRIPLE_TERMS`, with the §5 bench guardrails. + +**Register math:** PR-A −60, PR-B −3, PR-C −24 → positive register 87 → 0 (if +"accept"); PR-D → negative register 1 → 0. Under Option 3: PR-A −60 → 27 remain, +re-annotated as reviewed divergence; PR-D → 0. diff --git a/docs/audit/burn-down/update-completeness.md b/docs/audit/burn-down/update-completeness.md new file mode 100644 index 0000000000..d64c67bf80 --- /dev/null +++ b/docs/audit/burn-down/update-completeness.md @@ -0,0 +1,421 @@ +# SPARQL UPDATE completeness — pre-implementation deep audit (2026-07) + +**Scope:** the two UPDATE registers in `testsuite-sparql/tests/registers/mod.rs` — +`SPARQL11_SYNTAX_UPDATE_1` (54 tests, 31 fail) and `SPARQL11_UPDATE` (157 tests, +90 fail). The syntax-update-1 IDs appear in both registers, so the de-duplicated +failure set is ~90 distinct tests. +**Baseline:** branch `test/sparql-testsuite-full-coverage`, rdf-tests @ `efccbc6b8`. +**Method:** every claim below was verified by reading code and the per-test error +JSON (`scratchpad/w3c-baseline2/sparql11_{update,syntax_update_1}_tests.json`) and +by reading the actual `.ru` test files — not by trusting the register comments. +**This audit does not modify source; it is a design input.** + +## 0. TL;DR — the failures collapse to SIX root causes, not the eight the registers imply + +| Class | Root cause | Failing tests | Layer | +|---|---|---|---| +| **A** | Graph-management verbs absent from grammar/AST (LOAD/CLEAR/CREATE/DROP/COPY/MOVE/ADD + SILENT/INTO/TO/DEFAULT/NAMED/ALL) | 41 eval parse-errors + ~21 positive-syntax | parse-time | +| **B** | **Multi-operation (`;`) requests unsupported — only the FIRST operation of a request runs; the rest are silently dropped** | insert-05a, insert-data-same-bnode, insert-where-same-bnode(2), dawg-delete-insert-01c, test_54 | parse-time + commit-time | +| **C** | Empty / prologue-only update request rejected | test_38, test_39, test_40 | parse-time | +| **D** | `GRAPH` blocks in `DELETE WHERE` unsupported (validate + lower) | test_36 (syntax) + dawg-delete-where-02/04/06 (eval) | parse/validate + lower-time | +| **E** | Negative-syntax gap: blank nodes not rejected in DELETE forms | test_50/51/52, dawg-delete-insert-03/03b/05/06/07/07b/08/09 | parse/validate-time | +| **F** | USING + explicit-`GRAPH` dataset-scoping semantics incomplete | dawg-delete-using-02a, dawg-delete-using-06a | prepare-time (staging) | + +**Corrections to the register comments (verified):** +- **B is the single biggest correction.** The `SPARQL11_UPDATE` register attributes + `insert-05a` and the three `insert-*-same-bnode*` tests to *"INSERT into a + not-yet-existing named graph silently loses triples"* and `dawg-delete-insert-01c` + to *"combined DELETE/INSERT WHERE applies inserts without deletes."* Both are + **wrong.** All five are multi-operation requests (`…;…;…`), and their observed + output is exactly what you get when only the first operation is applied and the + remainder are discarded (see §1.B for the arithmetic). The "combined DELETE/INSERT" + form actually **passes** (`dawg-delete-insert-01`, a single `DELETE{}INSERT{}WHERE{}`); + it is the `;`-separated `INSERT ; DELETE` sibling (`-01c`) that fails. +- **test_36 is class D, not A.** Its register/audit home is "missing graph-management + grammar," but it is `DELETE WHERE { GRAPH { … } }` — rejected by the validator + (`validate/mod.rs:154`), the same cause as the eval-side `dawg-delete-where-*`. +- **test_38/39/40 are class C, not A.** They are an empty request, a `BASE`-only + request, and a `PREFIX`-only request — the "graph-management grammar" comment does + not apply. +- Classes A, D, E, F otherwise match their register comments. + +--- + +## 1. Per-test root cause with file:line evidence + +### The two structural facts that drive most of this cluster + +1. **The parser recognizes exactly four update operations and no request-level + sequence.** `UpdateOperation` has only `InsertData | DeleteData | DeleteWhere | + Modify` (`fluree-db-sparql/src/ast/update.rs:22-32`). `parse_query_body` + dispatches update on `KwInsert | KwDelete | KwWith` only + (`fluree-db-sparql/src/parse/query/mod.rs:308`); `parse_update_operation` errors + on anything else (`fluree-db-sparql/src/parse/query/update.rs:78-80`). Every other + verb falls through to `"expected query form … or update (INSERT, DELETE)"` + (`mod.rs:312-318`), surfaced as HTTP 400 by + `parse_and_lower_sparql_update` (`fluree-db-api/src/tx_builder.rs:42-48`). **All + the graph-management keywords are already lexed** (`fluree-db-sparql/src/lex/token.rs`: + `KwLoad` 255, `KwInto` 256, `KwClear` 257, `KwDrop` 258, `KwCreate` 259, `KwAdd` + 260, `KwMove` 261, `KwCopy` 262, `KwTo` 263, `KwSilent` 130, `KwDefault` 253, + `KwAll` 254, `KwNamed` 121, `Semicolon` 317; string map at 793-806) — the work is + parser/AST/lowering/exec, not lexing. + +2. **`parse_sparql` parses one operation and never checks for trailing tokens.** + `parse_query` runs `parse_prologue` then a single `parse_query_body()?` + (`mod.rs:193-207`); `parse_sparql` returns as soon as that succeeds, with no EOF + assertion (`mod.rs:40-74`). `parse_and_lower_sparql_update` calls `parse_sparql` + exactly once and lowers the single `QueryBody::Update` to one `Txn` + (`tx_builder.rs:41-53`, `lower_sparql_update_ast` at + `fluree-db-transact/src/lower_sparql_update.rs:620-632`). Therefore a request like + `INSERT{…}WHERE{…} ; DELETE{…}WHERE{…}` **parses the INSERT, then silently + discards `; DELETE{…}WHERE{…}`.** The harness confirms this: it feeds the whole + request text to one `.sparql_update(&sparql).commit()` + (`testsuite-sparql/src/query_handler.rs:421-437`), and the failing tests come back + as *evaluation mismatches*, not parse errors — proof the trailing operations were + dropped rather than rejected. + +### Class A — graph-management verbs (parse-time) + +`SPARQL11_SYNTAX_UPDATE_1` positive rejects: test_1, test_2 (`BASE`/`PREFIX` prologue +followed by `LOAD` — `syntax-update-01.ru`/`-02.ru`), test_3 (`LOAD … ;`), test_4 +(`LOAD … INTO GRAPH …`), test_5-7 (`DROP NAMED|DEFAULT|ALL`), test_8 (`DROP GRAPH`), +test_9-12 (`DROP SILENT …`), test_13-14 (`CREATE [SILENT] GRAPH`), test_15-22 +(`CLEAR [SILENT] NAMED|DEFAULT|ALL|GRAPH`), test_37 (`CREATE GRAPH ; LOAD …`). + +`SPARQL11_UPDATE` eval parse-errors (41, all `"expected query form … or update +(INSERT, DELETE)"`): `add01-08` (8), `clear-{all,default,graph,named}-01` (4), +`copy0{1,2,3,4,6,7}` (6), `drop-{all,default,graph,named}-01` (4), +`move0{1,2,3,4,6,7}` (6), and all 13 `update-silent/*` (`add-silent`, +`add-to-default-silent`, `clear-default-silent`, `clear-silent`, `copy-silent`, +`copy-to-default-silent`, `create-silent`, `drop-default-silent`, `drop-silent`, +`load-into-silent`, `load-silent`, `move-silent`, `move-to-default-silent`). + +### Class B — multi-operation requests (parse-time + commit-time). **Register-comment correction.** + +- **`dawg-delete-insert-01c`** (`delete-insert-01c.ru`) = `INSERT{?b knows ?a}WHERE{?a + knows ?b} ; DELETE{?a knows ?b}WHERE{?a knows ?b}`. Correct result: op1 makes the + graph bidirectional, op2 then deletes every `knows` edge → 6 name/mbox triples. + Observed: 12 triples = 6 name/mbox + 6 (now-bidirectional) `knows` — i.e. **op1 ran, + op2 was dropped.** The JSON diff (expected 6, got 12, actual still contains the + `knows` edges) matches this exactly. The sibling `-01` (single combined op) and + `-01b` (`DELETE ; INSERT`, where op2 legitimately matches nothing after op1) both + pass — `-01b` passes *even under the bug* because first-op-only happens to give the + right answer, which is why the bug hid here. +- **`insert-05a`** (`insert-05a.ru`) = three `INSERT…WHERE` ops then `DROP GRAPH :g1 ; + DROP GRAPH :g2`. Expected `g3 = :s :p 1`; observed `g3` empty ("Expected 1, got 0"). + Only op1 runs (copy `g1`→`g2`); the `g3` count-insert (op3) never executes. +- **`insert-data-same-bnode`, `insert-where-same-bnode`, `insert-where-same-bnode2`** + — all identical shape (multi-op ending in `DROP GRAPH …`); all fail with empty `g3` + for the same reason. Note these tests *also* require class A (their trailing ops are + `DROP GRAPH`), but the failure you can observe today is purely B. +- **test_54** (`syntax-update-54.ru`) = `INSERT DATA {_:b1 :p :o} ; INSERT DATA {_:b1 + :p :o}` — a negative test for reusing a bnode label across operations. It "passes + parse" (so the negative test fails) because the second operation is discarded and + the reuse is never seen. Fixing it needs B (parse both ops) **then** E-style + cross-operation bnode-scope validation. + +Because these are single `sparql_update()` calls, none of this is a staging bug — the +data proves the AST only ever carried the first operation. + +### Class C — empty / prologue-only requests (parse-time). **Register-comment correction.** + +- **test_38** `# Empty` (only a comment) → tokens are empty → `parse_query_body` + hits EOF and errors (`mod.rs:313-318`). +- **test_39** `BASE ` only, **test_40** `PREFIX : ` + only → prologue parses, then `parse_query_body` finds no operation → error. + +Per SPARQL 1.1 Update grammar `Update ::= Prologue (Update1 (';' Update)?)?` the +operation is **optional**; a prologue-only or empty request is a valid no-op. + +### Class D — `GRAPH` blocks in `DELETE WHERE` (validate + lower) + +- **test_36** (`syntax-update-36.ru`, `DELETE WHERE { GRAPH { … } }`) is a + *syntax* test; the parser accepts the GRAPH block (`update.rs:271-277`, + `parse_quad_pattern_graph_block` 314-365) but `validate_delete_where` pushes an error + (`fluree-db-sparql/src/validate/mod.rs:154-165`) → `has_errors` → the positive test + fails. +- **dawg-delete-where-02/04/06** (eval) reach lowering, which hard-errors at + `lower_sparql_update.rs:827-830` (`UnsupportedFeature { feature: "GRAPH blocks in + DELETE WHERE" }`). Same root cause, different layer. Register comment ("engine: + GRAPH blocks in DELETE WHERE unsupported") is correct. + +### Class E — blank nodes not rejected in DELETE forms (validate) + +The validator rejects only *variables* in ground DATA (`validate_ground_quad_data`, +`validate/mod.rs:214-247`) and only *GRAPH variables* in Modify templates +(`validate_update_template_quad_pattern` 183-209); it never rejects **blank nodes**. +SPARQL 1.1 §4.1.2 forbids blank nodes in `DELETE`, `DELETE DATA`, and `DELETE WHERE` +(they would be unbound wildcards). Failing negative tests: +- **test_50** `DELETE WHERE { _:a

}`, **test_51** `DELETE {

[] } + WHERE{…}`, **test_52** `DELETE DATA { _:a

}`. +- **dawg-delete-insert-03/03b/05/06/07/07b/08/09** — each has `[]` or `_:b` in the + DELETE template (03b/09 use labelled `_:b`; the rest use `[]`). All eight are + `mf:NegativeSyntaxTest11` with a bare `mf:action` (no data), so a validate-only + rejection suffices. Register comment ("missing validation") is correct. + +### Class F — USING + explicit-`GRAPH` dataset scoping (prepare-time) + +- **dawg-delete-using-02a** (`delete-using-02.ru`) and **dawg-delete-using-06a** + (`delete-using-06.ru`): single `DELETE{…} USING WHERE { GRAPH {…} }` + operations. USING default-graph scoping *is* wired + (`fluree-db-transact/src/stage.rs:1382-1399` selects the WHERE default graph from + `sparql_where.using_default_graph_iris`, falling back to `with_graph_iri`), but the + observed results ("Expected 5 got 3", "Expected 6 got 4") show the WHERE bindings / + delete-target resolution are wrong when an explicit `GRAPH ` block co-occurs with + `USING ` and a default-graph DELETE template. The manifest's own comment names + the trap: *"make sure the GRAPH clause does not override the USING clause."* This is + a genuine staging-semantics bug, single-operation, unrelated to B. Register comment + ("USING semantics incomplete") is correct. + +--- + +## 2. Fix design per class + +### A — graph-management grammar + AST + execution mapping + +**Grammar/AST.** Add variants to `UpdateOperation` (`ast/update.rs`) and a new +request-level container (see B). New AST nodes (all trivially `Clone/Debug/PartialEq` +with a `SourceSpan`): +- `Load { silent: bool, source: Iri, into: Option }` +- `Clear { silent: bool, target: GraphRefAll }` +- `Drop { silent: bool, target: GraphRefAll }` +- `Create { silent: bool, graph: Iri }` +- `Add | Copy | Move { silent: bool, from: GraphOrDefault, to: GraphOrDefault }` +- `GraphRefAll ::= Default | Named | All | Graph(Iri)`; `GraphOrDefault ::= Default | + Graph(Iri)`. + +Parser: extend `parse_update_operation` (`parse/query/update.rs:21`) to branch on +`KwLoad/KwClear/KwDrop/KwCreate/KwAdd/KwCopy/KwMove`, each consuming an optional +`KwSilent`, then the target grammar (`INTO GRAPH`, `TO`, `GRAPH|DEFAULT|NAMED|ALL`). +All grammar is in the SPARQL 1.1 Update spec §3.1.2–3.2. This is pure recursive-descent +work mirroring the existing `parse_using_clause` style. + +**Lowering + execution mapping onto Fluree's flake/g_id model.** Fluree models a named +graph as a reserved `GraphId` (`u16`) in a per-ledger `GraphRegistry` +(`fluree-db-core/src/graph_registry.rs`: reserved 0=default, 1=txn-meta, 2=config, +≥3=user, lines 31-41); each flake is tagged with the *Sid of the graph IRI* +(`fluree-db-core/src/flake.rs:120-124`), bridged to a `GraphId` via +`build_reverse_graph` (`db.rs:446-465`) / `resolve_flake_g_id` +(`fluree-db-novelty/src/lib.rs:547-556`). Mapping: + +| Verb | Execution | Existing machinery | Gap | +|---|---|---|---| +| `CLEAR GRAPH ` / `CLEAR DEFAULT` | scan every flake in that g_id, emit retraction (`op=false`) flakes | g_id-scoped scan (`GraphDbRef`, `fluree-db-ledger/src/lib.rs:519`); per-g_id novelty (`fluree-db-novelty/src/fact_state.rs:33-38`); retract templates as in `lower_delete_data` | need a "retract-all-in-g_id" staging primitive (none today) | +| `CLEAR ALL` / `CLEAR NAMED` | iterate registry g_ids (≥3, plus default for ALL), clear each | `GraphRegistry::iter_entries` (`graph_registry.rs:327`) | same | +| `DROP …` | **semantically = CLEAR** in Fluree's model (see empty-graph note) | same as CLEAR | true removal needs a registry-*remove* path; `graph_registry.rs` is additive-only (`apply_delta` 241, seed ctors — no `remove`/`drop`) | +| `CREATE GRAPH ` | register an empty graph | — | no empty-graph registration path (user g_ids only enter the registry via templates that carry triples, `lower_sparql_update.rs:1032`); **and the harness cannot observe an empty graph** — treat as near-no-op | +| `COPY/MOVE/ADD ` | scan g_id(a) → assert into g_id(b); `COPY`/`MOVE` first clear b; `MOVE` also clears a | g_id scan + template assertion + clear (above) | compose from the CLEAR primitive + template insert | +| `LOAD [INTO GRAPH ]` | fetch remote RDF, parse, insert into target | Turtle/TriG parser exists (`fluree_graph_turtle`); FlakeSink | **no HTTP fetch in transact/api** (reqwest only behind `search-remote-client` for SPARQL SERVICE, `remote_service.rs`) — see recommendation | + +**W3C empty-graph semantics vs Fluree (important for CLEAR/DROP/CREATE):** W3C +distinguishes `CLEAR GRAPH ` (graph remains, empty) from `DROP GRAPH ` (graph +gone) and `CREATE GRAPH ` (empty graph now exists). **Fluree does not track empty +named graphs**, and the harness explicitly cannot see them: `run_update_eval_test` +only compares *non-empty* graphs and lists only non-empty named graphs +(`query_handler.rs:468-479`, with an in-code note to this effect). Consequences: +- CLEAR and DROP of a graph are **indistinguishable to the test harness** — both leave + it non-existent-because-empty. So `DROP` can be implemented as CLEAR semantics for + W3C purposes; true unregistration is a separate, harness-invisible nicety. +- `CREATE GRAPH ` on a fresh graph is unobservable (creates nothing the harness can + see) and on an existing graph is a spec no-op — so a validate-only/near-no-op + implementation passes. Only `create-silent` is in the failing set (CREATE of an + existing graph, swallowed by SILENT). +- The four `clear/drop-{default,graph,named,all}-01` eval tests operate on *populated* + graphs, so they ARE observable and need the real retract-all primitive. + +**SILENT.** `SILENT` must swallow the "target already/doesn't exist" errors so the op +becomes a no-op (SPARQL §3.1.2). The 13 `update-silent/*` tests all target +missing/existing graphs and expect the store **unchanged** (e.g. `clear-silent` on a +non-existent graph, `mf:result` = input `spo.ttl`). Notably, `load-silent` / +`load-into-silent` LOAD a non-resolvable/remote source; with SILENT, a LOAD that +fails-to-fetch becomes a no-op and the store is unchanged — **so both can pass even +without real HTTP fetch**, provided LOAD is parsed and its failure is swallowed. + +**LOAD recommendation (open decision).** Non-SILENT `LOAD ` of arbitrary remote +RDF requires an HTTP client that is intentionally absent from the embedded transact/api +path. Recommend: implement parsing + SILENT-swallowed-failure (clears the two silent +tests), and **register non-SILENT remote `LOAD` as a documented divergence** in the +skip register with a spec link, unless/until an opt-in fetch hook is added. There are +no non-SILENT `LOAD` eval tests in the failing set, so this costs zero W3C coverage. + +### B — multi-operation requests + sequential execution + +**Parse.** Introduce a request node (e.g. `SparqlAst.body = QueryBody::UpdateRequest( +Vec)`, or a new top-level `UpdateRequest`) and loop in `parse_query` +over `Semicolon`-separated operations, sharing one prologue and threading PREFIX/BASE +across operations per spec. Add a trailing-token/EOF assertion so a request that fails +to fully consume its input is an error (this also hardens single-op parsing). + +**Execution — the subtle part.** SPARQL §3.1 requires each operation to observe the +graph-store state left by the previous one (the reason `-01c` differs from `-01`). So a +request must stage operations **sequentially against evolving state within one commit**: +each op's WHERE evaluates over the novelty overlay produced by the prior ops. Today one +commit stages one `Txn` (`tx_builder.rs:972-1007`, `stage_transaction_from_txn`). The +design choice is either (a) stage a `Vec` sequentially, re-deriving the staged view +between ops before the single commit, or (b) N commits (simpler but changes commit +count / `t`, and is not atomic). Recommend (a) for atomicity and to match "one request = +one transaction." This is UPDATE-path-only code; it does not touch query execution. + +### C — empty/prologue-only requests + +Fold into B: an `UpdateRequest` with zero operations is valid and lowers to an empty +no-op `Txn` (the harness already treats an empty transaction as a valid no-op, +`query_handler.rs:431-433`). If B is deferred, a one-line fix in `parse_query_body` +(allow EOF after prologue for update context) covers C alone. + +### D — `GRAPH` blocks in `DELETE WHERE` + +`DELETE WHERE` is shorthand for `DELETE { P } WHERE { P }`. The Modify path already +supports concrete-IRI `GRAPH` blocks end to end (`lower_modify` 881-999, templates get +g_ids at `lower_quad_pattern_to_templates:1020-1038`). The fix is to make +`lower_delete_where` (`lower_sparql_update.rs:792-876`) route GRAPH-bearing quad +patterns through the same template + `SparqlWhereClause` machinery instead of erroring +at 827-830, and drop the validator rejection at `validate/mod.rs:154-165`. Blank-node→ +existential-var rewriting must be preserved (it already is on the triple-only path). + +### E — reject blank nodes in DELETE forms + +Add a blank-node check to `validate_update_template_quad_pattern` (DELETE side only, for +Modify), `validate_delete_where`, and `validate_ground_quad_data` (DELETE DATA). Emit a +diagnostic mirroring the existing variable/GRAPH-var rejections. INSERT templates and +INSERT DATA must still **allow** blank nodes (CONSTRUCT-style), so the check is scoped to +delete contexts. test_54 additionally needs cross-operation bnode-label scoping, which +depends on B. + +### F — USING + explicit-GRAPH scoping + +Investigate `lower_sparql_where_patterns` (`stage.rs:1654-1668`) and the default-graph +selection at `stage.rs:1382-1399`: when `USING ` is present AND the WHERE contains an +explicit `GRAPH ` block, the explicit block must win for its own triples while USING +sets the default graph for un-GRAPH'd triples, and the (default-graph) DELETE template +must target the graph store's default graph — not `g`. The two failing tests are the +minimal reproductions; expected outputs are in `delete/delete-post-01f.ttl` / +`delete-post-02f.ttl`. This is the only class touching shared query-execution lowering. + +--- + +## 3. Hot-path classification (all UPDATE work is off the query hot path) + +| Class | Classification | Justification | +|---|---|---| +| A grammar/AST/parser | **parse-time** | new tokens already lexed; new AST variants + recursive-descent branches. Zero query-path code. | +| A execution (CLEAR/DROP/COPY/MOVE) | **prepare/commit-time** | a g_id-scoped read scan + retract/assert flakes, taken only by these verbs; the scan reuses read code but is not the per-row filter/join path and never runs for queries. | +| B parse | **parse-time** | request-level `;` loop + EOF check. | +| B execution | **commit-time** | sequential staging of `Vec` within one commit; UPDATE staging/commit only. | +| C | **parse-time** | empty-request acceptance. | +| D | **parse/validate + lower-time** | reuse Modify template + SparqlWhereClause lowering; no new per-row code. | +| E | **parse/validate-time** | additional validator diagnostics. | +| F | **prepare-time** | dataset/default-graph selection is chosen once per operation in staging (`stage.rs:1382-1399`), not per-row. | + +**Shared-code check (required by the brief).** The only shared surface with query +execution is UPDATE `WHERE` evaluation: `Modify`/`DELETE WHERE` WHERE clauses are lowered +through the shared SPARQL pipeline and run on the shared engine +(`stage.rs:1353-1359` → `lower_sparql_where_patterns` → planner/operators). **This is +already the case today for every `DELETE…WHERE`** — none of A/B/C/D/E adds new per-row +query code; they add parse/validate/staging code. **F** tweaks *which* graph the shared +WHERE runs against (prepare-time selection), not the operators themselves. The query hot +paths guarded by benches (`query_hot_bsbm`, `query_hot_bsbm_bi`) are not touched by any +class. Bench guardrails are therefore not strictly required for A–E; F should re-run the +query benches only to confirm the dataset-selection change is prepare-time (it is). + +--- + +## 4. Surface parity (SPARQL + JSON-LD) + +SPARQL and JSON-LD update share the `Txn` IR and engine; Fluree owns both grammars, so +the "SPARQL-possible ⇒ JSON-LD-possible" rule applies and each fix names its JSON-LD +regression test. JSON-LD update has its own parser producing the same IR, including +default- and named-graph WHERE scoping +(`fluree-db-transact/src/parse/jsonld.rs:310-446`, +`parse_update_where_{default_graph_iris,named_graphs}`). + +**Cypher is out of scope for this burn-down.** Cypher is openCypher — Fluree does not +own the grammar and will not add custom syntax — so there is no support-matrix or +syntax-parity work here. Cypher benefits implicitly from every IR/engine-level fix (D, +F, and the execution half of A) because it lowers to the same `Txn`/engine +(`fluree-db-transact/src/lower_cypher_update.rs`); no per-fix Cypher assessment is +needed. + +| Class | Surface category (per `sparql-compliance.md` §Query Surface Parity) | JSON-LD action | +|---|---|---| +| **A** CLEAR/DROP/CREATE/COPY/MOVE/ADD | **New IR/engine capability + SPARQL surface syntax.** Graph-store management verbs with no JSON-LD text form today. | The underlying "retract-all-in-graph" / "copy graph" capability is genuinely new and useful → **expose it as a JSON-LD/txn-builder capability + public API** (`GraphTransactBuilder::clear_graph`/`drop_graph`/`copy_graph`) in the same effort, with tests. Record decision if deferred. | +| **A** LOAD (remote) | SPARQL-only + external I/O | No JSON-LD equivalent; JSON-LD ingest is via the insert API. No parity. | +| **B** multi-op sequencing | Mostly SPARQL-only (text `;`). | If `Vec` sequential staging is added to the IR, JSON-LD *could* gain an ordered array-of-ops form — **record as a design decision**, not required for parity. | +| **C** empty request | SPARQL-only. | N/A. | +| **D** GRAPH in DELETE WHERE | **IR/engine-level** (reuses Modify GRAPH lowering). | Fixes JSON-LD named-graph delete implicitly → **add a JSON-LD regression test** for delete-with-named-graph. | +| **E** bnode-in-DELETE validation | SPARQL surface validation. | JSON-LD delete templates should mirror the "no blank node in delete" rule → **add a JSON-LD negative test**. | +| **F** USING/graph scoping | **IR/engine-level** (staging dataset selection). | JSON-LD's `where` default/named-graph scoping shares this code → **add a JSON-LD regression test** exercising graph-scoped delete-where. | + +**Exact regression-test files to add to** (per the compliance doc's "Where to add +parity tests" table, cross-checked against the tree): +- **SPARQL UPDATE** (D, F, and A execution): `fluree-db-api/tests/it_named_graphs.rs` + (already drives `sparql_update` on named graphs) — or a new `it_sparql_update.rs`. +- **JSON-LD transactions** (D, E, F, and any A capability exposed): + `fluree-db-api/tests/it_transact_update.rs` and `it_named_graphs.rs`. +- A compliance fix is "done" only when the W3C register entry is removed **and** the + corresponding JSON-LD test exists (compliance doc "Regression-test rule"). + +--- + +## 5. Blast radius, PR composition, risks, open questions + +**Files touched (by class):** +- A: `fluree-db-sparql/src/ast/update.rs`, `.../parse/query/update.rs`, + `.../parse/query/mod.rs`, `.../validate/mod.rs`; + `fluree-db-transact/src/lower_sparql_update.rs`, `.../ir.rs`, `.../stage.rs`, + `.../commit.rs`; `fluree-db-core/src/graph_registry.rs` (add a `remove` path only if + true DROP is pursued); `fluree-db-api/src/{tx_builder.rs, graph_transact_builder.rs}` + (public API for the exposed capability). +- B: `.../parse/query/mod.rs`, `ast` (request node), `fluree-db-api/src/tx_builder.rs` + + `fluree-db-transact/src/{stage.rs, commit.rs}` (sequential staging). +- C: `.../parse/query/mod.rs` (folds into B). +- D: `.../validate/mod.rs`, `.../lower_sparql_update.rs`. +- E: `.../validate/mod.rs`. +- F: `fluree-db-transact/src/stage.rs` (+ possibly shared WHERE lowering). + +**LOC ballpark:** A ≈ 500–800 (grammar+AST ~250, exec mapping ~300–500); B ≈ 250–400 +(the sequential-staging design dominates); C ≈ 10 (or free with B); D ≈ 120; E ≈ 60; +F ≈ 80–200 (depends on how deep the USING/GRAPH interaction goes). Plus ~400 LOC of +JSON-LD/SPARQL parity tests. + +**Suggested PR composition (each PR shrinks its register entries; both directions +enforced by CI):** +1. **PR-U1 — negative-syntax validation (E) + DELETE-WHERE-GRAPH (D).** Smallest, + pure parse/validate/lower, no new grammar. Clears test_36, test_50/51/52, + dawg-delete-insert-03..09, dawg-delete-where-02/04/06 (14 tests). Low risk. +2. **PR-U2 — multi-operation requests + empty request (B + C).** Parser request loop + + sequential staging. Clears dawg-delete-insert-01c, test_38/39/40, and unblocks + test_54; combined with A, unblocks the four basic-update tests. Medium risk + (sequential-staging design). +3. **PR-U3 — graph-management grammar + CLEAR/DROP/CREATE + SILENT (A, most of it).** + Grammar/AST/parser + CLEAR/DROP retract-all primitive + CREATE near-no-op + SILENT + swallow. Clears clear/drop/create/*-silent + the syntax rejects (test_1-22,37) and + many eval tests. Expose `clear_graph`/`drop_graph` on the builder + JSON-LD op here. +4. **PR-U4 — COPY/MOVE/ADD (A remainder).** Compose over PR-U3's primitives. Clears + add*/copy*/move*. +5. **PR-U5 — LOAD (parse + SILENT no-op) + documented-divergence register entry for + remote LOAD.** Clears load-silent/load-into-silent and the LOAD syntax tests. +6. **PR-U6 — USING/GRAPH scoping (F).** Isolated staging-semantics fix. + +**Risks:** +- *B sequential staging* is the highest-risk item: getting "each op sees prior ops' + writes, atomically, in one commit" right (novelty overlay between ops) without + changing single-op behavior. Mitigate by keeping the single-op path byte-identical + and adding the loop only when >1 op is present. +- *True DROP vs CLEAR*: implementing real graph unregistration touches the additive-only + `GraphRegistry` and the index root; since the harness can't observe it, **do not build + it for W3C** — ship CLEAR-semantics DROP and note the divergence. +- *F* is the only fix that risks perturbing shared WHERE lowering; keep the change at the + prepare-time graph-selection layer and run query benches to confirm. + +**Open design questions for the team:** +1. **Multi-op transaction model:** one atomic commit staging `Vec` sequentially + (recommended) vs N commits? Affects `t`/commit semantics and the builder API. +2. **DROP semantics:** ship DROP≡CLEAR (harness-equivalent) now and defer true + unregistration, or add a `GraphRegistry::remove` + index-root story now? +3. **Remote LOAD:** documented divergence (recommended, zero W3C cost) vs an opt-in + fetch hook? If a hook, where does it live given transact/api has no HTTP client? +4. **CREATE/empty graphs:** accept that Fluree cannot represent an empty named graph + (near-no-op CREATE) as a permanent documented divergence? +5. **JSON-LD graph-management surface:** which A capabilities (clear/drop/copy) get a + first-class JSON-LD/txn-builder form now vs later, per the parity guideline? diff --git a/docs/contributing/sparql-compliance.md b/docs/contributing/sparql-compliance.md index 790c4e95d5..2032cc6db4 100644 --- a/docs/contributing/sparql-compliance.md +++ b/docs/contributing/sparql-compliance.md @@ -25,7 +25,13 @@ cd testsuite-sparql cargo test ``` -This runs all non-ignored W3C test suites. Currently that includes SPARQL 1.0 and 1.1 syntax tests. Query evaluation tests (12 categories, 327 tests) are registered but `#[ignore]`'d — run them with `--include-ignored` or via the Makefile. +This runs **every** registered W3C test suite: SPARQL 1.0 and 1.1 syntax, +all 1.1 query-evaluation categories, SPARQL UPDATE (syntax + evaluation), +result formats (JSON/CSV/TSV), SPARQL 1.2, and the infrastructure suites. +Every suite must be green: each test either passes or appears in that suite's +explicit skip register (see "Managing the Skip List"). CI runs exactly this +command — a new failure *or* a stale skip entry (a registered test that now +passes) fails the build. ### Run a Specific Suite @@ -36,11 +42,14 @@ cargo test sparql11_syntax_query_tests # SPARQL 1.0 syntax only cargo test sparql10_syntax_tests -# Full query evaluation (~5 min, includes all 12 categories) -cargo test sparql11_query_w3c_testsuite -- --include-ignored - # Single evaluation category -cargo test sparql11_functions -- --include-ignored +cargo test sparql11_functions + +# SPARQL UPDATE (syntax + evaluation) +cargo test sparql11_update_tests + +# SPARQL 1.2 (RDF-star) suites +cargo test sparql12_ ``` ### Run With Verbose Output @@ -57,33 +66,25 @@ The `testsuite-sparql/Makefile` provides convenience targets: ```bash # --- Running tests --- -make test # Run syntax tests (live output) -make test-syntax11 # SPARQL 1.1 syntax tests only -make test-syntax10 # SPARQL 1.0 syntax tests only -make test-eval # Full eval suite, all 12 categories -make test-eval-cat CAT=functions - # Run one eval category -make test-eval10 # Run SPARQL 1.0 eval tests +make test # Full compliance suite (identical to CI) +make test-cat CAT=sparql11_functions + # Run one suite (fn names in tests/w3c_sparql.rs) # --- Reports --- -make count-eval # Quick pass/fail counts for eval tests -make report-eval-json # JSON report for 1.1 eval → report-eval.json -make report-10-json # JSON report for 1.0 eval → report-10.json -make cat-json CAT=functions - # JSON report for a single category - -# --- Analysis (requires report-eval.json) --- -make summary # Per-category pass/fail breakdown -make classify # Group failures by error type -make failures-eval # List all eval failures with type -make failures-eval CAT=functions - # Filter failures to one category +make report-cat CAT=sparql11_update_tests + # JSON report for one suite -> report-.json +make report-10-json # JSON report for the SPARQL 1.0 eval suite + +# --- Analysis (on a report generated above) --- +make summary REPORT=report-sparql11_update_tests.json +make classify REPORT=report-sparql11_update_tests.json +make failures-cat REPORT=report-sparql11_update_tests.json # --- Investigating specific tests --- -make investigate-eval TEST=substring01 - # Search eval report for a test +make investigate TEST=pp16 + # Register entries + matching files for a test make show-query TEST=syntax-select-expr-04.rq - # Print the .rq file for a test + # Print the .rq/.ru file for a test make clean # Remove generated report files ``` @@ -139,13 +140,14 @@ The fragment (`#test_34`) identifies the specific test within that manifest. The ### Per-Category Breakdown -Use `make summary` to see pass/fail rates by W3C category: +Generate a per-suite JSON report, then summarize it: ```bash -make summary +make report-cat CAT=sparql10_query_eval_tests +make summary REPORT=report-sparql10_query_eval_tests.json ``` -This requires `report-eval.json` (generated automatically if missing). Output looks like: +Output looks like: ``` Category Pass Fail Total Rate @@ -160,10 +162,10 @@ TOTAL 167 160 327 51.1% ### Error Classification -Use `make classify` to group failures by root cause: +Use `make classify` to group a report's failures by root cause: ```bash -make classify +make classify REPORT=report-sparql10_query_eval_tests.json ``` Error types: @@ -179,11 +181,10 @@ Error types: ### Listing Failures -Use `make failures-eval` to list all failures with their type and first error line: +Use `make failures-cat` to list a report's failures with their type and first error line: ```bash -make failures-eval # All failures -make failures-eval CAT=functions # Just one category +make failures-cat REPORT=report-sparql11_update_tests.json ``` ### JSON Reports @@ -191,9 +192,8 @@ make failures-eval CAT=functions # Just one category For programmatic analysis, generate a JSON report: ```bash -make report-eval-json # → report-eval.json -make report-10-json # → report-10.json -make cat-json CAT=bind # → report-bind.json +make report-cat CAT=sparql11_bind # → report-sparql11_bind.json +make report-10-json # → report-sparql10_query_eval_tests.json ``` Report format: @@ -211,9 +211,9 @@ Report format: The analysis script at `scripts/analyze_report.py` can also be used directly: ```bash -python3 scripts/analyze_report.py summary report-eval.json -python3 scripts/analyze_report.py classify report-eval.json -python3 scripts/analyze_report.py failures report-eval.json --category functions +python3 scripts/analyze_report.py summary report-.json +python3 scripts/analyze_report.py classify report-.json +python3 scripts/analyze_report.py failures report-.json --category functions ``` ## From Failure to Fix: The Workflow @@ -323,7 +323,7 @@ After making code changes: cargo test sparql11_syntax_query_tests -- --nocapture 2>&1 | grep "test_34" # Verify you haven't regressed other tests -make count-eval +make test # Run the parser's own tests (from workspace root) cd .. && cargo test -p fluree-db-sparql @@ -414,15 +414,19 @@ The parser code for BIND is in fluree-db-sparql/src/parser/. Please find the common root cause and fix all three. ``` -## JSON-LD Query Parity +## Query Surface Parity (JSON-LD) + +SPARQL, JSON-LD query, and Cypher (`fluree-db-cypher`) all compile to the **same intermediate representation** (`fluree-db-query/src/ir.rs`) and share the entire execution engine. Team guideline — every W3C compliance fix must classify itself as one of: + +1. **IR/engine-level fix** (evaluation semantics, planner, operators, storage): the fix lands once and applies to all surfaces implicitly — the user-facing syntax of each surface is unchanged. Still add a JSON-LD regression test for the fixed behavior (see below): the W3C submodule guards the SPARQL surface, but nothing guards the same semantics through the JSON-LD surface unless we write it. -SPARQL and JSON-LD queries in Fluree compile to the **same intermediate representation** (`fluree-db-query/src/ir.rs`) and share the entire execution engine. This means: +2. **Surface-syntax addition**: if we make something newly *possible in SPARQL* (new function, operator, clause, or syntax), it must also be made possible in JSON-LD query syntax as part of the same effort — with its own tests. Fluree **owns** the JSON-LD query syntax, so this is always within our power. -1. **Shared code changes affect both languages.** If you add a new `Expression` variant, `Pattern` variant, or `AggregateFn` for SPARQL, it automatically becomes available to JSON-LD query as well. Ensure JSON-LD tests still pass. +3. **SPARQL-only surface features**: property paths syntax, RDF-star/annotation syntax, ASK query form, and SPARQL UPDATE text forms have no JSON-LD equivalent. These don't require syntax parity — but if the underlying IR/engine capability is new (e.g., a new pattern type), consider whether JSON-LD should be able to express it and record the decision. -2. **New SPARQL features may need JSON-LD test coverage.** If a feature you're implementing for SPARQL compliance (e.g., a new built-in function, a new filter operator) is also expressible in JSON-LD query syntax, add corresponding JSON-LD integration tests. +**Cypher is deliberately excluded from this guideline.** We do not own the openCypher grammar and do not introduce custom Cypher syntax, so SPARQL compliance work carries no Cypher syntax obligations. Cypher benefits from IR/engine-level fixes automatically; what openCypher exposes is governed separately by `docs/reference/cypher-support-matrix.md`. -3. **Some features are SPARQL-only.** Property paths, RDF-star, ASK query form, and SPARQL Update don't have JSON-LD equivalents. These don't require parity testing. +**Regression-test rule:** a compliance fix is not done when the W3C test goes green — it is done when the register entry is removed AND an equivalent JSON-LD test exists for behavior that JSON-LD can express. ### Where to add parity tests @@ -436,7 +440,7 @@ SPARQL and JSON-LD queries in Fluree compile to the **same intermediate represen ```bash # SPARQL W3C tests (from testsuite-sparql/) -make test-eval-cat CAT= +make test-cat CAT= # JSON-LD query tests (from workspace root) — integration tests are grouped # into grp_* binaries; run a whole group or filter to one file's module. @@ -480,7 +484,7 @@ testsuite-sparql/ **2. Handler Dispatch** (`evaluator.rs`): `TestEvaluator` maps test type URIs (e.g., `mf:PositiveSyntaxTest11`) to handler functions. For each test, it finds the matching handler and invokes it. -**3. SPARQL Handlers** (`sparql_handlers.rs` + `query_handler.rs`): The Fluree-specific logic. Both syntax and evaluation tests run in isolated **subprocesses** via the `run-w3c-test` binary (`subprocess.rs`). For syntax tests, the subprocess calls `parse_sparql()` + `validate()` and reports whether errors were found (5-second timeout). For evaluation tests, the subprocess creates an in-memory Fluree ledger, loads Turtle test data, executes the SPARQL query, and compares results against expected `.srx`/`.srj` files using isomorphic matching (10-second timeout). If a test exceeds its timeout, the parent kills the child process — no zombie threads. +**3. SPARQL Handlers** (`sparql_handlers.rs` + `query_handler.rs`): The Fluree-specific logic. Syntax, query-evaluation, and update-evaluation tests all run in isolated **subprocesses** via the `run-w3c-test` binary (`subprocess.rs`). For syntax tests, the subprocess calls `parse_sparql()` + `validate()` and reports whether errors were found (5-second timeout). For query evaluation tests, the subprocess creates an in-memory Fluree ledger, loads default-graph Turtle plus named graphs (each wrapped as a TriG `GRAPH` block through the transact builder), executes the SPARQL query, and compares results against expected `.srx`/`.srj`/`.ttl`/`.csv`/`.tsv` files using isomorphic matching (10-second timeout). For update evaluation tests (`mf:UpdateEvaluationTest`), the subprocess loads the initial graph-store state (`ut:data`/`ut:graphData`), applies the update through the public `sparql_update` transact surface, and compares the resulting default-graph and named-graph states against `mf:result`'s expected state, including a check for unexpected non-empty named graphs. If a test exceeds its timeout, the parent kills the child process — no zombie threads. **4. Test Entry Points** (`tests/w3c_sparql.rs`): Each test function is ~5 lines — just a manifest URL and a skip list. The harness does the rest. @@ -503,7 +507,7 @@ testsuite-sparql/ ### Query Evaluation Tests (Phase 2) -Each test creates an in-memory Fluree ledger, loads RDF data, executes a SPARQL query, and compares results against W3C expected outputs. Run with `make test-eval-cat CAT=`. +Each test creates an in-memory Fluree ledger, loads RDF data, executes a SPARQL query, and compares results against W3C expected outputs. Run with `make test-cat CAT=`. | Suite | What It Tests | Manifest | | ------------------ | ----------------------------------------------- | --------------------------------- | @@ -535,7 +539,12 @@ Each test creates an in-memory Fluree ledger, loads RDF data, executes a SPARQL ## Managing the Skip List -Skip entries are the `ignored_tests` parameter in `check_testsuite()` calls: +Skip entries live in `tests/registers/mod.rs` — one `pub const` per suite, +passed as the `ignored_tests` parameter of that suite's `check_testsuite()` +call. The register is enforced in **both directions**: an unregistered +failure fails the suite, and a registered test that now *passes* also fails +the suite (stale entry — remove it in the same change that fixes the +feature). Entries look like: ```rust check_testsuite( diff --git a/testsuite-sparql/Makefile b/testsuite-sparql/Makefile index f022860a4d..08520006b2 100644 --- a/testsuite-sparql/Makefile +++ b/testsuite-sparql/Makefile @@ -2,23 +2,22 @@ # # Developer targets for the W3C SPARQL compliance test suite. # Run `make help` to see available targets. +# +# Model: `cargo test` runs EVERY registered W3C suite (no #[ignore]). A suite +# is green when each test passes or appears in tests/registers/mod.rs, and +# check_testsuite fails on unexpected failures AND stale register entries. +# CI invokes `cargo test` directly (identical to `make ci`). CARGO := cargo -REPORT := report.txt -FAILURES_REPORT := failures.txt RDF_TESTS := rdf-tests # W3C URL prefix → local path prefix W3C_PREFIX := https://w3c.github.io/rdf-tests/ LOCAL_PREFIX := $(RDF_TESTS)/ -.PHONY: help test test-all test-syntax11 test-syntax10 test-syntax-update \ - test-eval test-eval-cat test-eval10 test-update test-fed \ - test-results test-entailment \ - report report-eval report-json report-eval-json report-10-json \ - failures failures-list count count-eval \ - summary classify failures-eval cat-json \ - investigate investigate-eval show-query check clean update-submodule +.PHONY: help test ci test-cat report-cat report-10-json \ + summary classify failures-cat \ + investigate show-query check clean update-submodule help: ## Show this help @echo "W3C SPARQL Compliance Test Suite" @@ -29,239 +28,104 @@ help: ## Show this help awk 'BEGIN {FS = ":.*?## "}; {printf " %-24s %s\n", $$1, $$2}' @echo "" @echo "Examples:" - @echo " make test Run syntax tests (CI-safe)" - @echo " make test-all Run ALL tests including ignored" - @echo " make test-eval Run full 1.1 eval suite (~5 min)" - @echo " make test-eval-cat CAT=functions Run one eval category" - @echo " make test-eval10 Run SPARQL 1.0 eval tests" - @echo " make count-eval Quick pass/fail counts for eval" - @echo " make report-eval-json Generate JSON report for 1.1 eval" - @echo " make summary Per-category pass/fail breakdown" - @echo " make classify Group failures by error type" - @echo " make cat-json CAT=functions JSON report for a single category" - @echo " make investigate TEST=... Show context for a specific test" - @echo " make investigate-eval TEST=... Show context in eval report" - @echo " make show-query TEST=... Print the .rq file for a test" + @echo " make test Run the full compliance suite (same as CI)" + @echo " make test-cat CAT=sparql11_functions Run one suite" + @echo " make report-cat CAT=sparql11_update_tests JSON report for one suite" + @echo " make summary REPORT=report-sparql11_update_tests.json" + @echo " make investigate TEST=pp16 Find files/queries for a test" + @echo "" + @echo "Suite names = test fn names in tests/w3c_sparql.rs (sparql10_*," + @echo "sparql11_*, sparql12_*)." # --------------------------------------------------------------------------- # Running tests # --------------------------------------------------------------------------- -test: ## Run all non-ignored tests (syntax; CI-safe) +test: ## Run the FULL W3C compliance suite (all suites; green-gated) $(CARGO) test -- --nocapture 2>&1 -test-all: ## Run ALL registered W3C tests including ignored - $(CARGO) test -- --nocapture --include-ignored 2>&1 - -test-syntax11: ## Run SPARQL 1.1 query syntax tests only - $(CARGO) test sparql11_syntax_query_tests -- --nocapture 2>&1 - -test-syntax10: ## Run SPARQL 1.0 syntax tests only - $(CARGO) test sparql10_syntax_tests -- --nocapture 2>&1 - -test-syntax-update: ## Run SPARQL 1.1 update syntax tests - $(CARGO) test sparql11_syntax_update -- --nocapture 2>&1 - -test-eval: ## Run full SPARQL 1.1 query evaluation suite (~5 min) - $(CARGO) test sparql11_query_w3c_testsuite -- --nocapture --include-ignored 2>&1 +ci: ## CI target: identical to `make test` (CI runs plain `cargo test`) + $(CARGO) test 2>&1 -test-eval-cat: ## Run one eval category (CAT=aggregates|bind|functions|...) +test-cat: ## Run one suite (CAT=) ifndef CAT - @echo "Usage: make test-eval-cat CAT=" - @echo "" - @echo "SPARQL 1.1 categories:" - @echo " aggregates, bind, bindings, cast, construct, exists," - @echo " functions, grouping, negation, project_expression," - @echo " property_path, subquery" + @echo "Usage: make test-cat CAT=" + @echo "e.g. make test-cat CAT=sparql11_functions" + @grep -oE '^fn (sparql[0-9]+_[a-z0-9_]+)' tests/w3c_sparql.rs 2>/dev/null | sed 's/^fn / /' || true @exit 1 endif - $(CARGO) test sparql11_$(CAT) -- --nocapture --include-ignored 2>&1 - -test-eval10: ## Run SPARQL 1.0 query evaluation tests - $(CARGO) test sparql10_query_eval -- --nocapture --include-ignored 2>&1 - -test-update: ## Run SPARQL 1.1 update tests (syntax + eval) - $(CARGO) test sparql11_update -- --nocapture --include-ignored 2>&1 - -test-fed: ## Run SPARQL 1.1 federation tests (syntax + service) - $(CARGO) test sparql11_federation -- --nocapture --include-ignored 2>&1 - $(CARGO) test sparql11_service -- --nocapture --include-ignored 2>&1 - -test-results: ## Run SPARQL 1.1 result format tests (JSON + CSV/TSV) - $(CARGO) test sparql11_json_result -- --nocapture --include-ignored 2>&1 - $(CARGO) test sparql11_csv_tsv -- --nocapture --include-ignored 2>&1 - -test-entailment: ## Run SPARQL 1.1 entailment tests - $(CARGO) test sparql11_entailment -- --nocapture --include-ignored 2>&1 + $(CARGO) test --test w3c_sparql $(CAT) -- --exact --nocapture 2>&1 # --------------------------------------------------------------------------- # Reports and analysis +# +# Each suite writes its own JSON when W3C_REPORT_JSON is set. Reports are +# per-suite: generate one with report-cat, then analyze with summary/classify. # --------------------------------------------------------------------------- -report: ## Run syntax tests and save full output to report.txt - @echo "Running W3C SPARQL syntax tests (output → $(REPORT))..." - @$(CARGO) test -- --nocapture > $(REPORT) 2>&1; true - @grep -E "(=== Test Summary|Total:|Passed:|Ignored:|Failed:)" $(REPORT) || true - @echo "Full report saved to $(REPORT)" - -report-eval: ## Run eval tests and save full output to report-eval.txt - @echo "Running W3C SPARQL eval tests (output → report-eval.txt)..." - @$(CARGO) test sparql11_query_w3c_testsuite -- --nocapture --include-ignored > report-eval.txt 2>&1; true - @grep -E "(=== Test Summary|Total:|Passed:|Ignored:|Failed:)" report-eval.txt || true - @echo "Full report saved to report-eval.txt" - -report-json: ## Generate machine-readable JSON report for all tests - @echo "Running all W3C SPARQL tests (JSON output → report.json)..." - @echo " (using --test-threads=1 to avoid concurrent report writes)" - @W3C_REPORT_JSON=report.json $(CARGO) test -- --nocapture --include-ignored --test-threads=1 > /dev/null 2>&1; true - @echo "JSON report written to report.json" - @python3 -c "import json; r=json.load(open('report.json')); print(f' Total: {r[\"total\"]} Passed: {r[\"passed\"]} Ignored: {r[\"ignored\"]} Failed: {r[\"failed\"]} Rate: {r[\"pass_rate\"]}')" 2>/dev/null || true - -report-eval-json: ## Generate JSON report for SPARQL 1.1 eval tests - @echo "Running W3C SPARQL 1.1 eval tests (JSON output → report-eval.json)..." - @W3C_REPORT_JSON=report-eval.json $(CARGO) test sparql11_query_w3c_testsuite \ - -- --nocapture --include-ignored --test-threads=1 > /dev/null 2>&1; true - @echo "JSON report written to report-eval.json" - @python3 -c "import json; r=json.load(open('report-eval.json')); print(f' Total: {r[\"total\"]} Passed: {r[\"passed\"]} Ignored: {r[\"ignored\"]} Failed: {r[\"failed\"]} Rate: {r[\"pass_rate\"]}')" 2>/dev/null || true - -report-10-json: ## Generate JSON report for SPARQL 1.0 eval tests - @echo "Running W3C SPARQL 1.0 eval tests (JSON output → report-10.json)..." - @W3C_REPORT_JSON=report-10.json $(CARGO) test sparql10_query_eval \ - -- --nocapture --include-ignored --test-threads=1 > /dev/null 2>&1; true - @echo "JSON report written to report-10.json" - @python3 -c "import json; r=json.load(open('report-10.json')); print(f' Total: {r[\"total\"]} Passed: {r[\"passed\"]} Ignored: {r[\"ignored\"]} Failed: {r[\"failed\"]} Rate: {r[\"pass_rate\"]}')" 2>/dev/null || true - -failures: report ## Show only failing tests (extracted from report) - @echo "" - @awk '/failing test\(s\):/{found=1; print ""; print; next} found && /^test .*\.\.\. FAILED/{found=0; next} found{print}' $(REPORT) \ - || echo "(no failures)" - -failures-list: report ## One-line-per-failure summary (test ID + error type) - @echo "" - @echo "=== Failure Summary ===" - @awk '/failing test\(s\):/{found=1; next} found && /^test .*\.\.\. FAILED/{found=0; next} found && /^http/{print}' $(REPORT) \ - || echo "(no failures)" - -count: ## Show pass/fail summary counts (syntax tests) - @$(CARGO) test -- --nocapture 2>&1 | grep -E "(Total|Passed|Ignored|Failed|=== Test Summary)" || true +report-cat: ## JSON report for one suite (CAT=) +ifndef CAT + @echo "Usage: make report-cat CAT=" + @exit 1 +endif + @echo "Running $(CAT) (JSON output → report-$(CAT).json)..." + @W3C_REPORT_JSON=report-$(CAT).json $(CARGO) test --test w3c_sparql $(CAT) \ + -- --exact > /dev/null 2>&1; true + @python3 -c "import json; r=json.load(open('report-$(CAT).json')); print(f' Total: {r[\"total\"]} Passed: {r[\"passed\"]} Ignored: {r[\"ignored\"]} Failed: {r[\"failed\"]} Rate: {r[\"pass_rate\"]}')" 2>/dev/null || true -count-eval: ## Show pass/fail counts for 1.1 eval tests - @$(CARGO) test sparql11_query_w3c_testsuite -- --nocapture --include-ignored 2>&1 | grep -E "(Total|Passed|Ignored|Failed|=== Test Summary)" || true - -# --------------------------------------------------------------------------- -# Analysis (require report-eval.json — run `make report-eval-json` first) -# --------------------------------------------------------------------------- +report-10-json: ## JSON report for the SPARQL 1.0 eval suite + @$(MAKE) report-cat CAT=sparql10_query_eval_tests ANALYZE := python3 scripts/analyze_report.py +REPORT ?= -summary: ## Per-category pass/fail breakdown (generates report if needed) - @if [ ! -f report-eval.json ]; then \ - echo "No report-eval.json found — generating (this takes ~5 min)..."; \ - $(MAKE) report-eval-json; \ - fi - @echo "" - @echo "=== Per-Category Breakdown (SPARQL 1.1 Eval) ===" - @echo "" - @$(ANALYZE) summary report-eval.json - -classify: ## Group eval failures by error type (generates report if needed) - @if [ ! -f report-eval.json ]; then \ - echo "No report-eval.json found — generating (this takes ~5 min)..."; \ - $(MAKE) report-eval-json; \ - fi - @echo "" - @echo "=== Failure Classification (SPARQL 1.1 Eval) ===" - @echo "" - @$(ANALYZE) classify report-eval.json +summary: ## Per-category breakdown of a JSON report (REPORT=) +ifndef REPORT + @echo "Usage: make summary REPORT=report-.json (generate with report-cat)" + @exit 1 +endif + @$(ANALYZE) summary $(REPORT) -failures-eval: ## List all eval failures with error type (--category CAT to filter) - @if [ ! -f report-eval.json ]; then \ - echo "No report-eval.json found — generating (this takes ~5 min)..."; \ - $(MAKE) report-eval-json; \ - fi -ifdef CAT - @$(ANALYZE) failures report-eval.json --category $(CAT) -else - @$(ANALYZE) failures report-eval.json +classify: ## Group failures in a JSON report by error type (REPORT=) +ifndef REPORT + @echo "Usage: make classify REPORT=report-.json (generate with report-cat)" + @exit 1 endif + @$(ANALYZE) classify $(REPORT) -cat-json: ## JSON report + summary for one eval category (CAT=functions|bind|...) -ifndef CAT - @echo "Usage: make cat-json CAT=" - @echo "" - @echo "SPARQL 1.1 categories:" - @echo " aggregates, bind, bindings, cast, construct, exists," - @echo " functions, grouping, negation, project_expression," - @echo " property_path, subquery" +failures-cat: ## List failures in a JSON report (REPORT=) +ifndef REPORT + @echo "Usage: make failures-cat REPORT=report-.json" @exit 1 endif - @echo "Running W3C SPARQL 1.1 $(CAT) tests (JSON output → report-$(CAT).json)..." - @W3C_REPORT_JSON=report-$(CAT).json $(CARGO) test sparql11_$(CAT) \ - -- --nocapture --include-ignored --test-threads=1 > /dev/null 2>&1; true - @echo "JSON report written to report-$(CAT).json" - @python3 -c "import json; r=json.load(open('report-$(CAT).json')); print(f' Total: {r[\"total\"]} Passed: {r[\"passed\"]} Failed: {r[\"failed\"]} Rate: {r[\"pass_rate\"]}')" 2>/dev/null || true + @$(ANALYZE) failures $(REPORT) # --------------------------------------------------------------------------- # Investigating specific tests # --------------------------------------------------------------------------- -investigate: ## Show all context for a specific test (TEST=) +investigate: ## Show files matching a test (TEST=) ifndef TEST - @echo "Usage: make investigate TEST=" - @echo "" - @echo "TEST can be:" - @echo " - Full URL: https://w3c.github.io/rdf-tests/sparql/sparql11/syntax-query/manifest.ttl#test_34" - @echo " - Fragment: test_34 (will search report.txt for matches)" + @echo "Usage: make investigate TEST=" @exit 1 endif @echo "=== Test: $(TEST) ===" @echo "" - @echo "--- Searching report for test ---" - @grep "$(TEST)" $(REPORT) 2>/dev/null || echo "(not found in report — run 'make report' first)" - @echo "" - @echo "--- Matching .rq files ---" - @find $(RDF_TESTS) -name "*.rq" | xargs grep -l "$(TEST)" 2>/dev/null || \ - find $(RDF_TESTS) -name "*$(TEST)*" 2>/dev/null || \ - echo "(no matching query files found)" - -investigate-eval: ## Show eval test context (TEST=) -ifndef TEST - @echo "Usage: make investigate-eval TEST=" - @echo "" - @echo "TEST can be:" - @echo " - Full URL fragment: agg01" - @echo " - Test name: substring01" - @echo " (searches report-eval.txt — run 'make report-eval' first)" - @exit 1 -endif - @echo "=== Eval Test: $(TEST) ===" - @echo "" - @echo "--- Searching eval report ---" - @grep "$(TEST)" report-eval.txt 2>/dev/null || echo "(not found in report-eval.txt — run 'make report-eval' first)" + @echo "--- Register entries ---" + @grep -n "$(TEST)" tests/registers/mod.rs 2>/dev/null || echo "(not in any skip register)" @echo "" @echo "--- Matching test files ---" @find $(RDF_TESTS) -name "*$(TEST)*" 2>/dev/null || echo "(no matching files found)" -show-query: ## Print the .rq query file for a test (TEST=) +show-query: ## Print the .rq/.ru file for a test (TEST=) ifndef TEST @echo "Usage: make show-query TEST=" @echo "Example: make show-query TEST=syntax-select-expr-04.rq" @exit 1 endif - @find $(RDF_TESTS) -name "*$(TEST)*" -exec echo "--- {} ---" \; -exec cat {} \; 2>/dev/null || \ + @find $(RDF_TESTS) \( -name "*$(TEST)*" \) -exec echo "--- {} ---" \; -exec cat {} \; 2>/dev/null || \ echo "No file matching '$(TEST)' found in $(RDF_TESTS)/" -# --------------------------------------------------------------------------- -# CI integration -# -# CI should run `make ci` which only executes tests that are expected to pass -# (syntax tests). Eval tests can be added to CI incrementally by removing -# #[ignore] from individual test functions as categories become green. -# --------------------------------------------------------------------------- - -ci: ## CI target: run only tests that are expected to pass - $(CARGO) test 2>&1 - # --------------------------------------------------------------------------- # Maintenance # --------------------------------------------------------------------------- @@ -269,10 +133,11 @@ ci: ## CI target: run only tests that are expected to pass update-submodule: ## Update the W3C rdf-tests submodule to latest cd $(RDF_TESTS) && git pull origin main @echo "" - @echo "Submodule updated. Run 'make test' to check for regressions." + @echo "Submodule updated. Run 'make test' — new tests fail loudly until" + @echo "triaged (fixed or added to tests/registers/mod.rs with rationale)." check: ## Run cargo check on the testsuite crate $(CARGO) check clean: ## Remove generated report files - rm -f $(REPORT) $(FAILURES_REPORT) report-eval.txt report.json report-eval.json report-10.json report-*.json + rm -f report*.json report*.txt failures.txt diff --git a/testsuite-sparql/src/bin/run_w3c_test.rs b/testsuite-sparql/src/bin/run_w3c_test.rs index 39da15c5a5..ac7a9ce10b 100644 --- a/testsuite-sparql/src/bin/run_w3c_test.rs +++ b/testsuite-sparql/src/bin/run_w3c_test.rs @@ -48,6 +48,21 @@ fn main() -> ExitCode { result_url, graph_data, ), + TestDescriptor::UpdateEval { + ref test_id, + ref request_url, + ref data_url, + ref graph_data, + ref result_data_url, + ref result_graph_data, + } => run_update_eval_test( + test_id, + request_url, + data_url.as_deref(), + graph_data, + result_data_url.as_deref(), + result_graph_data, + ), }; // Write result as JSON to stdout @@ -107,6 +122,32 @@ fn run_eval_test( result_url: &str, graph_data: &[(String, String)], ) -> SubprocessResult { + run_async(testsuite_sparql::query_handler::run_eval_test( + test_id, query_url, data_url, result_url, graph_data, + )) +} + +fn run_update_eval_test( + test_id: &str, + request_url: &str, + data_url: Option<&str>, + graph_data: &[(String, String)], + result_data_url: Option<&str>, + result_graph_data: &[(String, String)], +) -> SubprocessResult { + run_async(testsuite_sparql::query_handler::run_update_eval_test( + test_id, + request_url, + data_url, + graph_data, + result_data_url, + result_graph_data, + )) +} + +/// Drive an async test body on a fresh current-thread runtime and convert +/// its outcome to a `SubprocessResult`. +fn run_async(fut: impl std::future::Future>) -> SubprocessResult { let rt = match tokio::runtime::Builder::new_current_thread() .enable_all() .build() @@ -121,14 +162,7 @@ fn run_eval_test( } }; - let outcome = rt.block_on(async { - testsuite_sparql::query_handler::run_eval_test( - test_id, query_url, data_url, result_url, graph_data, - ) - .await - }); - - match outcome { + match rt.block_on(fut) { Ok(()) => SubprocessResult { passed: true, has_errors: None, diff --git a/testsuite-sparql/src/lib.rs b/testsuite-sparql/src/lib.rs index 6c27ba404b..8ac6b01aa2 100644 --- a/testsuite-sparql/src/lib.rs +++ b/testsuite-sparql/src/lib.rs @@ -22,6 +22,11 @@ use sparql_handlers::register_sparql_tests; /// Tests listed in `ignored_tests` are expected to fail and won't cause /// the overall suite to fail. Every other test must pass. /// +/// The skip register is policed in both directions: a test in +/// `ignored_tests` that *passes* fails the suite as a stale skip entry. +/// This keeps the register an accurate ledger of remaining gaps — entries +/// must be removed in the same change that fixes the underlying feature. +/// /// If the `W3C_REPORT_JSON` environment variable is set, a machine-readable /// JSON report is written to that path. Use `--test-threads=1` when generating /// reports to avoid concurrent writes to the same file. @@ -37,21 +42,47 @@ pub fn check_testsuite(manifest_url: &str, ignored_tests: &[&str]) -> Result<()> let mut ignore_count = 0; let mut total = 0; let mut report_entries = Vec::new(); + let mut seen_tests = std::collections::HashSet::new(); for result in &results { total += 1; + seen_tests.insert(result.test.as_str()); let status; match &result.outcome { Ok(()) => { - pass_count += 1; - status = "pass"; + if ignored_tests.contains(&result.test.as_str()) { + failures.push(format!( + "{}: unexpectedly PASSED but is in the skip register — \ + remove its entry (stale skip entries hide regressions)", + result.test + )); + status = "unexpected-pass"; + } else { + pass_count += 1; + status = "pass"; + } } Err(error) => { + let msg = format!("{error:#}"); if ignored_tests.contains(&result.test.as_str()) { - ignore_count += 1; - status = "ignored"; + // The register excuses a KNOWN WRONG ANSWER, not an infra + // death: a registered test that starts timing out or + // crashing is a new regression (hang, panic) hiding + // behind an old entry — fail it. + if is_infra_death(&msg) { + failures.push(format!( + "{}: registered test died by timeout/crash instead of \ + its registered failure mode — investigate as a \ + regression: {msg}", + result.test + )); + status = "fail"; + } else { + ignore_count += 1; + status = "ignored"; + } } else { - failures.push(format!("{}: {error:#}", result.test)); + failures.push(format!("{}: {msg}", result.test)); status = "fail"; } } @@ -74,6 +105,28 @@ pub fn check_testsuite(manifest_url: &str, ignored_tests: &[&str]) -> Result<()> }); } + // A manifest that resolves but yields zero tests means coverage silently + // vanished (submodule restructure, partial checkout, manifest-parser + // regression) — that must never report green. + if total == 0 { + bail!( + "Manifest yielded ZERO tests — coverage silently lost \ + (submodule drift or manifest-parse regression?): {manifest_url}" + ); + } + + // Register entries must correspond to tests that actually ran. An entry + // matching no discovered test (typo, upstream rename, dawgt:Rejected + // drop) would otherwise live forever, overstating the register. + for entry in ignored_tests { + if !seen_tests.contains(entry) { + failures.push(format!( + "register entry matches no test discovered in this suite — \ + remove or correct it: {entry}" + )); + } + } + eprintln!( "\n=== Test Summary ===\n\ Total: {total}\n\ @@ -108,3 +161,11 @@ pub fn check_testsuite(manifest_url: &str, ignored_tests: &[&str]) -> Result<()> Ok(()) } + +/// Whether a test error is an infrastructure death (subprocess timeout, +/// crash, or unparseable output) rather than a produced wrong answer. +fn is_infra_death(msg: &str) -> bool { + msg.contains("timed out") + || msg.contains("Subprocess error:") + || msg.contains("Subprocess produced no parseable output") +} diff --git a/testsuite-sparql/src/manifest.rs b/testsuite-sparql/src/manifest.rs index a14d902bf1..8b5115d3d0 100644 --- a/testsuite-sparql/src/manifest.rs +++ b/testsuite-sparql/src/manifest.rs @@ -5,7 +5,7 @@ use fluree_graph_ir::{Graph, GraphCollectorSink, Term}; use fluree_graph_turtle::parse; use crate::files::{read_file_to_string, resolve_relative_iri}; -use crate::vocab::{mf, qt, rdf, rdfs, rdft}; +use crate::vocab::{mf, qt, rdf, rdfs, rdft, ut}; /// A single W3C test case extracted from a manifest. #[derive(Debug)] @@ -28,6 +28,17 @@ pub struct Test { pub graph_data: Vec<(String, String)>, /// Expected result URL. pub result: Option, + /// Update request file URL (`ut:request`, for UpdateEvaluationTest). + pub update_request: Option, + /// Expected default-graph state after an update (`ut:data` on mf:result). + pub result_data: Option, + /// Expected named-graph state after an update: (graph_name, data_url) + /// (`ut:graphData` on mf:result). + pub result_graph_data: Vec<(String, String)>, + /// Whether the update result carried an explicit `ut:result` marker + /// (e.g. `ut:success`) — distinguishes "expected: empty store" declared + /// on purpose from an unrecognized/mis-parsed `mf:result` shape. + pub result_success: bool, } /// Iterator over W3C test cases, loading manifests lazily. @@ -184,24 +195,42 @@ impl TestManifest { // Parse action — can be a simple IRI or a blank node with structured data let action_term = self.object_for(&subject, mf::ACTION); - let (action, query, data, graph_data) = match action_term { + let (action, query, data, graph_data, update_request) = match action_term { Some(term) if term.is_iri() => { // Simple action: just a URL (used for syntax tests) - (term_to_string(term), None, None, vec![]) + (term_to_string(term), None, None, vec![], None) } Some(term) if term.is_blank() => { - // Structured action: blank node with qt:query, qt:data, etc. + // Structured action: blank node with qt:query / qt:data / + // qt:graphData (query eval) or ut:request / ut:data / + // ut:graphData (update eval). let query = self.object_for(term, qt::QUERY).and_then(term_to_string); - let data = self.object_for(term, qt::DATA).and_then(term_to_string); - let graph_data = self.get_graph_data(term); - (None, query, data, graph_data) + let update_request = self.object_for(term, ut::REQUEST).and_then(term_to_string); + let data = self + .object_for(term, qt::DATA) + .or_else(|| self.object_for(term, ut::DATA)) + .and_then(term_to_string); + let mut graph_data = self.get_graph_data(term, qt::GRAPH_DATA); + graph_data.extend(self.get_graph_data(term, ut::GRAPH_DATA)); + (None, query, data, graph_data, update_request) } - _ => (None, None, None, vec![]), + _ => (None, None, None, vec![], None), }; - let result = self - .object_for(&subject, mf::RESULT) - .and_then(term_to_string); + // Parse result — a simple IRI (query eval: expected result file) or a + // blank node with ut:data / ut:graphData (update eval: expected + // post-update graph store state). + let result_term = self.object_for(&subject, mf::RESULT); + let (result, result_data, result_graph_data, result_success) = match result_term { + Some(term) if term.is_iri() => (term_to_string(term), None, vec![], false), + Some(term) if term.is_blank() => { + let result_data = self.object_for(term, ut::DATA).and_then(term_to_string); + let result_graph_data = self.get_graph_data(term, ut::GRAPH_DATA); + let result_success = self.object_for(term, ut::RESULT).is_some(); + (None, result_data, result_graph_data, result_success) + } + _ => (None, None, vec![], false), + }; Ok(Some(Test { id: test_id.to_string(), @@ -213,6 +242,10 @@ impl TestManifest { data, graph_data, result, + update_request, + result_data, + result_graph_data, + result_success, })) } @@ -224,22 +257,29 @@ impl TestManifest { .map(|t| &t.o) } - /// Extract named graph data from a structured action node. - fn get_graph_data(&self, action: &Term) -> Vec<(String, String)> { + /// Extract named graph data from a structured action/result node. + /// + /// Two W3C shapes: + /// - `qt:graphData ` — the IRI is both the graph name and data URL + /// (query eval tests) + /// - `ut:graphData [ ut:graph ; rdfs:label "name" ]` — labeled form + /// (update eval tests): `rdfs:label` is the graph name, `ut:graph` the + /// data file URL + fn get_graph_data(&self, node: &Term, predicate: &str) -> Vec<(String, String)> { self.graph .iter() - .filter(|t| t.s == *action && t.p.as_iri() == Some(qt::GRAPH_DATA)) + .filter(|t| t.s == *node && t.p.as_iri() == Some(predicate)) .filter_map(|t| { if t.o.is_iri() { // Simple named graph: IRI is both the graph name and data URL let url = t.o.as_iri()?.to_string(); Some((url.clone(), url)) } else if t.o.is_blank() { - // Labeled graph data + // Labeled graph data: name from rdfs:label, URL from ut:graph let label = self .object_for(&t.o, rdfs::LABEL) .and_then(term_to_string)?; - let graph_url = term_to_string(&t.o)?; + let graph_url = self.object_for(&t.o, ut::GRAPH).and_then(term_to_string)?; Some((label, graph_url)) } else { None diff --git a/testsuite-sparql/src/query_handler.rs b/testsuite-sparql/src/query_handler.rs index c78e14a361..cd08986a69 100644 --- a/testsuite-sparql/src/query_handler.rs +++ b/testsuite-sparql/src/query_handler.rs @@ -1,23 +1,32 @@ -//! `QueryEvaluationTest` handler: create an in-memory Fluree ledger, load -//! test data, execute a SPARQL query, and compare against expected results. +//! `QueryEvaluationTest` / `UpdateEvaluationTest` handlers: create an +//! in-memory Fluree ledger, load test data (default + named graphs), execute +//! a SPARQL query or update, and compare against expected results. use std::time::Duration; use anyhow::{bail, Context, Result}; -use fluree_db_api::{format, FlureeBuilder, FormatterConfig, GraphDb, ParsedContext, QueryOutput}; +use fluree_db_api::{ + format, Fluree, FlureeBuilder, FormatterConfig, GraphDb, LedgerState, ParsedContext, + QueryOutput, +}; use crate::files::read_file_to_string; use crate::manifest::Test; use crate::rdfxml; use crate::result_comparison::{are_results_isomorphic, format_results_diff}; use crate::result_format::{ - fluree_construct_to_sparql_results, fluree_json_to_sparql_results, parse_expected_results, + fluree_construct_to_sparql_results, fluree_json_to_sparql_results, parse_expected_graph, + parse_expected_results, project_to_csv_space, RdfTerm, SparqlResults, Triple, }; use crate::subprocess::{run_in_subprocess, TestDescriptor}; /// Max time for a single query evaluation test (data load + query + compare). const EVAL_TIMEOUT: Duration = Duration::from_secs(10); +/// Ledger alias used for every W3C test (each test runs in its own +/// subprocess with a fresh in-memory Fluree, so the alias never collides). +const TEST_LEDGER: &str = "w3c:test"; + /// Handler for `mf:QueryEvaluationTest`. /// /// Runs the test in an isolated subprocess for reliable timeout enforcement. @@ -54,78 +63,282 @@ pub fn evaluate_query_evaluation_test(test: &Test) -> Result<()> { Ok(()) } -/// Inner async function that does the actual test work. +/// Handler for `mf:UpdateEvaluationTest`. /// -/// Public for use by the `run-w3c-test` subprocess binary. -pub async fn run_eval_test( - test_id: &str, - query_url: &str, +/// Same subprocess isolation as query evaluation. Loads the initial graph +/// store state (`ut:data` / `ut:graphData`), applies the SPARQL UPDATE +/// (`ut:request`), and compares the resulting graph store state against the +/// expected state (`ut:data` / `ut:graphData` on `mf:result`). +pub fn evaluate_update_evaluation_test(test: &Test) -> Result<()> { + let test_id = test.id.clone(); + let request_url = test + .update_request + .clone() + .context("UpdateEvaluationTest missing ut:request (update file URL)")?; + + // Guard against a mis-parsed mf:result degrading to a trivially + // satisfiable "expected empty store": the result node must have exposed + // at least one recognized predicate (ut:data / ut:graphData / ut:result). + // A deliberate empty-store expectation always carries `ut:result + // ut:success` in the W3C manifests. + if test.result_data.is_none() + && test.result_graph_data.is_empty() + && !test.result_success + && test.result.is_none() + { + bail!( + "UpdateEvaluationTest mf:result parsed to an empty expectation \ + (no ut:data, ut:graphData, or ut:result) — unrecognized result \ + shape?\nTest: {test_id}" + ); + } + + let descriptor = TestDescriptor::UpdateEval { + test_id, + request_url, + data_url: test.data.clone(), + graph_data: test.graph_data.clone(), + result_data_url: test.result_data.clone(), + result_graph_data: test.result_graph_data.clone(), + }; + + let result = run_in_subprocess(&descriptor, EVAL_TIMEOUT)?; + + if !result.passed { + let error_msg = result.error.unwrap_or_else(|| "Unknown error".to_string()); + bail!("{error_msg}"); + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Graph store setup (shared by query-eval and update-eval) +// --------------------------------------------------------------------------- + +/// Create the test ledger and load initial state: default graph data plus +/// named graphs. Returns the resulting ledger state. +/// +/// Named graphs are loaded as TriG `GRAPH { ... }` blocks through the +/// alias-based transact builder (`upsert_turtle`), which routes through +/// `parse_trig_phase1`. Each file's `@prefix`/`@base` directives are hoisted +/// above the GRAPH block (TriG directives are document-scoped), and each +/// graph loads in its own transaction so prefix declarations can't collide +/// across files. +async fn setup_graph_store( + fluree: &Fluree, data_url: Option<&str>, - result_url: &str, graph_data: &[(String, String)], -) -> Result<()> { - // 1. Create in-memory Fluree + ledger - let fluree = FlureeBuilder::memory().build_memory(); +) -> Result { let ledger = fluree - .create_ledger("w3c:test") + .create_ledger(TEST_LEDGER) .await .context("Failed to create test ledger")?; - // 2. Load default graph data (.ttl or .rdf) if provided. - // For .ttl: prepend @base so relative IRIs resolve correctly. - // For .rdf: convert RDF/XML to N-Triples (absolute IRIs) first. - // Some W3C tests (e.g., empty.ttl) have valid syntax but no triples — - // Fluree rejects these as empty transactions, so we skip gracefully. - let ledger = if let Some(data_url) = data_url { + // Default graph data (.ttl or .rdf). + // For .ttl: prepend @base so relative IRIs resolve correctly. + // For .rdf: convert RDF/XML to N-Triples (absolute IRIs) first. + // Some W3C tests (e.g., empty.ttl) have valid syntax but no triples — + // Fluree rejects these as empty transactions, so we skip gracefully. + let mut current = ledger; + if let Some(data_url) = data_url { let raw = read_file_to_string(data_url) .with_context(|| format!("Reading test data: {data_url}"))?; - if raw.trim().is_empty() { - ledger - } else { + if !raw.trim().is_empty() { let turtle = prepare_for_insert(&raw, data_url)?; - match fluree.insert_turtle(ledger.clone(), &turtle).await { - Ok(result) => result.ledger, - Err(e) if is_empty_transaction(&e) => { - // Turtle had only prefixes / no triples — skip gracefully - ledger - } + match fluree.insert_turtle(current.clone(), &turtle).await { + Ok(result) => current = result.ledger, + Err(e) if is_empty_transaction(&e) => { /* no triples — skip */ } Err(e) => return Err(e).with_context(|| format!("Loading test data: {data_url}")), } } - } else { - ledger - }; + } - // 3. Load named graph data if present. - // Fluree's Turtle parser does not support TriG GRAPH blocks, so we load - // each named graph's data as a separate insert into the default graph. - // This means SPARQL GRAPH queries won't find data in the correct named - // graph — tests relying on named graph separation will fail. This is a - // known limitation until TriG or per-graph loading is supported. - let ledger = if graph_data.is_empty() { - ledger - } else { - let mut current_ledger = ledger; - for (_graph_name, graph_url) in graph_data { - let raw = read_file_to_string(graph_url) - .with_context(|| format!("Reading named graph data: {graph_url}"))?; - if !raw.trim().is_empty() { - let turtle = prepare_for_insert(&raw, graph_url)?; - match fluree.insert_turtle(current_ledger.clone(), &turtle).await { - Ok(result) => current_ledger = result.ledger, - Err(e) if is_empty_transaction(&e) => { /* no triples — skip */ } - Err(e) => { - return Err(e).with_context(|| { - format!("Loading named graph data: {graph_url} for test {test_id}") - }) - } - } + // Named graph data as TriG GRAPH blocks. + for (graph_name, graph_url) in graph_data { + let raw = read_file_to_string(graph_url) + .with_context(|| format!("Reading named graph data: {graph_url}"))?; + if raw.trim().is_empty() { + continue; + } + let content = prepare_for_insert(&raw, graph_url)?; + let (directives, body) = split_turtle_directives(&content); + if body.trim().is_empty() { + continue; + } + let trig = format!("{directives}GRAPH <{graph_name}> {{\n{body}}}\n"); + // upsert_turtle (not insert_turtle): the builder's insert_turtle + // fast path bypasses TriG GRAPH-block extraction. + match fluree + .graph(TEST_LEDGER) + .transact() + .upsert_turtle(&trig) + .commit() + .await + { + Ok(_) => { + current = fetch_state(fluree).await?; + } + Err(e) if is_empty_transaction(&e) => { /* no triples — skip */ } + Err(e) => { + return Err(e).with_context(|| { + format!("Loading named graph data: {graph_url} as <{graph_name}>") + }) } } - current_ledger + } + + Ok(current) +} + +/// Fetch the latest committed state of the test ledger by alias. +async fn fetch_state(fluree: &Fluree) -> Result { + let handle = fluree + .ledger_cached(TEST_LEDGER) + .await + .context("Fetching test ledger state")?; + Ok(handle.snapshot().await.to_ledger_state()) +} + +/// Split a Turtle document into (directives, body). +/// +/// TriG requires directives at document scope, so when wrapping a Turtle +/// file's content in a `GRAPH { }` block its `@prefix`/`@base` lines must be +/// hoisted out. W3C test data declares directives one per line. +/// +/// ASSUMPTION: line-based detection. A multi-line string literal whose +/// continuation line happens to start with `prefix`/`base` would be +/// mis-hoisted; no W3C test data has that shape. +fn split_turtle_directives(content: &str) -> (String, String) { + let mut directives = String::new(); + let mut body = String::new(); + for line in content.lines() { + let t = line.trim_start(); + let lower = t.to_ascii_lowercase(); + if lower.starts_with("@prefix") + || lower.starts_with("@base") + || lower.starts_with("prefix ") + || lower.starts_with("prefix\t") + || lower.starts_with("base ") + || lower.starts_with("base\t") + { + directives.push_str(line); + directives.push('\n'); + } else { + body.push_str(line); + body.push('\n'); + } + } + (directives, body) +} + +// --------------------------------------------------------------------------- +// Graph readback (used by update-eval comparison) +// --------------------------------------------------------------------------- + +/// Read all triples of one graph (default graph when `graph` is `None`) +/// through the public SPARQL query surface. +async fn read_graph_triples( + fluree: &Fluree, + ledger: &LedgerState, + graph: Option<&str>, +) -> Result> { + let db = GraphDb::from_ledger_state(ledger); + let sparql = match graph { + Some(g) => format!("SELECT ?s ?p ?o WHERE {{ GRAPH <{g}> {{ ?s ?p ?o }} }}"), + None => "SELECT ?s ?p ?o WHERE { ?s ?p ?o }".to_string(), + }; + let query_result = fluree + .query(&db, &sparql) + .await + .with_context(|| format!("Reading back graph {graph:?}"))?; + + let empty_context = ParsedContext::new(); + let config = FormatterConfig::sparql_json(); + let json = format::format_results(&query_result, &empty_context, &ledger.snapshot, &config) + .map_err(|e| anyhow::anyhow!("Formatting readback results: {e}"))?; + let results = fluree_json_to_sparql_results(&json) + .context("Converting readback results to SparqlResults")?; + + let SparqlResults::Solutions { solutions, .. } = results else { + bail!("Graph readback returned non-SELECT results"); + }; + + let mut triples = Vec::with_capacity(solutions.len()); + for sol in solutions { + let (Some(s), Some(p), Some(o)) = (sol.get("s"), sol.get("p"), sol.get("o")) else { + continue; // partial row — cannot happen for a bare SPO scan + }; + triples.push(Triple { + subject: s.clone(), + predicate: p.clone(), + object: o.clone(), + }); + } + // An RDF graph is a set; the SPO scan should already be duplicate-free, + // but normalize anyway so comparison semantics don't depend on it. + dedup_triples(&mut triples); + Ok(triples) +} + +fn dedup_triples(triples: &mut Vec) { + let mut seen = std::collections::HashSet::new(); + triples.retain(|t| seen.insert(format!("{t:?}"))); +} + +/// Enumerate the names of all non-empty named graphs. +async fn list_named_graphs(fluree: &Fluree, ledger: &LedgerState) -> Result> { + let db = GraphDb::from_ledger_state(ledger); + let sparql = "SELECT DISTINCT ?g WHERE { GRAPH ?g { ?s ?p ?o } }"; + let query_result = fluree + .query(&db, sparql) + .await + .context("Enumerating named graphs")?; + + let empty_context = ParsedContext::new(); + let config = FormatterConfig::sparql_json(); + let json = format::format_results(&query_result, &empty_context, &ledger.snapshot, &config) + .map_err(|e| anyhow::anyhow!("Formatting graph enumeration: {e}"))?; + let results = fluree_json_to_sparql_results(&json)?; + + let SparqlResults::Solutions { solutions, .. } = results else { + bail!("Graph enumeration returned non-SELECT results"); }; + // The engine currently binds `?g` as a plain string literal, not an IRI + // term, and also exposes the default graph under the ledger alias (both + // registered engine gaps — audit §8). Accept both term kinds so this + // enumeration keeps working when the engine is fixed, and exclude the + // alias-named default graph: only real named graphs count. + Ok(solutions + .into_iter() + .filter_map(|sol| match sol.get("g") { + Some(RdfTerm::Iri(iri)) => Some(iri.clone()), + Some(RdfTerm::Literal { value, .. }) => Some(value.clone()), + _ => None, + }) + .filter(|name| name != TEST_LEDGER) + .collect()) +} + +// --------------------------------------------------------------------------- +// Query evaluation (inner, runs inside the subprocess) +// --------------------------------------------------------------------------- + +/// Inner async function that does the actual test work. +/// +/// Public for use by the `run-w3c-test` subprocess binary. +pub async fn run_eval_test( + test_id: &str, + query_url: &str, + data_url: Option<&str>, + result_url: &str, + graph_data: &[(String, String)], +) -> Result<()> { + // 1. Create in-memory Fluree + ledger, load default + named graph data + let fluree = FlureeBuilder::memory().build_memory(); + let ledger = setup_graph_store(&fluree, data_url, graph_data).await?; - // 4. Read + execute the SPARQL query + // 2. Read + execute the SPARQL query let sparql = read_file_to_string(query_url) .with_context(|| format!("Reading query file: {query_url}"))?; @@ -135,10 +348,10 @@ pub async fn run_eval_test( .await .with_context(|| format!("Executing SPARQL query for test {test_id}"))?; - // 5. Parse expected results + // 3. Parse expected results let expected = parse_expected_results(result_url)?; - // 6. Detect CONSTRUCT vs SELECT/ASK from the parsed query's select mode. + // 4. Detect CONSTRUCT vs SELECT/ASK from the parsed query's select mode. // Previous heuristic checked file extension (.ttl/.rdf), but many SPARQL // 1.0 SELECT tests use .ttl result files encoded in the DAWG Result Set // vocabulary — not CONSTRUCT graphs. See issue #44. @@ -162,7 +375,15 @@ pub async fn run_eval_test( .context("Converting Fluree results to SparqlResults")? }; - // 7. Compare + // CSV expected results are lossy (no term kinds / datatypes); project the + // actual results into the same value space before comparing. + let actual = if result_url.ends_with(".csv") { + project_to_csv_space(actual) + } else { + actual + }; + + // 5. Compare if !are_results_isomorphic(&expected, &actual) { let diff = format_results_diff(&expected, &actual); bail!( @@ -177,6 +398,120 @@ pub async fn run_eval_test( Ok(()) } +// --------------------------------------------------------------------------- +// Update evaluation (inner, runs inside the subprocess) +// --------------------------------------------------------------------------- + +/// Inner async function for `mf:UpdateEvaluationTest`. +/// +/// Public for use by the `run-w3c-test` subprocess binary. +pub async fn run_update_eval_test( + test_id: &str, + request_url: &str, + data_url: Option<&str>, + graph_data: &[(String, String)], + result_data_url: Option<&str>, + result_graph_data: &[(String, String)], +) -> Result<()> { + // 1. Initial graph store state + let fluree = FlureeBuilder::memory().build_memory(); + setup_graph_store(&fluree, data_url, graph_data).await?; + + // 2. Apply the update request + let sparql = read_file_to_string(request_url) + .with_context(|| format!("Reading update request: {request_url}"))?; + match fluree + .graph(TEST_LEDGER) + .transact() + .sparql_update(&sparql) + .commit() + .await + { + Ok(_) => {} + // An update whose WHERE clause matches nothing (or whose data is + // already present/absent) is a valid no-op per SPARQL Update. + Err(e) if is_empty_transaction(&e) => {} + Err(e) => { + return Err(e).with_context(|| format!("Applying update for test {test_id}")); + } + } + + let ledger = fetch_state(&fluree).await?; + + // 3. Compare default graph state + let expected_default = match result_data_url { + Some(url) => parse_expected_graph(url)?, + None => Vec::new(), + }; + let actual_default = read_graph_triples(&fluree, &ledger, None).await?; + compare_graph( + test_id, + "default graph", + expected_default, + actual_default, + result_data_url, + )?; + + // 4. Compare each expected named graph + for (graph_name, expected_url) in result_graph_data { + let expected = parse_expected_graph(expected_url)?; + let actual = read_graph_triples(&fluree, &ledger, Some(graph_name)).await?; + compare_graph( + test_id, + &format!("named graph <{graph_name}>"), + expected, + actual, + Some(expected_url), + )?; + } + + // 5. No unexpected non-empty named graphs. + // + // Note: this compares *non-empty* graphs only. Fluree does not track + // empty named graphs as first-class graph-store entries, so tests that + // distinguish CLEAR (graph remains, empty) from DROP (graph removed) + // cannot observe that difference here. + let expected_names: std::collections::HashSet<&str> = result_graph_data + .iter() + .map(|(name, _)| name.as_str()) + .collect(); + let actual_names = list_named_graphs(&fluree, &ledger).await?; + for name in &actual_names { + if !expected_names.contains(name.as_str()) { + bail!( + "Unexpected non-empty named graph after update.\n\ + Test: {test_id}\n\ + Graph: <{name}>\n\ + Expected named graphs: {expected_names:?}" + ); + } + } + + Ok(()) +} + +/// Compare one graph's expected vs actual triples isomorphically. +fn compare_graph( + test_id: &str, + which: &str, + expected: Vec, + actual: Vec, + expected_url: Option<&str>, +) -> Result<()> { + let expected = SparqlResults::Graph(expected); + let actual = SparqlResults::Graph(actual); + if !are_results_isomorphic(&expected, &actual) { + let diff = format_results_diff(&expected, &actual); + bail!( + "Graph state after update not isomorphic ({which}).\n\ + Test: {test_id}\n\ + Expected: {expected_url:?}\n\n\ + {diff}" + ); + } + Ok(()) +} + /// Check if an error is a Fluree "empty transaction" rejection. /// /// Turtle files with only `@prefix` declarations and no triples produce zero diff --git a/testsuite-sparql/src/result_format.rs b/testsuite-sparql/src/result_format.rs index 3fccf89d0b..d4df91c273 100644 --- a/testsuite-sparql/src/result_format.rs +++ b/testsuite-sparql/src/result_format.rs @@ -81,11 +81,301 @@ pub fn parse_expected_results(url: &str) -> Result { } else if url.ends_with(".rdf") { parse_rdf_dawg_result_set(&content) .with_context(|| format!("Parsing .rdf DAWG result set: {url}")) + } else if url.ends_with(".csv") { + parse_csv_results(&content).with_context(|| format!("Parsing .csv: {url}")) + } else if url.ends_with(".tsv") { + parse_tsv_results(&content).with_context(|| format!("Parsing .tsv: {url}")) } else { bail!("Unknown result file format: {url}") } } +// --------------------------------------------------------------------------- +// SPARQL 1.1 CSV/TSV result formats +// --------------------------------------------------------------------------- + +/// Parse SPARQL Results CSV (RFC 4180 dialect per the W3C spec). +/// +/// CSV is lossy: every value is a plain string with no term-kind or datatype +/// information. By W3C convention, values beginning with `_:` are read back +/// as blank nodes (preserving isomorphism checks); everything else becomes a +/// plain literal. Compare against actual results projected through +/// [`project_to_csv_space`]. +pub fn parse_csv_results(content: &str) -> Result { + let mut rows = parse_csv_rows(content); + if rows.is_empty() { + bail!("CSV results missing header row"); + } + let variables: Vec = rows.remove(0); + let mut solutions = Vec::with_capacity(rows.len()); + for row in rows { + let mut solution = HashMap::new(); + for (var, value) in variables.iter().zip(row) { + // An empty CSV field encodes an unbound variable (CSV cannot + // distinguish unbound from empty string; W3C convention reads + // empty as unbound, and project_to_csv_space drops empty-string + // bindings on the actual side to match). + if value.is_empty() { + continue; + } + solution.insert(var.clone(), csv_value_to_term(&value)); + } + solutions.push(solution); + } + Ok(SparqlResults::Solutions { + variables, + solutions, + }) +} + +fn csv_value_to_term(value: &str) -> RdfTerm { + if let Some(label) = value.strip_prefix("_:") { + RdfTerm::BlankNode(label.to_string()) + } else { + RdfTerm::Literal { + value: value.to_string(), + datatype: None, + language: None, + } + } +} + +/// Minimal RFC 4180 CSV parser (quoted fields, `""` escapes, CRLF/LF rows). +fn parse_csv_rows(content: &str) -> Vec> { + let mut rows = Vec::new(); + let mut row = Vec::new(); + let mut field = String::new(); + let mut chars = content.chars().peekable(); + let mut in_quotes = false; + let mut saw_any = false; + + while let Some(c) = chars.next() { + saw_any = true; + if in_quotes { + match c { + '"' => { + if chars.peek() == Some(&'"') { + chars.next(); + field.push('"'); + } else { + in_quotes = false; + } + } + _ => field.push(c), + } + } else { + match c { + '"' => in_quotes = true, + ',' => row.push(std::mem::take(&mut field)), + '\r' => { /* swallow; LF terminates the row */ } + '\n' => { + row.push(std::mem::take(&mut field)); + rows.push(std::mem::take(&mut row)); + } + _ => field.push(c), + } + } + } + if saw_any && (!field.is_empty() || !row.is_empty()) { + row.push(field); + rows.push(row); + } + rows +} + +/// Project a solution set into CSV value space for comparison against +/// [`parse_csv_results`] output: IRIs and literals collapse to their lexical +/// form; blank nodes stay blank nodes (so isomorphism still applies). +pub fn project_to_csv_space(results: SparqlResults) -> SparqlResults { + match results { + SparqlResults::Solutions { + variables, + solutions, + } => SparqlResults::Solutions { + variables, + solutions: solutions + .into_iter() + .map(|sol| { + sol.into_iter() + .filter_map(|(var, term)| { + let projected = match term { + RdfTerm::Iri(iri) => RdfTerm::Literal { + value: iri, + datatype: None, + language: None, + }, + RdfTerm::BlankNode(b) => RdfTerm::BlankNode(b), + RdfTerm::Literal { value, .. } => RdfTerm::Literal { + value, + datatype: None, + language: None, + }, + }; + // Empty lexical forms serialize to an empty CSV + // field, which parse_csv_results reads as unbound + // — drop them so both sides agree. + match &projected { + RdfTerm::Literal { value, .. } if value.is_empty() => None, + _ => Some((var, projected)), + } + }) + .collect() + }) + .collect(), + }, + other => other, + } +} + +/// Parse SPARQL Results TSV: header row of `?var` names, then one row per +/// solution with terms in SPARQL/Turtle syntax (``, `"lit"@lang`, +/// `"lit"^^

`, `_:b`, bare numeric literals). Empty field = unbound. +pub fn parse_tsv_results(content: &str) -> Result { + let mut lines = content.lines(); + let header = lines.next().context("TSV results missing header row")?; + let variables: Vec = header + .split('\t') + .map(|v| v.trim().trim_start_matches('?').to_string()) + .filter(|v| !v.is_empty()) + .collect(); + + let mut solutions = Vec::new(); + for line in lines { + if line.is_empty() { + continue; + } + let mut solution = HashMap::new(); + for (var, raw) in variables.iter().zip(line.split('\t')) { + let raw = raw.trim(); + if raw.is_empty() { + continue; // unbound + } + solution.insert( + var.clone(), + parse_tsv_term(raw).with_context(|| format!("Parsing TSV term: {raw}"))?, + ); + } + solutions.push(solution); + } + Ok(SparqlResults::Solutions { + variables, + solutions, + }) +} + +fn parse_tsv_term(raw: &str) -> Result { + const XSD: &str = "http://www.w3.org/2001/XMLSchema#"; + if let Some(iri) = raw.strip_prefix('<').and_then(|r| r.strip_suffix('>')) { + return Ok(RdfTerm::Iri(iri.to_string())); + } + if let Some(label) = raw.strip_prefix("_:") { + return Ok(RdfTerm::BlankNode(label.to_string())); + } + if raw.starts_with('"') { + // "lexical" | "lexical"@lang | "lexical"^^ + let closing = find_closing_quote(raw).context("Unterminated TSV literal")?; + let lexical = unescape_turtle_string(&raw[1..closing]); + let rest = &raw[closing + 1..]; + if let Some(lang) = rest.strip_prefix('@') { + return Ok(RdfTerm::Literal { + value: lexical, + datatype: None, + language: Some(lang.to_string()), + }); + } + if let Some(dt) = rest.strip_prefix("^^<").and_then(|r| r.strip_suffix('>')) { + return Ok(RdfTerm::Literal { + value: lexical, + datatype: Some(dt.to_string()), + language: None, + }); + } + if rest.is_empty() { + return Ok(RdfTerm::Literal { + value: lexical, + datatype: None, + language: None, + }); + } + bail!("Malformed TSV literal suffix: {rest}"); + } + match raw { + "true" | "false" => { + return Ok(RdfTerm::Literal { + value: raw.to_string(), + datatype: Some(format!("{XSD}boolean")), + language: None, + }) + } + _ => {} + } + // Bare numeric literals in canonical form (TSV shorthand): + // an optional single leading sign followed by digits only. + let unsigned = raw.strip_prefix(['-', '+']).unwrap_or(raw); + if !unsigned.is_empty() && unsigned.chars().all(|c| c.is_ascii_digit()) { + return Ok(RdfTerm::Literal { + value: raw.to_string(), + datatype: Some(format!("{XSD}integer")), + language: None, + }); + } + if raw.contains('e') || raw.contains('E') { + if raw.parse::().is_ok() { + return Ok(RdfTerm::Literal { + value: raw.to_string(), + datatype: Some(format!("{XSD}double")), + language: None, + }); + } + } else if raw.contains('.') && raw.parse::().is_ok() { + return Ok(RdfTerm::Literal { + value: raw.to_string(), + datatype: Some(format!("{XSD}decimal")), + language: None, + }); + } + bail!("Unrecognized TSV term syntax: {raw}") +} + +/// Index of the closing `"` of a Turtle-quoted string starting at byte 0. +fn find_closing_quote(raw: &str) -> Option { + let bytes = raw.as_bytes(); + let mut i = 1; + while i < bytes.len() { + match bytes[i] { + b'\\' => i += 2, + b'"' => return Some(i), + _ => i += 1, + } + } + None +} + +/// Unescape the common Turtle string escapes used in TSV results. +fn unescape_turtle_string(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c != '\\' { + out.push(c); + continue; + } + match chars.next() { + Some('t') => out.push('\t'), + Some('n') => out.push('\n'), + Some('r') => out.push('\r'), + Some('"') => out.push('"'), + Some('\\') => out.push('\\'), + Some(other) => { + out.push('\\'); + out.push(other); + } + None => out.push('\\'), + } + } + out +} + // --------------------------------------------------------------------------- // SPARQL Results XML (.srx) parser // --------------------------------------------------------------------------- @@ -350,6 +640,33 @@ pub fn fluree_json_to_sparql_results(json: &serde_json::Value) -> Result Result> { + let content = + read_file_to_string(url).with_context(|| format!("Reading expected graph file: {url}"))?; + let with_base = format!("@base <{url}> .\n{content}"); + let mut sink = GraphCollectorSink::new(); + parse_turtle(&with_base, &mut sink) + .with_context(|| format!("Parsing expected graph: {url}"))?; + let graph = sink.finish(); + Ok(graph + .iter() + .map(|t| Triple { + subject: ir_term_to_rdf_term(&t.s), + predicate: ir_term_to_rdf_term(&t.p), + object: ir_term_to_rdf_term(&t.o), + }) + .collect()) +} + // --------------------------------------------------------------------------- // Turtle result parsing (.ttl) — auto-detects DAWG Result Set vs CONSTRUCT // --------------------------------------------------------------------------- diff --git a/testsuite-sparql/src/sparql_handlers.rs b/testsuite-sparql/src/sparql_handlers.rs index 2c5a31fc0c..59826a43e1 100644 --- a/testsuite-sparql/src/sparql_handlers.rs +++ b/testsuite-sparql/src/sparql_handlers.rs @@ -5,6 +5,7 @@ use anyhow::{bail, ensure, Context, Result}; use crate::evaluator::TestEvaluator; use crate::files::read_file_to_string; use crate::manifest::Test; +use crate::query_handler; use crate::query_handler::evaluate_query_evaluation_test; use crate::subprocess::{run_in_subprocess, TestDescriptor}; use crate::vocab::mf; @@ -20,7 +21,8 @@ pub fn register_sparql_tests(evaluator: &mut TestEvaluator) { evaluator.register(mf::NEGATIVE_SYNTAX_TEST, evaluate_negative_syntax_test); evaluator.register(mf::NEGATIVE_SYNTAX_TEST_11, evaluate_negative_syntax_test); - // Update syntax tests — SPARQL UPDATE uses the same parser + // Update syntax tests — SPARQL UPDATE uses the same parser. + // The un-versioned types are used by the SPARQL 1.2 manifests. evaluator.register( mf::POSITIVE_UPDATE_SYNTAX_TEST_11, evaluate_positive_syntax_test, @@ -29,15 +31,29 @@ pub fn register_sparql_tests(evaluator: &mut TestEvaluator) { mf::NEGATIVE_UPDATE_SYNTAX_TEST_11, evaluate_negative_syntax_test, ); + evaluator.register( + mf::POSITIVE_UPDATE_SYNTAX_TEST, + evaluate_positive_syntax_test, + ); + evaluator.register( + mf::NEGATIVE_UPDATE_SYNTAX_TEST, + evaluate_negative_syntax_test, + ); // Query evaluation tests evaluator.register(mf::QUERY_EVALUATION_TEST, evaluate_query_evaluation_test); - // Update evaluation tests — not yet implemented - evaluator.register(mf::UPDATE_EVALUATION_TEST, evaluate_update_evaluation_test); + // Update evaluation tests: load initial graph-store state, apply the + // update via the public SPARQL UPDATE surface, compare resulting state. + evaluator.register( + mf::UPDATE_EVALUATION_TEST, + query_handler::evaluate_update_evaluation_test, + ); - // CSV result format tests — not yet implemented - evaluator.register(mf::CSV_RESULT_FORMAT_TEST, evaluate_csv_result_format_test); + // CSV result format tests: same eval flow as QueryEvaluationTest; the + // .csv extension of the expected-result file selects lossy CSV-space + // comparison in the eval handler. + evaluator.register(mf::CSV_RESULT_FORMAT_TEST, evaluate_query_evaluation_test); // Infrastructure tests — not applicable to a database engine evaluator.register(mf::PROTOCOL_TEST, evaluate_not_applicable_test); @@ -96,31 +112,6 @@ fn evaluate_negative_syntax_test(test: &Test) -> Result<()> { Ok(()) } -/// Handler for UpdateEvaluationTest. -/// -/// Fluree does not yet support SPARQL UPDATE execution in the test harness. -/// Fails with a descriptive message. -fn evaluate_update_evaluation_test(test: &Test) -> Result<()> { - bail!( - "SPARQL UPDATE evaluation not yet implemented.\n\ - Test: {}\n\ - This test type (mf:UpdateEvaluationTest) requires executing SPARQL UPDATE \ - operations and comparing the resulting graph state.", - test.id, - ) -} - -/// Handler for CSVResultFormatTest. -/// -/// CSV/TSV result format comparison is not yet implemented. -fn evaluate_csv_result_format_test(test: &Test) -> Result<()> { - bail!( - "CSV/TSV result format comparison not yet implemented.\n\ - Test: {}", - test.id, - ) -} - /// Handler for test types not applicable to a database engine. /// /// Protocol tests, service description tests, and graph store protocol tests diff --git a/testsuite-sparql/src/subprocess.rs b/testsuite-sparql/src/subprocess.rs index fae4c77790..2e1ce00199 100644 --- a/testsuite-sparql/src/subprocess.rs +++ b/testsuite-sparql/src/subprocess.rs @@ -23,6 +23,16 @@ pub enum TestDescriptor { result_url: String, graph_data: Vec<(String, String)>, }, + /// Update evaluation test: load initial graph store state, apply the + /// SPARQL UPDATE request, compare the resulting graph store state. + UpdateEval { + test_id: String, + request_url: String, + data_url: Option, + graph_data: Vec<(String, String)>, + result_data_url: Option, + result_graph_data: Vec<(String, String)>, + }, } /// Result communicated back from the subprocess via stdout JSON. @@ -78,7 +88,8 @@ pub fn run_in_subprocess( let _ = child.wait(); // reap the zombie let test_id = match descriptor { TestDescriptor::Syntax { test_id, .. } - | TestDescriptor::Eval { test_id, .. } => test_id, + | TestDescriptor::Eval { test_id, .. } + | TestDescriptor::UpdateEval { test_id, .. } => test_id, }; bail!("Test timed out (>{timeout:?}) — subprocess killed.\nTest: {test_id}"); } diff --git a/testsuite-sparql/src/vocab.rs b/testsuite-sparql/src/vocab.rs index be475bf04c..d6f8eaa3c5 100644 --- a/testsuite-sparql/src/vocab.rs +++ b/testsuite-sparql/src/vocab.rs @@ -28,6 +28,11 @@ pub mod mf { "http://www.w3.org/2001/sw/DataAccess/tests/test-manifest#PositiveUpdateSyntaxTest11"; pub const NEGATIVE_UPDATE_SYNTAX_TEST_11: &str = "http://www.w3.org/2001/sw/DataAccess/tests/test-manifest#NegativeUpdateSyntaxTest11"; + // SPARQL 1.2 manifests use the un-versioned update syntax test types. + pub const POSITIVE_UPDATE_SYNTAX_TEST: &str = + "http://www.w3.org/2001/sw/DataAccess/tests/test-manifest#PositiveUpdateSyntaxTest"; + pub const NEGATIVE_UPDATE_SYNTAX_TEST: &str = + "http://www.w3.org/2001/sw/DataAccess/tests/test-manifest#NegativeUpdateSyntaxTest"; pub const UPDATE_EVALUATION_TEST: &str = "http://www.w3.org/2001/sw/DataAccess/tests/test-manifest#UpdateEvaluationTest"; pub const CSV_RESULT_FORMAT_TEST: &str = diff --git a/testsuite-sparql/tests/registers/mod.rs b/testsuite-sparql/tests/registers/mod.rs new file mode 100644 index 0000000000..5a5abefd41 --- /dev/null +++ b/testsuite-sparql/tests/registers/mod.rs @@ -0,0 +1,792 @@ +//! Per-suite skip registers for the W3C SPARQL test suites. +//! +//! Each entry is a test that currently FAILS for a known reason. The suites +//! themselves always run in CI; `check_testsuite` enforces this register in +//! BOTH directions: +//! +//! - a test that fails and is not listed here fails the suite (regression); +//! - a test that passes but IS listed here fails the suite (stale entry — +//! remove it in the same change that fixes the feature). +//! +//! Grouping comments name the root cause. The full failure taxonomy, root +//! causes, and burn-down plan live in +//! `docs/audit/2026-07-sparql-testsuite-audit.md`; policy for adding entries +//! is in `docs/contributing/sparql-compliance.md` ("Managing the Skip List"). +//! Baseline: rdf-tests submodule @ efccbc6b8, 2026-07-06. + +pub const SPARQL11_SYNTAX_QUERY: &[&str] = &[ + // parser rejects valid input (4) + // (test_21/test_23/test_64 were greened by main's sub-SELECT set-operand + // fix, PR #1436) + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-query/manifest#test_35a", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-query/manifest#test_36a", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-query/manifest#test_63", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-query/manifest#test_pp_coll", + // parser accepts invalid input (missing validation) (7) + // (test_65 formerly failed to parse for the wrong reason; PR #1436 made + // it parse, so it now needs the SELECT-scope validation pass — burn-down + // PR-2 V4/V5 territory) + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-query/manifest#test_43", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-query/manifest#test_44", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-query/manifest#test_45", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-query/manifest#test_60", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-query/manifest#test_61a", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-query/manifest#test_62a", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-query/manifest#test_65", +]; + +// Dominated by the missing UPDATE graph-management grammar +// (LOAD/CLEAR/CREATE/DROP/COPY/MOVE/ADD, SILENT variants) — audit §4.2.1. +pub const SPARQL11_SYNTAX_UPDATE_1: &[&str] = &[ + // parser rejects valid input (27) + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_1", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_10", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_11", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_12", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_13", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_14", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_15", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_16", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_17", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_18", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_19", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_2", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_20", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_21", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_22", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_3", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_36", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_37", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_38", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_39", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_4", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_40", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_5", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_6", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_7", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_8", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_9", + // parser accepts invalid input (missing validation) (4) + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_50", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_51", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_52", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_54", +]; + +pub const SPARQL10_SYNTAX: &[&str] = &[ + // parser accepts invalid input (missing validation) (24) + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql3/manifest#blabel-cross-graph-bad", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql3/manifest#blabel-cross-optional-bad", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql3/manifest#blabel-cross-union-bad", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql3/manifest#filter-missing-parens", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql3/manifest#syn-bad-02", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql3/manifest#syn-bad-03", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql3/manifest#syn-bad-05", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql3/manifest#syn-bad-06", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql3/manifest#syn-bad-07", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql3/manifest#syn-bad-08", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql3/manifest#syn-bad-09", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql3/manifest#syn-bad-10", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql3/manifest#syn-bad-11", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql3/manifest#syn-bad-12", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql3/manifest#syn-bad-13", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql3/manifest#syn-bad-14", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql4/manifest#syn-bad-34", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql4/manifest#syn-bad-35", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql4/manifest#syn-bad-36", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql4/manifest#syn-bad-37", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql4/manifest#syn-bad-38", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql4/manifest#syn-bad-GRAPH-breaks-BGP", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql4/manifest#syn-bad-OPT-breaks-BGP", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql4/manifest#syn-bad-UNION-breaks-BGP", + // parser rejects valid input (17) + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql1/manifest#syntax-forms-01", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql1/manifest#syntax-forms-02", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql1/manifest#syntax-lists-01", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql1/manifest#syntax-lists-02", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql1/manifest#syntax-lists-03", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql1/manifest#syntax-lists-04", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql1/manifest#syntax-lists-05", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql1/manifest#syntax-order-07", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql1/manifest#syntax-qname-05", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql2/manifest#syntax-function-01", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql2/manifest#syntax-function-02", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql2/manifest#syntax-function-03", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql2/manifest#syntax-lists-01", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql2/manifest#syntax-lists-02", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql2/manifest#syntax-lists-03", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql2/manifest#syntax-lists-04", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/syntax-sparql2/manifest#syntax-lists-05", +]; + +pub const SPARQL11_AGGREGATES: &[&str] = &[ + // parser accepts invalid input (missing validation) (5) + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/aggregates/manifest#agg08", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/aggregates/manifest#agg09", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/aggregates/manifest#agg10", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/aggregates/manifest#agg11", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/aggregates/manifest#agg12", + // result mismatch (3) + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/aggregates/manifest#agg-empty-group-count-graph", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/aggregates/manifest#agg-err-01", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/aggregates/manifest#agg02", + // query execution error (1) + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/aggregates/manifest#agg-count-rows-distinct", +]; + +pub const SPARQL11_BINDINGS: &[&str] = &[ + // result mismatch (1) + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/bindings/manifest#graph", +]; + +pub const SPARQL11_CONSTRUCT: &[&str] = &[ + // dataset: FROM/FROM NAMED unsupported on single-ledger GraphDb (1) + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/construct/manifest#constructwhere04", + // query execution error (1) + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/construct/manifest#constructlist", +]; + +pub const SPARQL11_EXISTS: &[&str] = &[ + // result mismatch (2) + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/exists/manifest#exists-graph-variable", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/exists/manifest#exists03", +]; + +pub const SPARQL11_FUNCTIONS: &[&str] = &[ + // result mismatch (6) + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/functions/manifest#bnode01", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/functions/manifest#concat02", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/functions/manifest#in01", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/functions/manifest#iri01", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/functions/manifest#notin01", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/functions/manifest#strlang03-rdf11", +]; + +pub const SPARQL11_GROUPING: &[&str] = &[ + // parser accepts invalid input (missing validation) (2) + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/grouping/manifest#group06", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/grouping/manifest#group07", +]; + +pub const SPARQL11_PROJECT_EXPRESSION: &[&str] = &[ + // result mismatch (1) + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/project-expression/manifest#projexp05", +]; + +pub const SPARQL11_SUBQUERY: &[&str] = &[ + // result mismatch (2) + // (subquery12 was greened by main's sub-SELECT set-operand fix, PR #1436 + // — its "mismatch" was a bare-{SELECT} misparse executing through error + // recovery) + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/subquery/manifest#subquery02", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/subquery/manifest#subquery04", +]; + +// Largest clusters: XSD type-promotion comparisons, open-world equality, +// expression builtins, FROM/FROM NAMED dataset construction, GRAPH ?g +// binding typed as literal — audit §4.2. +pub const SPARQL10_QUERY_EVAL: &[&str] = &[ + // result mismatch (113) + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/algebra/manifest#filter-nested-2", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/algebra/manifest#join-combo-2", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/algebra/manifest#join-scope-1", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/algebra/manifest#nested-opt-1", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/algebra/manifest#nested-opt-2", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/basic/manifest#base-prefix-2", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/basic/manifest#base-prefix-5", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/basic/manifest#list-1", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/basic/manifest#list-2", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/basic/manifest#list-3", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/basic/manifest#list-4", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/basic/manifest#quotes-3", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/basic/manifest#quotes-4", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/boolean-effective-value/manifest#dawg-bev-1", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/boolean-effective-value/manifest#dawg-bev-2", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/boolean-effective-value/manifest#dawg-bev-3", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/boolean-effective-value/manifest#dawg-bev-4", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/boolean-effective-value/manifest#dawg-bev-5", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/boolean-effective-value/manifest#dawg-bev-6", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/boolean-effective-value/manifest#dawg-boolean-literal", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/cast/manifest#cast-bool", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/cast/manifest#cast-dT", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/cast/manifest#cast-dbl", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/cast/manifest#cast-dec", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/cast/manifest#cast-flt", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/cast/manifest#cast-int", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/cast/manifest#cast-str", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/construct/manifest#construct-3", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/construct/manifest#construct-4", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/distinct/manifest#distinct-1", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/distinct/manifest#distinct-9", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/expr-builtin/manifest#dawg-datatype-2", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/expr-builtin/manifest#dawg-lang-1", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/expr-builtin/manifest#dawg-lang-2", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/expr-builtin/manifest#dawg-lang-3", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/expr-builtin/manifest#dawg-langMatches-1", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/expr-builtin/manifest#dawg-langMatches-2", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/expr-builtin/manifest#dawg-langMatches-3", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/expr-builtin/manifest#dawg-langMatches-4", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/expr-builtin/manifest#dawg-langMatches-basic", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/expr-builtin/manifest#dawg-str-1", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/expr-builtin/manifest#dawg-str-2", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/expr-builtin/manifest#sameTerm-eq", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/expr-builtin/manifest#sameTerm-not-eq", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/expr-builtin/manifest#sameTerm-simple", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/expr-equals/manifest#eq-2-1", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/expr-equals/manifest#eq-2-2", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/expr-equals/manifest#eq-4", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/expr-equals/manifest#eq-dateTime", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/expr-equals/manifest#eq-graph-1", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/expr-equals/manifest#eq-graph-2", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/expr-equals/manifest#eq-graph-4", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/expr-ops/manifest#add-literals", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/expr-ops/manifest#add-numbers-cast", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/expr-ops/manifest#divide-numbers-cast", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/expr-ops/manifest#multiply-numbers-cast", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/expr-ops/manifest#subtract-numbers-cast", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/expr-ops/manifest#unminus-2", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/expr-ops/manifest#unplus-2", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/graph/manifest#dawg-graph-03", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/graph/manifest#dawg-graph-04", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/graph/manifest#dawg-graph-06", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/graph/manifest#dawg-graph-07", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/graph/manifest#dawg-graph-08", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/graph/manifest#dawg-graph-09", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/graph/manifest#dawg-graph-10b", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/graph/manifest#dawg-graph-11", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/graph/manifest#graph-empty", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/graph/manifest#graph-exist", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/graph/manifest#graph-optional", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/graph/manifest#graph-variable-join", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/open-world/manifest#date-1", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/open-world/manifest#open-eq-01", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/open-world/manifest#open-eq-02", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/open-world/manifest#open-eq-04", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/open-world/manifest#open-eq-05", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/open-world/manifest#open-eq-06", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/open-world/manifest#open-eq-07", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/open-world/manifest#open-eq-08", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/open-world/manifest#open-eq-10", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/open-world/manifest#open-eq-11", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/open-world/manifest#open-eq-12", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/optional-filter/manifest#dawg-optional-filter-005-not-simplified", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/optional/manifest#dawg-optional-complex-2", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/optional/manifest#dawg-optional-complex-3", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/optional/manifest#dawg-optional-complex-4", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/regex/manifest#regex-no-metacharacters", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/regex/manifest#regex-no-metacharacters-case-insensitive", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/sort/manifest#dawg-sort-3", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/sort/manifest#dawg-sort-6", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/sort/manifest#dawg-sort-8", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/type-promotion/manifest#type-promotion-01", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/type-promotion/manifest#type-promotion-02", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/type-promotion/manifest#type-promotion-03", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/type-promotion/manifest#type-promotion-04", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/type-promotion/manifest#type-promotion-05", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/type-promotion/manifest#type-promotion-06", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/type-promotion/manifest#type-promotion-07", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/type-promotion/manifest#type-promotion-08", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/type-promotion/manifest#type-promotion-09", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/type-promotion/manifest#type-promotion-10", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/type-promotion/manifest#type-promotion-11", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/type-promotion/manifest#type-promotion-12", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/type-promotion/manifest#type-promotion-13", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/type-promotion/manifest#type-promotion-14", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/type-promotion/manifest#type-promotion-15", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/type-promotion/manifest#type-promotion-16", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/type-promotion/manifest#type-promotion-17", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/type-promotion/manifest#type-promotion-18", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/type-promotion/manifest#type-promotion-19", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/type-promotion/manifest#type-promotion-20", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/type-promotion/manifest#type-promotion-21", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/type-promotion/manifest#type-promotion-22", + // dataset: FROM/FROM NAMED unsupported on single-ledger GraphDb (12) + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/dataset/manifest#dawg-dataset-01", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/dataset/manifest#dawg-dataset-02", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/dataset/manifest#dawg-dataset-03", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/dataset/manifest#dawg-dataset-04", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/dataset/manifest#dawg-dataset-05", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/dataset/manifest#dawg-dataset-06", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/dataset/manifest#dawg-dataset-07", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/dataset/manifest#dawg-dataset-08", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/dataset/manifest#dawg-dataset-09b", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/dataset/manifest#dawg-dataset-10b", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/dataset/manifest#dawg-dataset-11", + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/dataset/manifest#dawg-dataset-12b", + // query execution error (1) + "http://www.w3.org/2001/sw/DataAccess/tests/data-r2/basic/manifest#base-prefix-1", +]; + +// Clusters: graph-management ops missing from the UPDATE grammar, +// GRAPH blocks in DELETE WHERE, USING semantics, INSERT into +// not-yet-existing named graph — audit §4.2.1. +pub const SPARQL11_UPDATE: &[&str] = &[ + // update grammar: graph-management op not parsed (41) + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/add/manifest#add01", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/add/manifest#add02", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/add/manifest#add03", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/add/manifest#add04", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/add/manifest#add05", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/add/manifest#add06", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/add/manifest#add07", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/add/manifest#add08", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/clear/manifest#dawg-clear-all-01", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/clear/manifest#dawg-clear-default-01", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/clear/manifest#dawg-clear-graph-01", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/clear/manifest#dawg-clear-named-01", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/copy/manifest#copy01", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/copy/manifest#copy02", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/copy/manifest#copy03", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/copy/manifest#copy04", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/copy/manifest#copy06", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/copy/manifest#copy07", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/drop/manifest#dawg-drop-all-01", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/drop/manifest#dawg-drop-default-01", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/drop/manifest#dawg-drop-graph-01", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/drop/manifest#dawg-drop-named-01", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/move/manifest#move01", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/move/manifest#move02", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/move/manifest#move03", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/move/manifest#move04", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/move/manifest#move06", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/move/manifest#move07", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/update-silent/manifest#add-silent", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/update-silent/manifest#add-to-default-silent", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/update-silent/manifest#clear-default-silent", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/update-silent/manifest#clear-silent", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/update-silent/manifest#copy-silent", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/update-silent/manifest#copy-to-default-silent", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/update-silent/manifest#create-silent", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/update-silent/manifest#drop-default-silent", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/update-silent/manifest#drop-silent", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/update-silent/manifest#load-into-silent", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/update-silent/manifest#load-silent", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/update-silent/manifest#move-silent", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/update-silent/manifest#move-to-default-silent", + // parser rejects valid input (27) + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_1", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_10", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_11", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_12", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_13", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_14", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_15", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_16", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_17", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_18", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_19", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_2", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_20", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_21", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_22", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_3", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_36", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_37", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_38", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_39", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_4", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_40", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_5", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_6", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_7", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_8", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_9", + // parser accepts invalid input (missing validation) (12) + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/delete-insert/manifest#dawg-delete-insert-03", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/delete-insert/manifest#dawg-delete-insert-03b", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/delete-insert/manifest#dawg-delete-insert-05", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/delete-insert/manifest#dawg-delete-insert-06", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/delete-insert/manifest#dawg-delete-insert-07", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/delete-insert/manifest#dawg-delete-insert-07b", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/delete-insert/manifest#dawg-delete-insert-08", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/delete-insert/manifest#dawg-delete-insert-09", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_50", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_51", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_52", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/syntax-update-1/manifest#test_54", + // update eval: resulting graph-store state differs (7) + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/basic-update/manifest#insert-05a", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/basic-update/manifest#insert-data-same-bnode", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/basic-update/manifest#insert-where-same-bnode", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/basic-update/manifest#insert-where-same-bnode2", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/delete-insert/manifest#dawg-delete-insert-01c", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/delete/manifest#dawg-delete-using-02a", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/delete/manifest#dawg-delete-using-06a", + // engine: GRAPH blocks in DELETE WHERE unsupported (3) + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/delete-where/manifest#dawg-delete-where-02", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/delete-where/manifest#dawg-delete-where-04", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/delete-where/manifest#dawg-delete-where-06", +]; + +// All four block on one Turtle lexer bug: a blank-node label +// immediately followed by '.' (`_:o6.`) fails to lex — audit §4.2.5. +pub const SPARQL11_JSON_RES: &[&str] = &[ + // data load: Turtle/TriG parse error (4) + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/json-res/manifest#jsonres01", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/json-res/manifest#jsonres02", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/json-res/manifest#jsonres03", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/json-res/manifest#jsonres04", +]; + +// csv03 expects canonical xsd:double lexical form (1.0E6) in CSV output. +pub const SPARQL11_CSV_TSV: &[&str] = &[ + // result mismatch (1) + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/csv-tsv-res/manifest#csv03", +]; + +// Requires RDFS/OWL/RIF entailment regimes — a deliberate non-goal +// (audit §4.4). The 20 simple-entailment-answerable tests that pass +// today stay enforced; revisit this register if reasoning support lands. +pub const SPARQL11_ENTAILMENT: &[&str] = &[ + // result mismatch (44) + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#bind07", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#lang", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#paper-sparqldl-Q1", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#paper-sparqldl-Q1-rdfs", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#paper-sparqldl-Q2", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#paper-sparqldl-Q3", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#paper-sparqldl-Q4", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#parent10", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#parent3", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#parent4", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#parent5", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#parent6", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#parent7", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#parent8", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#parent9", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#plainLit", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#rdf01", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#rdfs01", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#rdfs02", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#rdfs03", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#rdfs04", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#rdfs05", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#rdfs06", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#rdfs07", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#rdfs09", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#rdfs10", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#rdfs11", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#rif01", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#rif03", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#rif04", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#rif06", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#simple1", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#simple2", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#simple3", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#simple4", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#simple5", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#simple6", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#simple7", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#simple8", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#sparqldl-02", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#sparqldl-10", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#sparqldl-11", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#sparqldl-12", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#sparqldl-13", + // query execution error (4) + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#sparqldl-06", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#sparqldl-07", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#sparqldl-08", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#sparqldl-09", + // data load: Turtle/TriG parse error (2) + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#owlds02", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/entailment/manifest#sparqldl-03", +]; + +pub const SPARQL12_GROUPING: &[&str] = &[ + // result mismatch (1) + "https://w3c.github.io/rdf-tests/sparql/sparql12/grouping/manifest#group01", +]; + +pub const SPARQL12_CODEPOINT_ESCAPES: &[&str] = &[ + // parser rejects valid input (5) + "https://w3c.github.io/rdf-tests/sparql/sparql12/codepoint-escapes/manifest#codepoint-esc-01", + "https://w3c.github.io/rdf-tests/sparql/sparql12/codepoint-escapes/manifest#codepoint-esc-02", + "https://w3c.github.io/rdf-tests/sparql/sparql12/codepoint-escapes/manifest#codepoint-esc-06", + "https://w3c.github.io/rdf-tests/sparql/sparql12/codepoint-escapes/manifest#codepoint-esc-07", + "https://w3c.github.io/rdf-tests/sparql/sparql12/codepoint-escapes/manifest#codepoint-esc-08", + // parser accepts invalid input (missing validation) (1) + "https://w3c.github.io/rdf-tests/sparql/sparql12/codepoint-escapes/manifest#codepoint-esc-bad-03", +]; + +pub const SPARQL12_SYNTAX_TRIPLE_TERMS_NEGATIVE: &[&str] = &[ + // parser accepts invalid input (missing validation) (1) + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-negative/manifest#syntax-update-anonreifier-02", +]; + +// RDF-star / SPARQL 1.2 triple-term syntax (<<( )>> and related) is not +// yet in the parser — audit §4.3 / Phase D. +pub const SPARQL12_SYNTAX_TRIPLE_TERMS_POSITIVE: &[&str] = &[ + // parser rejects valid input (87) + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#annotation-anonreifier-03", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#annotation-anonreifier-04", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#annotation-anonreifier-06", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#annotation-anonreifier-07", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#annotation-anonreifier-multiple-01", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#annotation-anonreifier-multiple-02", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#annotation-anonreifier-multiple-03", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#annotation-anonreifier-multiple-04", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#annotation-reifier-03", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#annotation-reifier-04", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#annotation-reifier-06", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#annotation-reifier-07", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#annotation-reifier-multiple-02", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#annotation-reifier-multiple-03", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#annotation-reifier-multiple-04", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#annotation-reifier-multiple-05", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#annotation-reifier-multiple-07", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#annotation-reifier-multiple-08", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#annotation-reifier-multiple-09", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#annotation-reifier-multiple-10", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#basic-anonreifier-02", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#basic-anonreifier-04", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#basic-anonreifier-07", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#basic-anonreifier-08", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#basic-anonreifier-09", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#basic-anonreifier-10", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#basic-anonreifier-11", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#basic-anonreifier-12", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#basic-anonreifier-13", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#basic-reifier-01", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#basic-reifier-02", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#basic-reifier-03", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#basic-reifier-04", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#basic-reifier-06", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#basic-reifier-07", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#basic-reifier-08", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#basic-reifier-09", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#basic-reifier-10", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#basic-reifier-11", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#basic-reifier-12", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#basic-reifier-13", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#basic-tripleterm-01", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#basic-tripleterm-02", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#basic-tripleterm-03", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#basic-tripleterm-04", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#basic-tripleterm-05", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#basic-tripleterm-06", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#basic-tripleterm-07", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#bnode-reifier-01", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#bnode-reifier-02", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#bnode-reifier-03", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#bnode-tripleterm-01", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#bnode-tripleterm-02", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#bnode-tripleterm-03", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#compound-all", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#compound-anonreifier", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#compound-reifier", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#compound-tripleterm", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#compound-tripleterm-subject", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#expr-tripleterm-01", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#expr-tripleterm-03", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#expr-tripleterm-04", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#expr-tripleterm-05", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#expr-tripleterm-06", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#inside-anonreifier-01", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#inside-anonreifier-02", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#inside-reifier-01", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#inside-reifier-02", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#inside-tripleterm-01", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#inside-tripleterm-02", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#nested-anonreifier-02", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#nested-reifier-01", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#nested-reifier-02", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#nested-tripleterm-01", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#nested-tripleterm-02", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#subject-tripleterm", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#update-anonreifier-04", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#update-anonreifier-05", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#update-reifier-01", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#update-reifier-03", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#update-reifier-04", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#update-reifier-05", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#update-reifier-07", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#update-tripleterm-01", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#update-tripleterm-03", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#update-tripleterm-04", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest#update-tripleterm-05", +]; + +// Blocked on Turtle-star data loading and engine triple-term support — +// audit §4.3 / Phase D. +pub const SPARQL12_EVAL_TRIPLE_TERMS: &[&str] = &[ + // data load: Turtle/TriG parse error (40) + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#basic-2", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#basic-3", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#basic-4", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#basic-5", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#basic-6", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#basic-7", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#basic-8", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#basic-9", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#construct-1", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#construct-2", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#construct-3", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#construct-4", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#construct-5", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#expr-1", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#graphs-1", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#graphs-2", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#op-1", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#op-2", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#order-1", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#order-2", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#pattern-1", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#pattern-10", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#pattern-11", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#pattern-2", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#pattern-3", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#pattern-3-nomatch", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#pattern-4", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#pattern-5", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#pattern-6", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#pattern-7", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#pattern-8", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#pattern-8-nomatch", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#pattern-9", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#results-reifiedtriples-1j", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#results-reifiedtriples-1x", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#results-tripleterms-1j", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#results-tripleterms-1x", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#update-1", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#update-2", + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#update-3", + // query execution error (1) + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest#expr-2", +]; + +pub const SPARQL12_EXPRESSION: &[&str] = &[ + // result mismatch (1) + "https://w3c.github.io/rdf-tests/sparql/sparql12/expression/manifest#not-not", +]; + +// Base-direction language literals (`@en--ltr`) — audit §4.3. +pub const SPARQL12_LANG_BASEDIR: &[&str] = &[ + // query execution error (8) + "https://w3c.github.io/rdf-tests/sparql/sparql12/lang-basedir/manifest#concat", + "https://w3c.github.io/rdf-tests/sparql/sparql12/lang-basedir/manifest#contains", + "https://w3c.github.io/rdf-tests/sparql/sparql12/lang-basedir/manifest#datatype", + "https://w3c.github.io/rdf-tests/sparql/sparql12/lang-basedir/manifest#haslang", + "https://w3c.github.io/rdf-tests/sparql/sparql12/lang-basedir/manifest#haslangdir", + "https://w3c.github.io/rdf-tests/sparql/sparql12/lang-basedir/manifest#langdir", + "https://w3c.github.io/rdf-tests/sparql/sparql12/lang-basedir/manifest#langdir-literal", + "https://w3c.github.io/rdf-tests/sparql/sparql12/lang-basedir/manifest#strlangdir", + // result mismatch (2) + "https://w3c.github.io/rdf-tests/sparql/sparql12/lang-basedir/manifest#lang", + "https://w3c.github.io/rdf-tests/sparql/sparql12/lang-basedir/manifest#strlang", +]; + +pub const SPARQL12_RDF11: &[&str] = &[ + // query execution error (2) + "https://w3c.github.io/rdf-tests/sparql/sparql12/rdf11/manifest#langstring-datatype", + "https://w3c.github.io/rdf-tests/sparql/sparql12/rdf11/manifest#plain-string-datatype", + // result mismatch (1) + "https://w3c.github.io/rdf-tests/sparql/sparql12/rdf11/manifest#plain-string-same", +]; + +pub const SPARQL12_SYNTAX: &[&str] = &[ + // parser accepts invalid input (missing validation) (2) + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax/manifest#duplicated-values-variable", + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax/manifest#nested-aggregate-functions", +]; + +// VERSION declaration support — audit §4.3. +pub const SPARQL12_VERSION: &[&str] = &[ + // parser rejects valid input (3) + "https://w3c.github.io/rdf-tests/sparql/sparql12/version/manifest#version-01", + "https://w3c.github.io/rdf-tests/sparql/sparql12/version/manifest#version-02", + "https://w3c.github.io/rdf-tests/sparql/sparql12/version/manifest#version-05", +]; + +// Path cardinality / duplicate semantics — sequence-path results differ in +// multiplicity (`p1/p2` path counting vs `*`/`+` distinct nodes) — plus the +// zero-variable `SELECT *` projection over a both-bound path (pp36). +// Audit §4.2.7 (hot-operator semantics; fix must preserve `*`/`+` fast path). +pub const SPARQL11_PROPERTY_PATH: &[&str] = &[ + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/property-path/manifest#pp16", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/property-path/manifest#pp34", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/property-path/manifest#pp35", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/property-path/manifest#pp36", +]; + +// SERVICE evaluation requires live external SPARQL endpoints, which a unit +// test environment cannot provide. Revisit with an in-process mock endpoint +// when federation execution lands (audit §4.4). +pub const SPARQL11_SERVICE: &[&str] = &[ + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/service/manifest#service1", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/service/manifest#service2", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/service/manifest#service3", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/service/manifest#service4a", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/service/manifest#service5", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/service/manifest#service6", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/service/manifest#service7", +]; + +// SPARQL Protocol conformance requires an HTTP client/server harness — +// not applicable to database-engine unit testing (audit §4.4). +pub const SPARQL11_PROTOCOL: &[&str] = &[ + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#bad_multiple_queries", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#bad_multiple_updates", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#bad_query_method", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#bad_query_missing_direct_type", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#bad_query_missing_form_type", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#bad_query_non_utf8", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#bad_query_syntax", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#bad_query_wrong_media_type", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#bad_update_dataset_conflict", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#bad_update_get", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#bad_update_missing_form_type", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#bad_update_non_utf8", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#bad_update_syntax", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#bad_update_wrong_media_type", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#query_content_type_ask", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#query_content_type_construct", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#query_content_type_describe", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#query_content_type_select", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#query_dataset_default_graphs_get", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#query_dataset_default_graphs_post", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#query_dataset_full", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#query_dataset_named_graphs_get", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#query_dataset_named_graphs_post", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#query_get", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#query_multiple_dataset", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#query_post_direct", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#query_post_form", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#update_base_uri", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#update_dataset_default_graph", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#update_dataset_default_graphs", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#update_dataset_full", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#update_dataset_named_graphs", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#update_post_direct", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#update_post_form", +]; + +// Requires a running SPARQL server to introspect (audit §4.4). +pub const SPARQL11_SERVICE_DESCRIPTION: &[&str] = &[ + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/service-description/manifest#conforms-to-schema", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/service-description/manifest#has-endpoint-triple", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/service-description/manifest#returns-rdf", +]; + +// Graph Store Protocol requires HTTP endpoint infrastructure (audit §4.4). +pub const SPARQL11_HTTP_RDF_UPDATE: &[&str] = &[ + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#delete__existing_graph", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#delete__nonexistent_graph", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#get_of_delete__existing_graph", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#get_of_post__after_noop", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#get_of_post__create__new_graph", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#get_of_post__existing_graph", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#get_of_post__multipart_formdata", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#get_of_put__default_graph", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#get_of_put__graph_already_in_store", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#get_of_put__initial_state", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#head_on_a_nonexisting_graph", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#head_on_an_existing_graph", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#post__create__new_graph", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#post__existing_graph", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#post__multipart_formdata", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#put__default_graph", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#put__graph_already_in_store", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#put__initial_state", + "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#put__mismatched_payload", +]; diff --git a/testsuite-sparql/tests/w3c_sparql.rs b/testsuite-sparql/tests/w3c_sparql.rs index 51ad98d851..3707f00add 100644 --- a/testsuite-sparql/tests/w3c_sparql.rs +++ b/testsuite-sparql/tests/w3c_sparql.rs @@ -1,6 +1,21 @@ +//! W3C SPARQL test suite registration. +//! +//! Every manifest in the rdf-tests submodule is registered here and runs +//! unconditionally (`cargo test` — no `#[ignore]`). A suite is green when +//! each of its tests either passes or appears in that suite's skip register +//! (`tests/registers/mod.rs`), which `check_testsuite` polices in both +//! directions: unexpected failures AND stale register entries fail the suite. +//! +//! See `docs/contributing/sparql-compliance.md` for the workflow and +//! `docs/audit/2026-07-sparql-testsuite-audit.md` for the failure taxonomy +//! and burn-down plan. + use anyhow::Result; use testsuite_sparql::check_testsuite; +mod registers; +use registers as reg; + // ============================================================================= // SPARQL 1.1 Syntax Tests (Query) // ============================================================================= @@ -12,7 +27,7 @@ use testsuite_sparql::check_testsuite; fn sparql11_syntax_query_tests() -> Result<()> { check_testsuite( "https://w3c.github.io/rdf-tests/sparql/sparql11/syntax-query/manifest.ttl", - &[], + reg::SPARQL11_SYNTAX_QUERY, ) } @@ -21,15 +36,11 @@ fn sparql11_syntax_query_tests() -> Result<()> { // ============================================================================= /// W3C SPARQL 1.1 update syntax tests (positive + negative). -/// -/// Update syntax uses the same SPARQL parser; this exercises .ru files. -/// Note: The update manifests also include UpdateEvaluationTest entries, -/// which will fail with "not yet implemented" — those are listed in ignored_tests. #[test] fn sparql11_syntax_update_1_tests() -> Result<()> { check_testsuite( "https://w3c.github.io/rdf-tests/sparql/sparql11/syntax-update-1/manifest.ttl", - &[], + reg::SPARQL11_SYNTAX_UPDATE_1, ) } @@ -50,7 +61,7 @@ fn sparql11_syntax_update_2_tests() -> Result<()> { fn sparql10_syntax_tests() -> Result<()> { check_testsuite( "https://w3c.github.io/rdf-tests/sparql/sparql10/manifest-syntax.ttl", - &[], + reg::SPARQL10_SYNTAX, ) } @@ -70,29 +81,19 @@ fn sparql11_federation_syntax_tests() -> Result<()> { // ============================================================================= // SPARQL 1.1 Query Evaluation Tests — Per-Category // -// Each runs the eval tests for one W3C category against an in-memory Fluree -// ledger. These are #[ignore]'d because many tests fail (unsupported features, -// missing functions, etc.). They will NOT block CI. -// -// Run individually: -// cargo test -p testsuite-sparql sparql11_ -- --nocapture --include-ignored -// -// Run all eval tests: -// cargo test -p testsuite-sparql -- --nocapture --include-ignored -// make test-all (from testsuite-sparql/) +// Each category runs against an in-memory Fluree ledger through the public +// API surface (data load, query, result comparison). // ============================================================================= #[test] -#[ignore = "eval: many failures expected — run with --include-ignored"] fn sparql11_aggregates() -> Result<()> { check_testsuite( "https://w3c.github.io/rdf-tests/sparql/sparql11/aggregates/manifest.ttl", - &[], + reg::SPARQL11_AGGREGATES, ) } #[test] -#[ignore = "eval: many failures expected — run with --include-ignored"] fn sparql11_bind() -> Result<()> { check_testsuite( "https://w3c.github.io/rdf-tests/sparql/sparql11/bind/manifest.ttl", @@ -101,16 +102,14 @@ fn sparql11_bind() -> Result<()> { } #[test] -#[ignore = "eval: many failures expected — run with --include-ignored"] fn sparql11_bindings() -> Result<()> { check_testsuite( "https://w3c.github.io/rdf-tests/sparql/sparql11/bindings/manifest.ttl", - &[], + reg::SPARQL11_BINDINGS, ) } #[test] -#[ignore = "eval: many failures expected — run with --include-ignored"] fn sparql11_cast() -> Result<()> { check_testsuite( "https://w3c.github.io/rdf-tests/sparql/sparql11/cast/manifest.ttl", @@ -119,43 +118,38 @@ fn sparql11_cast() -> Result<()> { } #[test] -#[ignore = "eval: many failures expected — run with --include-ignored"] fn sparql11_construct() -> Result<()> { check_testsuite( "https://w3c.github.io/rdf-tests/sparql/sparql11/construct/manifest.ttl", - &[], + reg::SPARQL11_CONSTRUCT, ) } #[test] -#[ignore = "eval: many failures expected — run with --include-ignored"] fn sparql11_exists() -> Result<()> { check_testsuite( "https://w3c.github.io/rdf-tests/sparql/sparql11/exists/manifest.ttl", - &[], + reg::SPARQL11_EXISTS, ) } #[test] -#[ignore = "eval: many failures expected — run with --include-ignored"] fn sparql11_functions() -> Result<()> { check_testsuite( "https://w3c.github.io/rdf-tests/sparql/sparql11/functions/manifest.ttl", - &[], + reg::SPARQL11_FUNCTIONS, ) } #[test] -#[ignore = "eval: many failures expected — run with --include-ignored"] fn sparql11_grouping() -> Result<()> { check_testsuite( "https://w3c.github.io/rdf-tests/sparql/sparql11/grouping/manifest.ttl", - &[], + reg::SPARQL11_GROUPING, ) } #[test] -#[ignore = "eval: many failures expected — run with --include-ignored"] fn sparql11_negation() -> Result<()> { check_testsuite( "https://w3c.github.io/rdf-tests/sparql/sparql11/negation/manifest.ttl", @@ -164,87 +158,26 @@ fn sparql11_negation() -> Result<()> { } #[test] -#[ignore = "eval: many failures expected — run with --include-ignored"] fn sparql11_project_expression() -> Result<()> { check_testsuite( "https://w3c.github.io/rdf-tests/sparql/sparql11/project-expression/manifest.ttl", - &[], + reg::SPARQL11_PROJECT_EXPRESSION, ) } -// Runs in the normal suite (not `#[ignore]`d): every unsupported case is -// explicitly listed below, so the test is green and guards the supported path -// features (simple `p`/`^p`/`p*`/`p+`, sequence `p1/p2`, alternation `a|b`, -// alternation-transitive `(a|b)*`/`(a|b)+`, both-bound reachability) against -// regressions. Each ignored case is grouped by the missing feature so the -// remaining work is legible; remove an entry as its feature lands. #[test] fn sparql11_property_path() -> Result<()> { - let base = "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/property-path/manifest#"; - let ignored: Vec = [ - // Transitive over a *sequence* — `(:p1/:p2)*` needs closure over a - // multi-hop sub-path, not just a predicate set. - "pp02", - "pp12", - "pp37", - // Negated property sets — `!:p`, `!(:p|^:q)`: an "any predicate except" - // traversal operator (not yet implemented). - "pp10", - "nps_inverse", - "nps_direct_and_inverse", - "nps_a", - "nps_a_inverse", - // Zero-or-one `?` — incl. `(:p/:p)?` over a sequence and over a set - // (zero-length-path semantics over arbitrary terms). - "pp28a", - "zero_or_one_set_start", - "zero_or_one_set_end", - // Path cardinality / duplicate semantics — results differ in - // multiplicity (`p1/p2` path counting vs `*`/`+` distinct nodes). - "pp06", - "pp16", - "pp34", - "pp35", - // Zero-variable `SELECT *` over a both-bound reachability path — the - // path lowers correctly; the empty-solution projection differs. - "pp36", - // VALUES combined with a path pattern. - "values_and_path", - ] - .iter() - .map(|t| format!("{base}{t}")) - .collect(); - let ignored_refs: Vec<&str> = ignored.iter().map(String::as_str).collect(); check_testsuite( "https://w3c.github.io/rdf-tests/sparql/sparql11/property-path/manifest.ttl", - &ignored_refs, + reg::SPARQL11_PROPERTY_PATH, ) } #[test] -#[ignore = "eval: many failures expected — run with --include-ignored"] fn sparql11_subquery() -> Result<()> { check_testsuite( "https://w3c.github.io/rdf-tests/sparql/sparql11/subquery/manifest.ttl", - &[], - ) -} - -// ============================================================================= -// SPARQL 1.1 Full Query Evaluation Test Suite -// ============================================================================= - -/// All 12 SPARQL 1.1 query evaluation categories in a single run. -/// -/// This is the top-level manifest that includes aggregates, bind, bindings, -/// cast, construct, exists, functions, grouping, negation, project-expression, -/// property-path, subquery, and syntax-query. -#[test] -#[ignore = "Full 1.1 eval suite (~5 min); use per-category tests or --include-ignored"] -fn sparql11_query_w3c_testsuite() -> Result<()> { - check_testsuite( - "https://w3c.github.io/rdf-tests/sparql/sparql11/manifest-sparql11-query.ttl", - &[], + reg::SPARQL11_SUBQUERY, ) } @@ -259,34 +192,25 @@ fn sparql11_query_w3c_testsuite() -> Result<()> { /// boolean-effective-value, bound, expr-builtin, expr-ops, expr-equals, /// regex, i18n, construct, ask, distinct, sort, solution-seq, reduced. #[test] -#[ignore = "eval: many failures expected — run with --include-ignored"] fn sparql10_query_eval_tests() -> Result<()> { check_testsuite( "https://w3c.github.io/rdf-tests/sparql/sparql10/manifest-evaluation.ttl", - &[], + reg::SPARQL10_QUERY_EVAL, ) } // ============================================================================= -// SPARQL 1.1 Update Tests -// -// Includes both update syntax tests (PositiveUpdateSyntaxTest11 / -// NegativeUpdateSyntaxTest11) and update evaluation tests -// (UpdateEvaluationTest). Syntax tests use the existing parser handlers. -// Evaluation tests fail with "not yet implemented" — this is expected. +// SPARQL 1.1 Update Tests (syntax + evaluation, all 13 categories) // ============================================================================= -/// SPARQL 1.1 update tests: all 13 categories via top-level manifest. -/// -/// Categories: add, basic-update, clear, copy, delete-data, delete-insert, -/// delete-where, delete, drop, move, syntax-update-1, syntax-update-2, -/// update-silent. +/// SPARQL 1.1 update tests: add, basic-update, clear, copy, delete-data, +/// delete-insert, delete-where, delete, drop, move, syntax-update-1, +/// syntax-update-2, update-silent. #[test] -#[ignore = "update eval not yet implemented — run with --include-ignored"] fn sparql11_update_tests() -> Result<()> { check_testsuite( "https://w3c.github.io/rdf-tests/sparql/sparql11/manifest-sparql11-update.ttl", - &[], + reg::SPARQL11_UPDATE, ) } @@ -294,204 +218,170 @@ fn sparql11_update_tests() -> Result<()> { // SPARQL 1.1 Result Format Tests // ============================================================================= -/// SPARQL 1.1 JSON result format tests. -/// -/// These are QueryEvaluationTest entries with .srj expected results. +/// SPARQL 1.1 JSON result format tests (.srj expected results). #[test] -#[ignore = "eval: failures expected — run with --include-ignored"] fn sparql11_json_result_tests() -> Result<()> { check_testsuite( "https://w3c.github.io/rdf-tests/sparql/sparql11/json-res/manifest.ttl", - &[], + reg::SPARQL11_JSON_RES, ) } /// SPARQL 1.1 CSV/TSV result format tests. -/// -/// Includes CSVResultFormatTest (not yet implemented) and QueryEvaluationTest -/// with .csv/.tsv expected results (not yet supported). #[test] -#[ignore = "CSV/TSV comparison not yet implemented — run with --include-ignored"] fn sparql11_csv_tsv_result_tests() -> Result<()> { check_testsuite( "https://w3c.github.io/rdf-tests/sparql/sparql11/csv-tsv-res/manifest.ttl", - &[], + reg::SPARQL11_CSV_TSV, ) } // ============================================================================= // SPARQL 1.1 Federation SERVICE Tests -// -// Deliberately ignored: these require external SPARQL endpoints (qt:serviceData -// / qt:endpoint) which cannot be provided in a unit test environment. -// All test IDs are listed in ignored_tests to prevent false failures. // ============================================================================= /// SPARQL 1.1 SERVICE federation evaluation tests. /// -/// All tests are ignored because they require external SPARQL endpoints. -/// When federation support is added, tests should be enabled incrementally -/// with a mock endpoint or integration test environment. +/// All entries are registered as skips: they require external SPARQL +/// endpoints. Enable incrementally (mock endpoint) when federation +/// execution support is added. #[test] -#[ignore = "requires external SPARQL endpoints — run with --include-ignored"] fn sparql11_service_tests() -> Result<()> { check_testsuite( "https://w3c.github.io/rdf-tests/sparql/sparql11/service/manifest.ttl", - &[ - // All SERVICE tests require external SPARQL endpoints. - // Enable incrementally when federation support is added. - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/service/manifest#service1", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/service/manifest#service2", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/service/manifest#service3", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/service/manifest#service4a", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/service/manifest#service5", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/service/manifest#service6", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/service/manifest#service7", - ], + reg::SPARQL11_SERVICE, ) } // ============================================================================= // SPARQL 1.1 Protocol / Service Description / Graph Store Protocol Tests // -// These require HTTP server infrastructure and cannot run as unit tests. -// Registered for completeness — all tests are in ignored_tests. +// Not applicable to a database engine (they exercise HTTP conformance). +// Registered so the suite inventory is complete; every test is in the +// register with that rationale. // ============================================================================= -/// SPARQL 1.1 protocol tests. -/// -/// Require HTTP server and client infrastructure. Not applicable to -/// database engine unit testing. #[test] -#[ignore = "requires HTTP protocol infrastructure"] fn sparql11_protocol_tests() -> Result<()> { check_testsuite( "https://w3c.github.io/rdf-tests/sparql/sparql11/protocol/manifest.ttl", - &[ - // All protocol tests require HTTP server — not applicable to unit tests. - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#query_post_form", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#query_dataset_default_graphs_get", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#query_dataset_default_graphs_post", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#query_dataset_named_graphs_post", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#query_dataset_named_graphs_get", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#query_dataset_full", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#query_multiple_dataset", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#query_get", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#query_content_type_select", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#query_content_type_ask", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#query_content_type_describe", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#query_content_type_construct", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#update_dataset_default_graph", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#update_dataset_default_graphs", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#update_dataset_named_graphs", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#update_dataset_full", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#update_post_form", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#update_post_direct", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#update_base_uri", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#query_post_direct", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#bad_query_method", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#bad_multiple_queries", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#bad_query_wrong_media_type", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#bad_query_missing_form_type", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#bad_query_missing_direct_type", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#bad_query_non_utf8", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#bad_query_syntax", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#bad_update_get", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#bad_multiple_updates", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#bad_update_wrong_media_type", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#bad_update_missing_form_type", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#bad_update_non_utf8", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#bad_update_syntax", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/protocol/manifest#bad_update_dataset_conflict", - ], - ) -} - -/// SPARQL 1.1 service description tests. -/// -/// Require a running SPARQL server to introspect. + reg::SPARQL11_PROTOCOL, + ) +} + #[test] -#[ignore = "requires running SPARQL server"] fn sparql11_service_description_tests() -> Result<()> { check_testsuite( "https://w3c.github.io/rdf-tests/sparql/sparql11/service-description/manifest.ttl", - &[ - // All service description tests require a running server. - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/service-description/manifest#returns-rdf", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/service-description/manifest#has-endpoint-triple", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/service-description/manifest#conforms-to-schema", - ], + reg::SPARQL11_SERVICE_DESCRIPTION, ) } -/// SPARQL 1.1 HTTP graph store protocol tests. -/// -/// Require HTTP graph store endpoint infrastructure. #[test] -#[ignore = "requires HTTP graph store infrastructure"] fn sparql11_http_rdf_update_tests() -> Result<()> { check_testsuite( "https://w3c.github.io/rdf-tests/sparql/sparql11/http-rdf-update/manifest.ttl", - &[ - // All graph store protocol tests require HTTP infrastructure. - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#put__initial_state", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#get_of_put__initial_state", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#put__graph_already_in_store", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#get_of_put__graph_already_in_store", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#put__default_graph", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#get_of_put__default_graph", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#put__mismatched_payload", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#delete__existing_graph", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#get_of_delete__existing_graph", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#delete__nonexistent_graph", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#post__existing_graph", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#get_of_post__existing_graph", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#post__multipart_formdata", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#get_of_post__multipart_formdata", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#post__create__new_graph", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#get_of_post__create__new_graph", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#get_of_post__after_noop", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#head_on_an_existing_graph", - "http://www.w3.org/2009/sparql/docs/tests/data-sparql11/http-rdf-update/manifest#head_on_a_nonexisting_graph", - ], + reg::SPARQL11_HTTP_RDF_UPDATE, ) } // ============================================================================= // SPARQL 1.1 Entailment Tests -// -// Require entailment regime support (RDFS, OWL, RIF) which Fluree does not -// implement. Registered for completeness. // ============================================================================= /// SPARQL 1.1 entailment regime tests. /// -/// Require RDFS/OWL/RIF entailment reasoning. Fluree does not implement -/// entailment regimes. These tests are registered for completeness and -/// to track potential future support. +/// Fluree does not implement RDFS/OWL/RIF entailment regimes (deliberate +/// non-goal). The subset answerable under simple entailment passes and is +/// enforced; the rest is registered. #[test] -#[ignore = "requires entailment regime support (RDFS/OWL/RIF)"] fn sparql11_entailment_tests() -> Result<()> { check_testsuite( "https://w3c.github.io/rdf-tests/sparql/sparql11/entailment/manifest.ttl", - &[], + reg::SPARQL11_ENTAILMENT, ) } // ============================================================================= -// SPARQL 1.1 Complete Suite (all manifests) +// SPARQL 1.2 Test Suite (RDF-star / triple terms and other 1.2 features) // ============================================================================= -/// Run the complete SPARQL 1.1 test suite: all manifests combined. -/// -/// This is the ultimate compliance check — includes query, update, -/// federation, result formats, and all infrastructure tests. -/// Takes ~10 minutes. Use per-category tests for incremental work. #[test] -#[ignore = "Complete 1.1 suite (~10 min); use per-category tests"] -fn sparql11_all() -> Result<()> { +fn sparql12_grouping() -> Result<()> { check_testsuite( - "https://w3c.github.io/rdf-tests/sparql/sparql11/manifest-all.ttl", - &[], + "https://w3c.github.io/rdf-tests/sparql/sparql12/grouping/manifest.ttl", + reg::SPARQL12_GROUPING, + ) +} + +#[test] +fn sparql12_codepoint_escapes() -> Result<()> { + check_testsuite( + "https://w3c.github.io/rdf-tests/sparql/sparql12/codepoint-escapes/manifest.ttl", + reg::SPARQL12_CODEPOINT_ESCAPES, + ) +} + +#[test] +fn sparql12_syntax_triple_terms_negative() -> Result<()> { + check_testsuite( + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-negative/manifest.ttl", + reg::SPARQL12_SYNTAX_TRIPLE_TERMS_NEGATIVE, + ) +} + +#[test] +fn sparql12_syntax_triple_terms_positive() -> Result<()> { + check_testsuite( + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax-triple-terms-positive/manifest.ttl", + reg::SPARQL12_SYNTAX_TRIPLE_TERMS_POSITIVE, + ) +} + +#[test] +fn sparql12_eval_triple_terms() -> Result<()> { + check_testsuite( + "https://w3c.github.io/rdf-tests/sparql/sparql12/eval-triple-terms/manifest.ttl", + reg::SPARQL12_EVAL_TRIPLE_TERMS, + ) +} + +#[test] +fn sparql12_expression() -> Result<()> { + check_testsuite( + "https://w3c.github.io/rdf-tests/sparql/sparql12/expression/manifest.ttl", + reg::SPARQL12_EXPRESSION, + ) +} + +#[test] +fn sparql12_lang_basedir() -> Result<()> { + check_testsuite( + "https://w3c.github.io/rdf-tests/sparql/sparql12/lang-basedir/manifest.ttl", + reg::SPARQL12_LANG_BASEDIR, + ) +} + +#[test] +fn sparql12_rdf11() -> Result<()> { + check_testsuite( + "https://w3c.github.io/rdf-tests/sparql/sparql12/rdf11/manifest.ttl", + reg::SPARQL12_RDF11, + ) +} + +#[test] +fn sparql12_syntax() -> Result<()> { + check_testsuite( + "https://w3c.github.io/rdf-tests/sparql/sparql12/syntax/manifest.ttl", + reg::SPARQL12_SYNTAX, + ) +} + +#[test] +fn sparql12_version() -> Result<()> { + check_testsuite( + "https://w3c.github.io/rdf-tests/sparql/sparql12/version/manifest.ttl", + reg::SPARQL12_VERSION, ) }