Skip to content

fix: fail closed on unencodable datatype IRIs; keep the membership-join lane join-equivalent on list rows - #1746

Merged
aaj3f merged 4 commits into
mainfrom
fix/literal-identity-seam-1686-1687
Sep 3, 2026
Merged

fix: fail closed on unencodable datatype IRIs; keep the membership-join lane join-equivalent on list rows#1746
aaj3f merged 4 commits into
mainfrom
fix/literal-identity-seam-1686-1687

Conversation

@aaj3f

@aaj3f aaj3f commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Fixes #1686. Partially addresses #1687 — the join-equivalence half; the list-position semantics decision stays open there (details below).

Both of these came out of the same review pass over #1674/#1676 and sit on the same literal-identity seam — what a constant literal's datatype means when it meets the scan — so they ship together.

Problem

1. Unencodable datatype IRIs failed open (#1686). Both term-identity sites in SPARQL lowering — the triple-pattern constraint in lower_literal_with_constraint and its term_to_binding VALUES twin (fluree-db-sparql/src/lower/term.rs) — resolved a typed literal's datatype with encode_iri_strict and fell back to xsd:string when the namespace wasn't registered. So ?s ex:p "a"^^ex:NoSuchType matched every plain-string "a" row, and the VALUES form did the same. encode_iri_strict returning None means exactly "this ledger has no such namespace", and ingest registers every namespace it stores — so no stored term on that ledger can carry that datatype, and for a single-ledger query the empty result isn't just the spec answer, it's provably the only correct one. (In a multi-ledger dataset the constraint is resolved against the primary ledger only and a non-primary ledger can carry the datatype — that seam predates this PR and is #1771; see Not in this PR.) The JSON-LD surface already gave it (its lower_triple_pattern uses the non-strict encoder), which means the two query surfaces disagreed on the same question — verified live before the fix: SPARQL 1 row, JSON-LD 0.

2. The membership-join lane silently diverged from the generic join on list rows (#1687). MembershipJoinOperator answers a right triple that binds nothing new as a hash keep/drop, on the premise that a fully-ground triple matches at most once. RDF list rows break that premise inside a single graph — the same (s, p, o) recurs at multiple o_i positions — and nothing in membership_join_key_vars excluded them, so above the 256-row driving gate the lane answered a var-object join over list elements as a semi-join while the generic pipeline answers a bag join. Reproduced above the gate in the new fixture: 276 rows from the lane vs 532 from the generic pipeline on identical data — a scale-dependent silent wrong answer, and a break of the lane's own documented "join-equivalent unconditionally" invariant. On top of that the lane was untestable: no stamp_fast_path site, and FLUREE_DISABLE_QUERY_FAST_PATHS didn't reach it, so MustFire/MustNotFire couldn't express engagement at all.

Changes

Commit 1 — fail closed on unencodable datatypes (fluree-db-sparql)

  • Both sites now resolve through a shared datatype_sid helper: non-strict encode, so an unregistered namespace yields the EMPTY-namespace full-IRI Sid — a constraint no stored row carries. Every enforcement lane compares via dt_compatible (exact Sid equality outside the XSD numeric families: range.rs:289 novelty, binary_scan.rs:846 overlay, binary_scan.rs:1287 indexed), so the pattern matches nothing in all of them, and the SPARQL answer now equals the JSON-LD one by construction.
  • Registered custom datatypes are untouched — their query-time encode resolves to the same canonical Sid ingest stored, pinned by controls.
  • A datatype IRI that canonically splits to the EMPTY prefix on both sides (a bare-string datatype actually stored that way) still matches by exact equality, as before.
  • Unit test pins the lowered constraint form at both sites; integration guards in it_literal_identity.rs pin the empty result and the controls on both surfaces, in both lanes (novelty + indexed).

Commit 2 — keep the membership lane join-equivalent (fluree-db-query)

  • Stats carry no list-ness, so the shape is not plan-detectable. Instead the one-shot drain that builds the key set doubles as the detector: a key tuple inserted twice is the premise's counterexample (two flakes matched one ground triple). When that happens the operator discards the hash path and routes every row through its existing exact per-row fallback — the same seeded evaluation the generic join runs — so lane and generic agree by construction, whatever the eventual list-semantics ruling says. The detection is exact, not heuristic: keep/drop diverges from the generic join iff some ground probe matches more than one flake, and that's precisely when the drain sees a duplicate key.
  • The decline is surgical: on ordinary shapes the added cost is one bool OR per drained key, and the new fixture pins (via routing stamp) that a same-size non-list shape still takes the hash path.
  • Testability: membership_join.rs now stamps the repo-standard fast-path outcome (site membership-joinProceed when the hash path serves, GateDeclined on the duplicate-key or multi-graph fallback), and the planner honors FLUREE_DISABLE_QUERY_FAST_PATHS for the lane (stamping KillSwitch, checked at the choose site so membership_join_key_vars stays a pure predicate). The differential harness can finally compare this lane against the generic join.
  • New own-binary test it_membership_join_list_rows runs both cases above the gate (296/280 driving rows) with span-captured stamps, so neither case can silently degrade into a below-gate run and pass vacuously; phase 2 re-answers both under the kill switch and requires identical rows. The stale "the lane's answer at or above the gate is unpinned" comment in it_fastpath_1652_regression.rs now points at the new pin.

Both fixes were reverted independently during development to confirm their tests fail for the right reason: the #1686 guards reproduce the 1-row fail-open match, and the #1687 fixture reproduces the 276-vs-532 divergence with the lane engaged.

Behaviour changes (release notes)

  • SPARQL: a typed literal whose datatype IRI has no registered namespace on the ledger now matches nothing (previously: matched plain xsd:string rows), in triple patterns and VALUES alike. Registered custom datatypes are unaffected.
  • A var-object join over RDF list elements now returns the same rows whether or not the planner picks the membership lane. Below the driving gate nothing changes; above it the lane previously under-counted (one row per driving row instead of one per matching flake).
  • FLUREE_DISABLE_QUERY_FAST_PATHS now also disables the membership-join lane.

Not in this PR

  • The list-position semantics decision (Membership-join lane's answer on list rows is unpinned at or above the driving-size gate #1687's step 1). Whether a var-object join over list elements should be a bag join over positions (532/5-style answers) or a set join over distinct values is still the open product call — this PR only guarantees both pipelines give the same answer, which today is the generic bag join. The two fixtures pin today's numbers with comments saying exactly that, so settling the decision the other way means changing pinned expectations deliberately, not silently. That's why this is "partially addresses": the issue's remaining substance is that decision, and it shouldn't get closed as a side effect of the equivalence fix.
  • term_to_binding special-cases fluree:embeddingVectorSid(FLUREE_DB, "vector") before encoding while the triple-pattern site doesn't; that asymmetry predates this PR and is orthogonal to the fail-open bug, so I left it alone rather than touch vector matching without a vector rig.
  • Multi-ledger datasets: the constraint Sid is resolved against the primary ledger only. query_dataset lowers against the first default graph's snapshot, and per-graph execution re-encodes s/p/o Sids but not pattern.dtc (binary_scan.rs build_match_val_for_snapshot), so a datatype registered only on a non-primary ledger constrains as the primary encoded it — confirmed by fixture: this PR turns that from matching the primary's plain-string rows (wrong rows) into matching nothing (silent drop), while registered-on-both datatypes with divergent namespace codes were already dropped before it. The re-encode fix belongs with the pre-existing gap, and this PR's EMPTY-namespace Sid keeps the datatype IRI recoverable for it where the xsd:string fallback destroyed it. Follow-up: Multi-ledger datasets: datatype-constraint Sid is never re-encoded per graph, so cross-ledger custom-datatype matches silently drop #1771.

Gates

  • cargo test -p fluree-db-query (1,485 lib + integration bins) and -p fluree-db-sparql (641): green.
  • -p fluree-db-api --features native: it_literal_identity, it_fastpath_1652_regression, it_membership_join_list_rows, it_cyclic_bgp_string_dict, it_query_sparql_indexed: green.
  • W3C suite (testsuite-sparql, both-way registers): 36/36 groups green.
  • cargo clippy --all-targets --no-deps -- -D warnings on all three touched crates; cargo fmt --all --check clean.

aaj3f added 2 commits August 28, 2026 13:43
…IRIs

A typed literal whose datatype IRI has no registered namespace on the
ledger used to fall back to xsd:string at both term-identity sites in
SPARQL lowering (the triple-pattern constraint and the term_to_binding
VALUES twin), so "a"^^ex:NoSuchType matched every plain-string "a"
row. Ingest registers every namespace it stores, so no stored term can
carry such a datatype — the provably correct answer is the empty result.

Resolve the datatype through the non-strict encoder instead: an
unregistered namespace yields the EMPTY-namespace full-IRI Sid, a
constraint no stored row carries, so the pattern matches nothing in
every enforcement lane (dt_compatible requires exact Sid equality
outside the XSD numeric families). This is the form the JSON-LD query
surface has used all along (parse/lower.rs), which also closes a live
SPARQL-vs-JSON-LD answer divergence on the same question.

Registered custom datatypes are unaffected: their query-time encode
resolves to the same canonical Sid ingest stored. Pinned by unit test
at the lowering layer and integration guards on both surfaces, both
lanes (novelty + indexed), with matching controls.

Fixes #1686
MembershipJoinOperator answers a right triple that binds nothing new as
a hash keep/drop, on the premise that a fully-ground triple matches at
most once. RDF list rows break that premise inside a single graph — the
same (s, p, o) recurs at multiple o_i positions — so above the 256-row
driving gate the lane answered a var-object join over list elements as
a semi-join while the generic pipeline answers it as a bag join (3 vs 5
on the #1652 fixture): a scale-dependent silent divergence from the
lane's own documented join-equivalence invariant.

Stats carry no list-ness, so the shape is not plan-detectable. Instead
the one-shot drain that builds the key set doubles as the detector: a
key tuple inserted twice IS the premise's counterexample (two flakes
matched one ground triple). When that happens the operator discards the
hash path and routes every row through its existing exact per-row
fallback, so the lane's answer equals the generic pipeline's by
construction — without ruling on what a var-object join over list
positions should mean (that semantics call stays open on #1687). The
cost on ordinary shapes is one bool OR per drained key; the decline is
surgical, pinned by a MustFire-style stamp assertion on a same-size
non-list fixture.

Also closes the lane's testability gap: membership_join.rs now stamps
the repo's fast-path routing outcome (site membership-join — Proceed
when the hash path serves, GateDeclined on the duplicate-key or
multi-graph fallback) and the planner honors
FLUREE_DISABLE_QUERY_FAST_PATHS for the lane, so differential harnesses
can finally express engagement and compare it against the generic join.
New own-binary integration test pins both directions above the gate,
with span-captured stamps so neither case can silently degrade into a
below-gate run.
@aaj3f aaj3f added bug Something isn't working as expected area:query Query execution, planning, fast paths, overlay, result formatting labels Aug 28, 2026

@bplatz bplatz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Three stale doc comments the new fallback route leaves behind — two inline, plus one outside the diff:

fast_path_outcome.rs stamp_fast_path says stamps are "called at plan time from the kill-switch gate sites and at open() time from FastPathOperator". This lane adds a third timing — mid-execution, from build_key_set on the first fully-bound row. Worth a clause so the next site author knows that's allowed.

Separately, filed #1765 for the third site on this seam: ComparableValue::to_binding (eval/value.rs) resolves a STRDT datatype with encode_iri_strict and raises Unknown datatype IRI when the namespace isn't registered — BIND(STRDT(?v, <http://no-such-ns.example.com/T>) AS ?x) fails the whole query, while the registered control works. Out of scope here; noting it so the seam is fully mapped.

self.per_row_join(ctx, &input_batch, row_idx, &mut columns)
.await?;
}
self.fallback_rows += 1;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

estimated_rows() (:371) still claims "A filter: at most the child's cardinality (ground rows match ≤ once)". This route is exactly where that stops holding — the new fixture returns 532 rows from 296 driving rows.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Right — verified the numbers against the fixture (it_membership_join_list_rows asserts the 532-from-296 shape and is green at head) and rewrote the comment to describe both regimes. I also chased whether the value now misleads anything, since doc-patching a live planner input would be the wrong fix: the consumers are describe()/EXPLAIN (operator.rs:149) and two runtime lane gates that read a child's estimate — SubqueryOperator's materialize-eligibility check (subquery.rs:237) and the annotation-edge probe's driving-size gate (default_graph_source.rs:204). Both treat a low value conservatively: they decline their optimization and stay on the exact generic lane, so an under-estimate can cost a missed optimization but not correctness. And the operator can't honestly do better — the regime isn't known until build_key_set sees the first batch, while describe() and both gates read the estimate at plan/open time. So the child estimate stays, now documented as the drain lane's upper bound and the fallback regime's potential under-estimate. Fixed in 14d1e83.

}
self.key_set = Some(set);
Ok(())
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

