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
…ting The owner rejected the same classes across three consecutive PRs (graphistry#1894, graphistry#1895, graphistry#1897): mechanism narration where a name/test/structure should carry the meaning, perf and asymptotics claims that belong in pyg-bench, and Any/type-ignore/hygiene-pin instead of a narrow type. Their diagnosis was that this skill is incomplete or unused; both were true, and 'unused' was the larger half -- it is written for reviewing others' PRs and was never run against our own diffs across ~14 authored PRs. Adds three sections: an Encoding table (narration, why-a-fix-was-made, perf/asymptotics, engine-mechanics prose, restating the signature), a Typing table (Any with a known domain, new type: ignore, hygiene-ok on new code, cast() to satisfy the checker, and why 'no growth' is not sufficient), and a Self-review gate with the greps to run over <base>..HEAD before pushing -- plus the rule that fix-cycle briefs must carry these rules inline, since a brief that gates correctness and mentions style in passing produces code that satisfies what was measured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
The "Encoding: names, tests, and structure -- not prose" rules in agents/skills/review/SKILL.md were the only rule class on the 2026-08 GFQL stack with no automated enforcement, which is exactly why they were the only class that kept reaching the owner: rejected on graphistry#1894, graphistry#1895 and graphistry#1897 for the same reason each time. Everything else that mattered already had a gate -- bin/ci_type_hygiene_guard.py (AST, per-file baseline), bin/ci_cypher_surface_guard.py (line-count ratchet), the per-file coverage floors, test_polars_lane_completeness.py. Comment discipline was left to human review. This converts it into a gate. bin/ci_comment_density_guard.py is a stdlib-only tokenize + ast pass over graphistry/, modelled on the type-hygiene guard: same per-file count ratchet, same --report/--list/--update-baseline/--strict surface, same exit codes, and a `# guard-ok: <check> -- <reason>` escape hatch. It runs from bin/lint.sh, so it shares the python-lint-types matrix (py3.8-3.14) with no new workflow and no new job; that lane fires on any *.py change. Checks: comment-block 2+ adjacent full-line `#` comments (3+ for a Sphinx `#:` run, which was being used as a prose loophole) perf-claim complexity notation or performance vocabulary issue-rationale a standalone comment or docstring citing an issue number as the explanation comment-block is a form rule and reads `#` comments only; the two content rules also read docstrings, since a claim does not become admissible by moving into one. Tests are exempt from comment-block and issue-rationale -- a test may explain its oracle -- but not from perf-claim: measurement belongs in pyg-bench wherever it is written. Master's grandfathered debt: 948 comment-block, 216 perf-claim, 169 issue-rationale. Acceptance was measured, not asserted. Replayed against the three rejected branches at the commits the owner reviewed, the guard flags all 16 sites he flagged by hand, and each of the 9 touched files exceeds its master cap, so the ratchet would have failed all three PRs. Two false-positive classes were measured and tuned out rather than left as noise: `regress`/`A/B` also name correctness concepts, so they count only next to performance vocabulary; and a comment naming pyg-bench is a pointer to where measurement lives, not a claim. Together those were 128 of 344 raw perf-claim hits. 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