Fix SPARQL path+ (OneOrMore) transitive closure: project each depth branch to its endpoints - #370
Open
teipsum wants to merge 1 commit into
Open
Fix SPARQL path+ (OneOrMore) transitive closure: project each depth branch to its endpoints#370teipsum wants to merge 1 commit into
path+ (OneOrMore) transitive closure: project each depth branch to its endpoints#370teipsum wants to merge 1 commit into
Conversation
…ive closure SPARQL `path+` (OneOrMore) returned no transitive closure for variable endpoints: it yielded only the direct neighbours, with the true multi-hop endpoint replaced by the first intermediate hop and duplicate rows that DISTINCT failed to collapse. The sibling operators `/ | ^ * ?` were all correct. `translate_one_or_more_path` pushed each fixed-depth branch raw into the Union, whereas `translate_zero_or_more_path` and `translate_zero_or_one_path` wrap every depth branch in `project_path_endpoints` first. Without that projection the depth-N branches keep their per-hop intermediate columns, the Union output schema is seeded from the narrow depth-1 branch, and Distinct copies the leading (intermediate) columns into the endpoint slot while keying on each chunk's own width. Wrap each depth branch in `project_path_endpoints`, exactly as the sibling operators do. No reflexive 0-hop branch is added: `path+` excludes the zero-length path (SPARQL 1.1 sec 9.1). Add result-level closure oracles to property_paths.gtest over a->b->c->d->e plus c->x (open-ended `?s :p+ ?o`, bound-subject, a cycle, nested `^(:p+)` and `(:p+)/:q`, and `?` `/` `^` regression guards), and strengthen two existing count-only `+` tests to assert values rather than only cardinality. The bounded MAX_DEPTH=50 expansion is unchanged (also fixes a stale test comment). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
1 issue found across 2 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="crates/grafeo-engine/src/query/translators/sparql.rs">
<violation number="1" location="crates/grafeo-engine/src/query/translators/sparql.rs:1934">
P2: `project_path_endpoints` returns `input` unchanged when both endpoints are non-variables. For fully-bound `path+` queries (e.g., `<a> <p>+ <b>`), the newly-added call in `translate_one_or_more_path` is a no-op, so depth branches retain heterogeneous intermediate columns and reach `Union`/`Distinct` with mismatched schemas.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| // without this projection the heterogeneous branches reach `Union` | ||
| // and `Distinct` unnormalized and the visible object column ends up | ||
| // holding the first intermediate hop instead of the true endpoint. | ||
| branches.push(self.project_path_endpoints(&subject, &object, branch)); |
Contributor
There was a problem hiding this comment.
P2: project_path_endpoints returns input unchanged when both endpoints are non-variables. For fully-bound path+ queries (e.g., <a> <p>+ <b>), the newly-added call in translate_one_or_more_path is a no-op, so depth branches retain heterogeneous intermediate columns and reach Union/Distinct with mismatched schemas.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/grafeo-engine/src/query/translators/sparql.rs, line 1934:
<comment>`project_path_endpoints` returns `input` unchanged when both endpoints are non-variables. For fully-bound `path+` queries (e.g., `<a> <p>+ <b>`), the newly-added call in `translate_one_or_more_path` is a no-op, so depth branches retain heterogeneous intermediate columns and reach `Union`/`Distinct` with mismatched schemas.</comment>
<file context>
@@ -1922,7 +1925,13 @@ impl SparqlTranslator {
+ // without this projection the heterogeneous branches reach `Union`
+ // and `Distinct` unnormalized and the visible object column ends up
+ // holding the first intermediate hop instead of the true endpoint.
+ branches.push(self.project_path_endpoints(&subject, &object, branch));
}
</file context>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Contributor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #369.
What
SPARQL
path+(OneOrMore) did not return a transitive closure for variableendpoints — it returned only the direct neighbours, with the true multi-hop
endpoint replaced by the first intermediate hop, plus duplicate rows that
DISTINCTfailed to collapse. The five sibling operators (/ | ^ * ?) werealready correct.
This was a single missing endpoint projection in the
OneOrMoretranslation.Root cause
crates/grafeo-engine/src/query/translators/sparql.rstranslate_one_or_more_pathpushed each fixed-depth branch raw into theUnion, whereastranslate_zero_or_more_pathandtranslate_zero_or_one_pathwrap every depth branch in
project_path_endpointsfirst. The projection stripsthe per-hop intermediate columns so all union branches share the endpoint
schema.
Without it, a depth-N branch is wider than the depth-1 branch (one extra column
per intermediate hop). The union output schema is seeded from branch 0 (the
narrow depth-1 branch), and
Distinctkeys on each chunk's own width whilecopying into the narrow branch-0-sized builder — so the visible object column
ends up holding the first intermediate hop instead of the true endpoint, and
endpoints reached at different depths keep distinct (wider) keys and are never
de-duplicated.
The fix
One call site — wrap each depth branch exactly as the sibling operators do:
for depth in 1..=MAX_DEPTH { let branch = self.translate_fixed_depth_path(inner_path, &subject, &object, &graph, depth)?; - branches.push(branch); + branches.push(self.project_path_endpoints(&subject, &object, branch)); }No reflexive 0-hop branch is added:
path+excludes the zero-length path(SPARQL 1.1 §9.1).
project_path_endpointsis the existing, unchanged helper;this only adds a third caller and touches no shared state, so the five working
operators are textually unaffected.
Tests (result-level oracles)
The existing
tests/spec/rdf/sparql/property_paths.gtest+cases asserted onlya row count (or single-hop data), which passed even with the bug present —
on the chain graph the buggy output has the right cardinality but every value is
the leaked intermediate hop. This PR:
+cases (one_or_more_path_chain,sequence_with_one_or_more) to assert the actual reached values.a->b->c->d->e+c->x(predicate
p) that asserts the full distinct closure as a sorted multiset,so it fails on both missing transitive pairs and duplicate leakage:
?s :p+ ?o(full 13-pair closure;SELECT DISTINCTcount = 13),:a :p+ ?o={b,c,d,e,x},:p*control on the same graph (regression guard for*),ASKover a 3-hop fully-bound+path,a <-> b(4 distinct pairs; proves termination + de-duplication),^(:p+)and(:p+)/:q,?,/,^regression guards over the same graph.Pre-fix these new/strengthened oracles fail with the leaked output (e.g. the
cycle yields 100 rows;
SELECT DISTINCTopen-ended yields 5 instead of 13);post-fix all pass. The rest of
property_paths.gtestand the SPARQL suites staygreen, demonstrating
/ | ^ * ?are unchanged.Run them with:
Scope / honesty
path*,path+expands to abounded
MAX_DEPTH = 50hops plusDistinct(no visited-set), so this is notthe unbounded ALP of SPARQL 1.1 §18.4; closures over 50 hops truncate. Shared
with
path*, orthogonal to this fix — this PR does not claim spec-completeALP conformance.
SELECT ?s WHERE { :a :p+ :b }(projecting a variable absent from the pattern) errors with
GRAFEO-X001: Internal error: Variable 's' not found in input columns. This is identicalfor
:p*and:p?(shared with the working operators), so it is a separatenormalization item left for a follow-up.
ASKandSELECT *overfully-bound paths already work.
Summary by cubic
Fixes SPARQL
path+so it returns the true transitive closure for variable endpoints and removes duplicate leakage. Each fixed-depth branch is now projected to its endpoints before union, matching the behavior of*and?.OneOrMoredepth branch withproject_path_endpointsbeforeUnion/Distinct; no 0‑hop branch (per spec).+tests to assert values, and added closure oracles (open-ended, bound-subject, cycle, nested^(:p+)and(:p+)/:q) plus regression guards for*,?,/,^.MAX_DEPTH = 50and updated a stale test comment; other path operators unchanged.Written for commit f77bae7. Summary will update on new commits.