per_row_join's doc just below still reads "Exact join fallback for a row that does NOT fully ground the pattern" — after this change it's also the path every fully-ground row takes once the drain declines.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 14d1e83 — it now reads as the exact join for any row the keep/drop drain can't answer: one that doesn't fully ground the pattern, or every row once exact_only is set.

@bplatz bplatz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Couple docs updates noted but also created issue #1765 related to what you fixed here.

@bplatz bplatz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One question on the fail-closed choice — inline. Worth looking into before merge, and a follow-up issue is probably the right home for it if it confirms.

/// matches nothing, which is the spec answer for a term that does not
/// exist. (The previous `xsd:string` fallback made
/// `"a"^^ex:NoSuchType` match every plain-string `"a"` row — #1686.)
fn datatype_sid(&self, dt_iri: &str) -> Sid {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Does this premise survive a multi-ledger dataset query? "Ingest registers every namespace it stores" is true of one ledger, and this Sid is resolved once against the lowering snapshot.

map_pattern_to_match (binary_scan.rs:1796-1830) re-encodes the subject, predicate and object Sids into the per-graph snapshot, but not pattern.dtc — it goes to dt_compatible as the lowering snapshot encoded it. So for a datatype registered in ledger B but not in the lowering snapshot A, the constraint is now the EMPTY-ns Sid and matches nothing in B, where before it was xsd:string and matched the wrong rows. Both wrong, but the new one silently drops real matches instead of adding false ones.

The underlying gap (dtc never re-encoded per graph) predates this PR — registered custom datatypes are already broken cross-ledger by it — and I read this off the code rather than building a fixture, so it may well not be reachable in practice. Worth confirming either way; if it holds, a follow-up issue for re-encoding dtc alongside s/p/o would cover both cases.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're right, and it's reachable — I built the fixture rather than trust either of our readings. Two memory ledgers through the public query_dataset surface: A (primary) holds a plain-string "a" row, B holds ex:s2 ex:p "a"^^ct:T with ct: registered only on B (code 14). SELECT ?s WHERE { ?s ex:p "a"^^ct:T } over [A, B]:

  • at this PR's head: [] — the silent drop you predicted (B alone as primary returns s2, so the rows are there and the union reaches them);
  • with main's xsd:string fallback swapped back into datatype_sid: [["ex:s1"]] — the primary's plain-string row, i.e. wrong rows.

Your pre-existing half confirmed too, with a second case: ct: registered on both ledgers but under divergent codes (15 vs 14, via a padded namespace table on the primary) drops the non-primary row identically at head and on main. Full fixture + verbatim output are in #1771, filed for the re-encode-dtc-alongside-s/p/o fix covering both shapes. I looked at folding it in here: it's three dt_compatible sites (binary_scan.rs:846, matches_datatype_constraint at :1286, range.rs:289) plus threading a per-open effective dtc through the scan, which felt past the fold-in line for this PR — but happy to talk it through if you'd rather see it land here.

One point in the new behavior's favor beyond fail-closed-vs-wrong-rows: the EMPTY-namespace Sid carries the full datatype IRI in its name, so #1771's per-graph re-encode can recover the graph-local Sid from it directly — the xsd:string fallback destroyed that information at lowering time, so no downstream fix could ever have repaired the unregistered-on-primary shape on top of it. Kept the PR's behavior and scoped the body's "provably the only correct one" claim to single-ledger, with the multi-ledger caveat under Not in this PR. The fixture also turned up the decode-side sibling on the same surface (to_jsonld against the primary snapshot either errors on B's codes or silently decodes the wrong IRI) — noted in #1771 next to #1639 rather than filed separately.

…k route

Review follow-ups on #1746 (bplatz):

- membership_join::estimated_rows: the "ground rows match <= once" claim
  only holds for the drain lane; the per-row-join fallback is a bag join
  that can exceed the child's cardinality (532 rows from 296 driving rows
  on the #1687 fixture). Document both regimes and why the child estimate
  stays the honest hint (the regime is unknown at plan/open time, and the
  gates that read the value decline optimizations conservatively).
- membership_join::per_row_join: also the path every fully-ground row
  takes once the drain declines (exact_only), not just non-ground rows.
- fast_path_outcome::stamp_fast_path: record the third stamp timing this
  PR added (mid-execution, from build_key_set on the first fully-bound
  row) so the next site author knows it's allowed.
@aaj3f

aaj3f commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @bplatz — all three doc notes were right; fixed in 14d1e83. That includes stamp_fast_path's doc, which now names the third timing this PR introduced (mid-execution, from build_key_set on the first fully-bound row) and says outright that a new site may stamp at whichever point its gate actually decides — so the next site author isn't left inferring permission from this lane.

On your inline multi-ledger question: confirmed reachable by fixture, both failure modes as you read them — details in the thread, follow-up filed as #1771.

And thanks for filing #1765 — agreed it's the third site on this seam and the only one that fails hard, and the non-strict-encoder direction you sketched there lines up with what the two lowering sites now do. Between this PR (constraint + VALUES lowering), #1765 (expression materialization), and #1771 (per-graph re-encode), I think the seam is now fully mapped.

@aaj3f
aaj3f merged commit 94b2c26 into main Sep 3, 2026
16 checks passed
@aaj3f
aaj3f deleted the fix/literal-identity-seam-1686-1687 branch September 3, 2026 20:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:query Query execution, planning, fast paths, overlay, result formatting bug Something isn't working as expected

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unencodable datatype IRI in a triple pattern falls back to xsd:string instead of matching nothing

2 participants