fix(gfql): OPTIONAL MATCH + WITH pipeline row semantics (#1896) - #1897
fix(gfql): OPTIONAL MATCH + WITH pipeline row semantics (#1896)#1897lmeyerov wants to merge 11 commits into
Conversation
Four silent-wrong shapes from the round-004-precursor adversarial re-probe: 1 (CRITICAL): terminal WITH after OPTIONAL MATCH rode the single-alias row-column pipeline -- pandas nulled matched bindings and collapsed multiplicity, polars fabricated bid=seed-ids. flatten_terminal_with_over_ optional now rewrites pure bare-alias carries onto the connected left-join lowering, with the stage WHERE applied as a post-join binding-ROW filter (openCypher WITH..WHERE semantics; serves seed AND optional-alias predicates -- the canonical `WHERE x IS NULL` anti-join now answers). Terminal projection/aggregate stages passed through by RETURN fold into a direct RETURN (2: zero-count groups now kept -- count(b)=0 / count(*)=1 for unmatched seeds). Whole-row-next-to-aggregate stages keep their typed decline; any other unflattenable OM WITH stage now declines typed instead of riding the pipeline into silent-wrong. 3+4 (one root): the reentry null-fill replaced `prefix_rows - result_rows` anonymous null templates with an identity anti-join over projected carried-alias columns (bare props, renamed WITH scalars incl. their __cypher_reentry_ markers) -- rename (`WITH a AS p`) and LIMIT carries now null-extend with their seed identity; a multiplicity surplus no longer masks missing null rows. Shapes that project no identifying carried column decline typed (prefix_rows==1 count logic stays, it is sound). 5: the ORDER BY optional-property decline now hints the output-alias spelling that sorts with openCypher null placement. Pins: section F in test_optional_match_semantics.py -- pandas+polars hand-computed oracles for every finding, plus decline-shape negative controls; the anti-join message-audit pin flips to a served-answer pin. Surface-guard baseline regenerated: lowering.py 9454 -> 9503 (flatten hook + typed guard + post-join filter). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
|
GPU sweep receipt (dgx GB10, RAPIDS 26.02, --gpus-enforced, @ d502dea): full |
Same coverage-floor playbook as flatten_pure_carry_optional: the new #1896 flatten shipped with only end-to-end coverage; 20 direct AST pins cover the admit paths and every decline branch (flatten.py floor 93.60% -> above 94.05%). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
| if fill_rows is None and carried_output_map: | ||
| # #1896: never synthesize ANONYMOUS null rows by count arithmetic -- | ||
| # anti-join unmatched prefix rows so the null-extension keeps its | ||
| # carried seed identity, or decline typed below. |
There was a problem hiding this comment.
Violates comment rules too
| *, | ||
| prefix_df: DataFrameT, | ||
| result_df: Optional[DataFrameT], | ||
| null_row: Dict[str, Any], # hygiene-ok: explicit-any -- heterogeneous Cypher fill values, mirrors _optional_reentry_carried_null_rows |
There was a problem hiding this comment.
Avoid Any when narrow available
|
|
||
| Disqualified shapes return None and stay on the (typed-decline) pipeline: | ||
| renames/DISTINCT/ORDER/SKIP/LIMIT on the stage, multiple stages, UNWIND/ | ||
| CALL, references to non-carried aliases after a subset carry. |
There was a problem hiding this comment.
More comment issues
…ping (#1897 review) Owner inline review flagged three sites (reentry/execution.py:145 comment, reentry/execution.py:189 `Any` + hygiene-ok pin, reentry/flatten.py:311 docstring). The review skill's self-review gate was then run over the whole PR diff and every hit given a verdict. Names and structure instead of prose: reentry/flatten.py flatten_terminal_with_over_optional keeps a two-line docstring; its 20-line narration is now the helper names _is_terminal_with_over_optional_match, _stage_reshapes_rows, _match_clause_aliases, _query_without_pure_carry_stage, _query_with_terminal_stage_folded_into_return, _stage_has_aggregates. _pure_carry_aliases_ignoring_where was a copy of _pure_carry_aliases minus one guard; both now share _bare_carry_aliases + _stage_reshapes_rows. reentry/execution.py the carried_output_map tri-state (None declines / {} nothing to do / map fills) is a frozen CarriedOutputSources plus CARRIED_OUTPUTS_NOT_REPRODUCIBLE; the "never synthesize anonymous null rows" comment is the helper name _optional_reentry_unmatched_identity_null_rows. gfql_unified.py _carried_output_sources / _carried_output_source_column / _output_reads_carried_alias replace the inline branch cascade and its trailing comments. cypher/lowering.py both added comment blocks and the post_join_row_filter docstring paragraph deleted; the parameter name states the rule. Typing (aliases byte-identical to the #1894 review so the branches merge cleanly): cypher/ast.py CypherScalar = Union[None, bool, int, float, str]; CypherLiteral re-expressed over it agg_types.py CypherEmptyGroupValue / CypherEmptyGroupFills execution.py CypherFillValue / CypherFillRow Both `# hygiene-ok: explicit-any` pins this PR had introduced are removed, not suppressed. Ratchets locked down: explicit-any reentry/execution.py 9 -> 0, gfql_unified.py 21 -> 20 (4496 -> 4486 total); cypher surface lowering_py_max_lines 9503 -> 9493. Pins added where a deleted comment was the only record of a contract: test_flatten_terminal_with.py::test_admitted_query_never_retains_a_with_stage_so_recompiling_it_terminates test_optional_reentry_carried_outputs.py (all seven source-column branches, including the reentry-marker branch that had only a trailing comment) test_optional_match_semantics.py::test_optional_match_anti_join_with_where_x_is_null_keeps_only_unmatched_rows Parametrized decline cases carry pytest ids instead of `#` labels. No behavior change: graphistry/tests/compute/gfql failure set byte-identical to the PR base (93 failed, md5 d68f3d7cddf5bab5d11588e04727d362), test_compute_chain/test_compute_hops identical (2 pre-existing cudf-lane failures). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
|
Review addressed, with the self-review gate run over the whole PR diff rather than just the flagged lines. The three sites
Self-review gate, whole diff18 sites given verdicts — including 12 Kept, with reasons: Pins added where a comment was the only recordDeleting a comment without an equivalent pin is information loss, so three went in:
GatesFailure list md5 Hygiene improved and ratcheted down, not held flat: One judgement call flagged for you: that surface ratchet tightens within the bump this PR itself made (9454 → 9503 → 9493) rather than leaving 10 lines of slack. |
| fill_rows = [dict(null_row) for _ in range(missing_count)] | ||
| elif not fill_rows: | ||
| if not carried_outputs.every_output_reproducible: | ||
| raise reentry_validation_error( |
There was a problem hiding this comment.
tck-gfql obligations likely, audit & handle as needed
| @@ -0,0 +1,150 @@ | |||
| """Direct tests for terminal-WITH-over-OPTIONAL flattening (#1896). | |||
There was a problem hiding this comment.
shouldn't these be more like cypher/reentry/test_flatten.py instead of toplevel?
| return replace(query, with_stages=()), stage.where | ||
|
|
||
|
|
||
| def _query_with_terminal_stage_folded_into_return( |
There was a problem hiding this comment.
seems significant positive/negative test obligations
|
|
||
| CypherLiteral = Union[None, bool, int, float, str, ParameterRef] | ||
| #: Every value openCypher can write literally: the four primitive types plus null. | ||
| CypherScalar = Union[None, bool, int, float, str] |
There was a problem hiding this comment.
is this from lower on our pr stack?
| ) | ||
| if compiled_connected_optional is not None: | ||
| return _attach_graph_context(compiled_connected_optional) | ||
| if query.with_stages and any(m.optional for m in query.matches): |
There was a problem hiding this comment.
more significant pos/neg test amplifications obligations
|
|
||
| # TYPE_CHECKING only: `gfql.cypher.__init__` eagerly imports the whole compiler, and | ||
| # this module is imported from inside that import (row.pipeline), so a runtime import | ||
| # would be circular. The aliases below only need the name at type-check time. |
There was a problem hiding this comment.
avoid unnecessary and overly verbote comments... root cause why this happened, and both fix here, but all the root cause of why authoring & reviewing let this through
|
|
||
| def _optional_reentry_aggregate_fill_values(compiled_query: CompiledCypherQuery) -> Dict[str, Any]: | ||
| _IDENTIFIER_TOKEN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") | ||
| _REENTRY_MARKER_COLUMN = re.compile(r"__cypher_reentry_(\w+)__") |
There was a problem hiding this comment.
strange location for these symbols, shouldn't they be externed for reuse by others?
| return None | ||
|
|
||
|
|
||
| def _carried_output_sources(compiled_query: CompiledCypherQuery) -> CarriedOutputSources: |
There was a problem hiding this comment.
gfql_unified.py is already overly large..
| ops = list(compiled_query.chain.chain) if compiled_query.chain is not None else [] | ||
| for op in ops: | ||
| function = getattr(op, "function", None) | ||
| op_params = getattr(op, "params", None) or {} |
There was a problem hiding this comment.
try to replace dynamic typing pattersn like getattr with more structured & static programming
| return None | ||
|
|
||
|
|
||
| def _carried_output_sources(compiled_query: CompiledCypherQuery) -> CarriedOutputSources: |
There was a problem hiding this comment.
test amplification concerns?
|
Answering the factual question first, then the rest is dispatched. "is this from lower on our pr stack?" ( "gfql_unified.py is already overly large" — confirmed and now tracked. It is 2,526 lines; Dispatched for fixing, grouped:
Also worth flagging from your earlier note, since it applies here: the surface-guard ratchet on this PR tightened within its own bump (9454 → 9503 → 9493), leaving no slack. Say the word if you would rather it left headroom. |
| """Prefix-frame column behind each result output that reads the carried alias.""" | ||
|
|
||
| columns: Mapping[str, str] | ||
| every_output_reproducible: bool |
There was a problem hiding this comment.
are any of these exernally needed types?
| _IDENT_TOKEN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") | ||
| _AGGREGATE_CALL = re.compile( | ||
| r"\b(count|sum|avg|min|max|collect|stdev|percentile\w*)\s*\(", re.IGNORECASE | ||
| ) |
There was a problem hiding this comment.
should these be externed and/or swapped to predicates?
| #: What an aggregate answers for an EMPTY group, per the Cypher contract documented above: | ||
| #: ``count``/``sum`` -> ``0``, ``count(*)`` over a synthesized null-extended row -> ``1``, | ||
| #: ``collect`` -> ``[]``. Every other aggregate's empty-group answer is ``null`` and is | ||
| #: therefore absent from a fill map rather than present as a value. |
Cascading base update after #1895 landed its review remediation. Resolutions: - graphistry/compute/gfql/agg_types.py: BOTH sides independently introduced an empty-group-aggregate contract (diff3 merge base empty). They are the SAME concept, so reconciled rather than unioned blindly: kept the base's shorter `#:` line for CypherEmptyGroupValue / CypherEmptyGroupFills and the base's CYPHER_ZERO_EMPTY_GROUP_AGGREGATIONS / CYPHER_EMPTY_LIST_EMPTY_GROUP_AGGREGATIONS frozensets, which the (cleanly merged) body of _optional_reentry_aggregate_fill_values already reads. Dropped this branch's 3-line TYPE_CHECKING circular-import narration; the import position already states it. - graphistry/compute/gfql_unified.py: import block took the base (superset: the two frozensets plus CypherParams, both used in the merged file). The _optional_reentry_aggregate_fill_values hunk is NOT a rival implementation -- the base only renamed its return type Dict[str, Any] -> Dict[str, CypherEmptyGroupValue], which this branch did too. Kept the base's signature line and this branch's genuinely-new carried-output helpers above it (_IDENTIFIER_TOKEN, _REENTRY_MARKER_COLUMN, _output_reads_carried_alias, _carried_output_source_column, _carried_output_sources), which are a different feature (#1896 carried-output reproducibility) that only shares the file. - graphistry/compute/gfql/cypher/reentry/execution.py: unioned the imports (this branch's CypherEmptyGroupValue/CypherScalar for CypherFillValue, the base's REENTRY_HIDDEN_COLUMN_PREFIX used at lines 572/576). Kept this branch's carried_outputs parameter on apply_optional_reentry_null_fill. Kept the base's deletion of the empty_result_row prose comment while keeping this branch's `null_row: CypherFillRow` annotation. - bin/ci_type_hygiene_baseline.json: took the base's numbers, then --update-baseline; reentry/execution.py now has zero findings and its entry is removed rather than left at 8. Gates: no conflict markers, ruff clean, type-hygiene guard clean, cypher surface guard pass, mypy shows only the 4 known polars-skew errors. graphistry/tests/compute/gfql = 8595 passed / 93 failed, and the same 93 fail on ghhttps/fix/gfql-1888-endpoint-closure alone (local cudf + polars version skew), so the merge adds no failures. Touched tests (test_flatten_terminal_with, test_optional_reentry_carried_outputs, test_optional_match_semantics, plus agg/flatten neighbours): 431 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
Cascading base update after #1897 took its review remediation. Resolutions: - graphistry/compute/gfql/cypher/reentry/flatten.py: the base refactored flatten_terminal_with_over_optional into named helpers and DELETED _pure_carry_aliases_ignoring_where (verified: absent from ghhttps/fix/gfql-1896-om-with-pipeline:flatten.py, its only caller replaced by _is_terminal_with_over_optional_match / _stage_reshapes_rows / _bare_carry_aliases). Took the base's deletion plus its new _stage_has_aggregates, which the cleanly-merged _query_with_terminal_stage_folded_into_return already calls. Kept this branch's genuinely-new flatten_pure_carry_terminal_with_nonoptional (#1899), which reads _pure_carry_aliases, not the deleted helper. `git grep _pure_carry_aliases_ignoring_where` is now empty. - bin/test-polars.sh: kept BOTH lane entries -- test_row_multiplicity_semantics.py (this branch) and test_hop_boundary_matrix.py (base) are different files, so picking a side would silently drop a polars lane. - bin/ci_cypher_surface_guard_baseline.json: took the base's 9493, then --write-baseline. lowering.py is 9635 lines after the merge, which is the union of both sides' additions and BELOW this branch's own previous cap of 9674; the ratchet is tightened, not loosened. Gates: no conflict markers, ruff clean, type-hygiene guard clean, cypher surface guard pass, mypy shows only the 4 known polars-skew errors. test_row_multiplicity_semantics + test_fast_path_engagement + tests/compute/gfql/cypher + test_optional_match_semantics = 3671 passed, 8 failed -- all 8 are [cudf] parametrizations that fail identically on the base branch alone (no GPU in this environment). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
#1897 went CONFLICTING when #1895's remediation landed, so GitHub could build no merge ref and stopped creating workflow runs entirely. Both conflicts were "each side appended a different new section", merge base empty on both: - test_optional_match_semantics.py: #1895 added section E (optional-reentry aggregate fill values, #1891, compile-level so the end-to-end sweep cannot discharge it vacuously via a decline); #1897 added section F (#1896 OM -> WITH pipeline row semantics). Different features, so BOTH kept. - ci_cypher_surface_guard_baseline.json: `lowering_py_max_lines` was 9493 ours / 9425 theirs. Neither is right for the merged tree -- taking either side would have left the ratchet loose or falsely tight. Set to the MEASURED count of the merged file, 9464. 90 passed in test_optional_match_semantics.py; cypher surface guard passes; ruff clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
…ith-pipeline # Conflicts: # graphistry/compute/gfql/lazy/engine/polars/chain.py
… into fix/gfql-1896-om-with-pipeline
… mirror reentry tests Externs the optional-reentry carried-output symbols out of the oversized gfql_unified.py into graphistry/compute/gfql/cypher/reentry/carried_outputs.py, replaces the getattr AST probes with isinstance dispatch on ASTCall, and shares one identifier predicate across flatten/carry/carried_outputs via graphistry.compute.gfql.identifiers. Tests pinning cypher/reentry/* now mirror the source tree under graphistry/tests/compute/gfql/cypher/reentry/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
#1901 went CONFLICTING when #1897's merge landed, so GitHub could build no merge ref and created no workflow runs at all. One conflict, in ci_cypher_surface_guard_baseline.json: `lowering_py_max_lines` was 9635 ours / 9464 theirs / 9493 at the merge base. Neither side's number describes the merged tree, so it is set to the MEASURED count of the merged lowering.py, 9635. The cypher-surface guard passes at that value. gfql_fast_paths.py auto-merged. ruff clean; cypher suite 3517 passed with 7 failures, all `[cudf]` lanes already present in the recorded baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
|
Two things I promised in the dispatch comment above were not actually delivered at 1. The test relocation was one level shortYou asked for
80 before, 80 after (23/26/24/7 each side, checked per file, not just the total). The fourth one is the interesting case, and it is why I checked what each file pins instead of moving on filename: No References swept (
2. The comment-density guard: it did fail, and the fix was not a capYou were right that this was live, not hypothetical. Merging
No baseline cap was raised. The only baseline edit is a tightening: 3. Merge resolutionOne conflict, in
4. Per-thread disposition (all 16)
Also added the missing Gates
Failure lists captured whole ( Pushed as |
`test-gfql-core (3.12)` was the only red lane on #1897, and no test failed in it -- 4756 passed. The coverage audit rejected the run: graphistry/compute/gfql/cypher/reentry/carried_outputs.py: actual=94.17% floor=0.00% reason=resolved target file is missing from the baseline That file is new: externing the carried-output resolution out of gfql_unified.py (review threads #10/#11) created it, and a newly added source file has to be registered or the audit fails closed. Fixing the omission, not the gate. Floors follow each lane's measured reality rather than one invented number: pandas gets the 94.17% CI measured, and both rapids lanes get 0.0, matching every sibling in `cypher/reentry/` (carry, compiletime, flatten, lowering_support, rewrite all sit at 0.0 there -- the GPU lane does not exercise them). No floor is asserted that was not measured on that lane. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
Stacked on #1895. Fixes all four silent-wrong shapes from the #1896 re-probe umbrella:
flatten_terminal_with_over_optionalroutes pure bare-alias carries onto the connected left-join lowering with the stage WHERE as a post-join binding-row filter — exact openCypher row semantics, which also makes the canonicalWITH p, x WHERE x IS NULLanti-join answer correctly on both engines.3+4. Anonymous null fill —
apply_optional_reentry_null_fillcount-arithmetic replaced by identity-carrying anti-join of unmatched prefix rows (rename + LIMIT carries fixed); no-identity shapes decline typed, never anonymous rows.21 new green pins (section F, hand-computed oracles, both engines) + 2 decline-shape negative controls. Gates: OM 81P, divergences 11P/3xf, hop 42P, flatten 25P, lowering 1420P (0 new failures), polars 587P, TCK 4145P/0F, ruff+guards green (surface baseline regen for intentional lowering growth 9454→9503).
🤖 Generated with Claude Code
https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi