Skip to content

refactor(gfql/cypher): switch to Earley parser; retire LALR pattern-shape cruft (#1031 slice 1) - #1217

Merged
lmeyerov merged 16 commits into
masterfrom
issue-1031-grammar-mixed-where-pattern-expr
Apr 27, 2026
Merged

lmeyerov merged 16 commits into
masterfrom
issue-1031-grammar-mixed-where-pattern-expr

Conversation

@lmeyerov

Copy link
Copy Markdown
Contributor

Summary

Slice 1 of N for issue #1031. Per tech-lead direction (1-day timebox) following an LALR(1) state-collapse on ` AND ` shapes that could not be resolved by grammar restructure within Lark's LALR(1) constraints.

Earley parser swap eliminates four LALR-induced workarounds:

  1. The 3 dedicated pattern-shape `where_clause` grammar alternatives.
  2. `_canonicalize_where_single_pattern_and_expr` regex source-rewrite that reordered `expr AND pattern AND expr` → `pattern AND `.
  3. `_mixed_where_pattern_expr_error` pre-flight rejector.
  4. The `except LarkError` retry block in `parse_cypher`.

Net diff: +341 / -186 across 7 files.

Two commits, logically separate

  1. `fix(predicates): allow raw strings in NE/GT/LT/GE/LE` — extends EQ's pre-existing string acceptance to its sibling comparison ops. Latent bug: LALR's routing meant string comparisons mixed with labels never reached structured comparison predicates. Earley unifies the route, surfacing the strictness on idiomatic Cypher patterns. ~50 LOC self-contained in `comparison.py`.
  2. `refactor(gfql/cypher): switch to Earley parser; retire LALR pattern-shape cruft` — the main change. Grammar restructure + Earley swap + pre-flight retirement + lift step + AST extension.

Strict-improvement consequences (Earley accepts what LALR rejected)

Query shape Pre-#1031 Post-#1031
`WHERE expr OR expr` E107 syntax error parses as `or` tree
`WHERE expr AND (expr OR expr)` (Shape B) E107 parses as `and(left, or(...))`
`WHERE n:Label AND n.prop = X` (Shape F) raw expr text structured `where_predicates`
`WHERE prop OP 'string'` mixed with label ran via raw text path runs via structured ops (now safe via comparison.py fix)

What this PR adds

  • `BooleanExpr.op` literal extended with `"pattern"`; new `BooleanExpr.pattern` field.
  • Parser `pattern_atom` transformer wrapping `WHERE_PATTERN` as a tree leaf.
  • `_split_top_level_and_pattern_leaves` + `_rebuild_and_tree` + `_build_where_with_pattern_lift` helpers that extract pattern leaves from `expr_tree` into `WhereClause.predicates` as `WherePatternPredicate` entries before lowering.
  • Parser swap: `Lark(parser="earley")` for both `_parser` and `_pattern_parser`.
  • `_StringAllowingComparisonMixin` applied to NE/GT/LT/GE/LE (separate commit).

What this PR retires

  • 3 grammar alternatives, 3 transformer methods, `_mixed_where_clause`, 2 pre-flight functions, 3 derived regex constants, 3 helper utilities, the `parse_cypher` retry block.

#1031 progress

  • ✅ Retires `_canonicalize_where_single_pattern_and_expr`.
  • ✅ Removes one of three `split_top_level_and` callers (parser side). Remaining: `predicate_pushdown._split_conjuncts` (retired under gfql/ir: expose Lark and_op/or_op tree in WhereClause so passes walk structure instead of re-parsing text #1200). Together: `expr_split.py` deletion is within reach.
  • ⏭️ `NOT pattern` lowering (anti-semi-join) — slice 2. Emits explicit `unsupported` error today.
  • ⏭️ Multi-positive patterns — slice 3. Rejected at lift step.
  • ⏭️ `OR` / `XOR` around patterns — slice 4. Rejected at lift step.

Test plan

  • `pytest graphistry/tests/compute/gfql/` — 1539 pass / 80 skip / 15 xfail / 0 fail.
  • 10 tests updated to match Earley's strict-improvement routing (Categories A, B, C, D documented in `plans/issue-1031-grammar-mixed-where-pattern-expr/plan.md`).
  • gfql-benchmarks (per tech-lead's <2× parse-delta bound) — to run on CI.

Notes for review

  • The plan file (`plans/issue-1031-...`) has the full LALR investigation, planning-review (12 deltas), and tech-lead correspondence baked in.
  • Earley introduces a new failure mode (grammar ambiguity). Lark's default `ambiguity="resolve"` picks the highest-priority parse. Conformance + benchmark suites are the validation oracle.

🤖 Generated with Claude Code

lmeyerov and others added 4 commits April 25, 2026 17:40
…cates

EQ already accepts raw strings via inline ``_normalize_value`` +
``_validate_fields`` overrides.  The other comparison ops lacked the
same overrides and rejected raw strings unconditionally — their
``__call__`` methods also explicitly raised ``TypeError`` on strings.

This was latent: LALR couldn't unify label-predicate and
property-predicate alternatives in Cypher's ``where_predicates``, so
queries like ``WHERE n.name <> 'value' AND n:Label`` routed through
raw expr text and never reached ``ComparisonPredicate.__call__``.

#1031's Earley swap unifies the route, surfacing the strictness on
idiomatic Cypher patterns.  Pandas Series supports lexicographic
``>``/``<``/``!=`` on strings natively, so the fix is to extend EQ's
pre-existing string acceptance to siblings.

Implementation:
- Extract ``_StringAllowingComparisonMixin`` with the
  ``_normalize_value`` + ``_validate_fields`` overrides.
- Apply mixin to NE/GT/LT/GE/LE.
- Extend each ``__call__`` to handle ``isinstance(self.val, str)``.

The strict raw-string rejection on the base ``ComparisonPredicate``
stays — it still applies to ``Between`` (and any future direct-IR
constructors) where the datetime-vs-string ambiguity matters for
direct-IR users.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…hape cruft (#1031 slice 1)

Per tech-lead direction (1-day timebox) following an LALR(1) state-collapse
on ``<comparable> AND <pattern>`` shapes that could not be resolved by
grammar restructure.

Earley parser swap eliminates four LALR-induced workarounds:

1. The 3 dedicated pattern-shape ``where_clause`` grammar alternatives
   (``where_pattern_and_expr_clause`` / ``expr_and_where_pattern_clause``
   / ``where_pattern_only_clause``) — collapse to the unified ``expr``
   route via a new ``WHERE_PATTERN -> pattern_atom`` leaf in ``?primary``.
2. ``_canonicalize_where_single_pattern_and_expr`` — regex source-rewrite
   that reordered ``expr AND pattern AND expr`` to ``pattern AND <rest>``
   so the LALR grammar could match.  Earley accepts patterns at any
   AND-spine position natively.
3. ``_mixed_where_pattern_expr_error`` — pre-flight rejector for shapes
   LALR could parse but the IR couldn't represent.  Replaced with a
   structural lift step that inspects the parsed boolean tree.
4. The ``except LarkError`` retry block in ``parse_cypher`` that fed
   the canonicalize-then-reparse path.

What this PR adds:

- ``BooleanExpr.op`` literal extended with ``"pattern"``; new
  ``BooleanExpr.pattern: Optional[Tuple[PatternElement, ...]]`` field.
- Parser ``pattern_atom`` transformer: wraps ``WHERE_PATTERN`` token as
  ``BooleanExpr(op="pattern", pattern=parsed)``; reuses the existing
  ``_parse_where_pattern_predicate_text`` helper.
- ``_split_top_level_and_pattern_leaves`` walker + ``_rebuild_and_tree``
  helper + ``_build_where_with_pattern_lift`` that extracts pattern
  leaves from ``expr_tree`` into ``WhereClause.predicates`` as
  ``WherePatternPredicate`` entries before lowering.
- Parser swap: ``Lark(parser="earley")`` for both ``_parser`` and
  ``_pattern_parser``.

What this PR retires:

- 3 grammar alternatives, 3 transformer methods, 1 helper
  (``_mixed_where_clause``), 2 pre-flight functions, 3 derived regex
  constants (``_WHERE_PATTERN_SEQUENCE_RE``, ``_WHERE_PATTERN_THEN_EXPR_RE``,
  ``_WHERE_EXPR_THEN_PATTERN_RE``), 2 helper utilities
  (``_WHERE_CLAUSE_BODY_RE``, ``_BOOLEAN_KEYWORD_RE``,
  ``_line_and_column_from_offset``), and the ``parse_cypher``
  ``except LarkError`` retry block.

Net diff: +204 / -144.

Strict-improvement consequences (Earley accepts what LALR rejected):

- ``WHERE expr OR expr`` now parses as a structured ``or`` tree.
- ``WHERE expr AND (expr OR expr)`` (Shape B) now parses as
  ``and(left, or(...))``.
- ``WHERE n:Label AND n.prop = X`` now routes through structured
  ``where_predicates`` (was raw expr); label narrowing applies.
- ``WHERE prop OP 'string'`` mixed with label predicates now hits
  ``ComparisonPredicate`` for strings — separately fixed in the
  preceding commit by extending EQ's string-accepting overrides
  to NE/GT/LT/GE/LE.

#1031 milestones in this PR:

- ✅ Retires ``_canonicalize_where_single_pattern_and_expr``.
- ✅ Removes one of three ``split_top_level_and`` callers (parser side).
  Remaining caller: ``predicate_pushdown._split_conjuncts``, retired
  separately under #1200.  Together this brings ``expr_split.py``
  deletion within reach.
- ⏭️  ``NOT pattern`` lowering (anti-semi-join) — slice 2 territory;
  emits explicit ``unsupported`` error today.
- ⏭️  Multi-positive patterns — slice 3 territory; rejected at lift step.
- ⏭️  ``OR``/``XOR`` around patterns — slice 4 territory; rejected at lift step.

Tests updated:

- 7 tests adjusted to match Earley's strict-improvement routing
  (``test_or_where_not_yet_supported``, conformance Shape B parser +
  binder, conformance Shape F parser + binder, label-narrowing mixed
  case, logical planner WITH-stage attachment).
- ``test_parse_supports_where_pattern_predicate_and_expr_mix`` paren-
  preservation case updated to expect ``boolean_expr_to_text``-
  reconstructed paren-stripped form (semantically equivalent).

1539 / 1539 GFQL tests pass.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…#1217)

Pre-#1217: LALR rejected OR / NOT inside WHERE bodies, so disjunctive
and negation shapes had zero native end-to-end test coverage.  Earley
accepts them and the runtime evaluates via pandas, but until now the
correctness was implicit — only the (separate-repo) tck-gfql harness
exercised these paths via xfail-marked scenarios.

Adds 5 native execution tests in test_lowering.py:

- ``test_string_cypher_executes_disjunctive_property_predicate_returns_union``
  mirrors TCK match-where1-10: ``WHERE n.p1 = 12 OR n.p2 = 13`` returns
  the union of matches (a, b), not just one.
- ``test_string_cypher_executes_disjunctive_same_alias_property_predicate``
  ``WHERE n.p1 = 12 OR n.p1 = 99`` — same alias on both sides.
- ``test_string_cypher_executes_negation_property_predicate_returns_complement``
  ``WHERE NOT n.p1 = 12`` — locks pandas's NaN-NOT-NaN semantics
  (NaN is falsy under NOT, so rows with null p1 are excluded).
- ``test_string_cypher_executes_disjunctive_then_conjunction``
  ``WHERE (n.p1 = 12 OR n.p2 = 13) AND n.id = 'a'`` — mixed OR-AND
  tree narrowing.
- ``test_string_cypher_executes_disjunction_returns_correct_count_with_more_rows``
  larger fixture (6 rows) confirming OR doesn't silently union too many
  or too few rows.

Pairs with the tck-gfql sibling branch ``issue-1031-grammar-mixed-where-pattern-expr``
which registers ``match-where1-10`` as ``success_matches_expected`` in
the xfail-contract (kept as ``xfail`` rather than promoted to
``supported`` until broader OR-disjunction conformance is validated).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
@lmeyerov

Copy link
Copy Markdown
Contributor Author

Filed follow-up: #1219 — "Cypher WHERE: static validation gap on row-boolean shapes (OR/NOT/XOR among row predicates) — emergent from #1217 Earley swap". The validator question is genuinely out of scope for #1031 (which is specifically about mixing pattern + row predicates). Earley's broader unification incidentally lifts the implicit LALR rejection on row-side OR/NOT/XOR, exposing a missing semantic gate that pre-#1217 wasn't needed. Decision space (validator vs partial-accept vs full-disjunction-support vs defer-with-docs) is left open in #1219.

lmeyerov and others added 2 commits April 25, 2026 19:43
IMPORTANT findings from multi-dimension review:

1. Dead-code in OR test (test_lowering.py:3199-3205) — the
   ``if False else`` made the first comprehension permanently dead;
   the fallback branch then asserted on either of two contradictory
   shapes.  Replaced with a single deterministic assertion: 5 rows
   expected, every kept row carries either ``p1: 1`` or ``p2: 2`` in
   its rendered form.
2. EQ duplicated the new mixin's overrides (comparison.py:245-263).
   The mixin docstring already invited the retrofit ("EQ predates this
   mixin with an inline override").  EQ now uses
   ``_StringAllowingComparisonMixin`` for its single-source contract.

SUGGESTIONS deferred (documented in plan):
- NaN-NOT semantics test name (locks pandas-not-Cypher behavior;
  acknowledged in test docstring + #1219 link).
- ``_rebuild_and_tree`` invents source-spans across non-adjacent
  residuals (cosmetic, error-reporting only).
- No deeply-nested pattern-under-multi-level-boolean test
  (``_has_pattern_descendant`` handles correctly by inspection).
- PEP-8 spacing nit after constant deletes.
- Defensive ``_split_top_level_and_pattern_leaves`` bad-flag drop on
  malformed AND nodes (#1214 invariant prevents the case).

1680 GFQL + predicates tests pass.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
lmeyerov and others added 10 commits April 25, 2026 22:00
…mixed-where-pattern-expr

# Conflicts:
#	CHANGELOG.md
#	graphistry/compute/gfql/cypher/parser.py
#	graphistry/tests/compute/gfql/cypher/test_where_bool_conformance.py
…der Earley ambiguity (#1031)

Earley with ambiguous grammar parsed `MATCH ... WITH ... WHERE ... RETURN`
two ways: (a) `with_stage[with_clause + with_where_clause]` (correct), or
(b) `stage[with_clause] + standalone where_clause` (wrong — query_body
then attached the where to the preceding MATCH at parser.py:1582-1587).

Earley's default ambiguity resolution silently picked the wrong parse:
the predicate ended up populated on both matches[0].where and top-level
where, with with_stages[0].where = None.  Surfaced by Wave 2 fresh-eyes
review of #1217 — the renamed/loosened
test_logical_planner_applies_predicates_attached_to_with_stage_filter
masked the regression.

Fix: bump Lark rule priority on with_where_clause so Earley prefers it
during ambiguity resolution.  Smoke-trace post-fix:
  match.where=0 preds, top.where=0 preds, with[0].where=ExpressionText(...)

Validated on dgx-spark:
- graphistry/tests/compute/gfql/cypher/  → 1038 passed
- graphistry/tests/compute/gfql/         → 1543 passed
- graphistry/tests/compute/              → 2441 passed

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…ring mixin

Two Wave-2 review fixes:

1. test_logical_planner_applies_predicates_attached_to_with_part —
   restored original test name + WITH-part predicate assertion. The
   previous rename (..._to_with_stage_filter) and "predicate anywhere
   in plan" loosening had MASKED the WITH-WHERE re-routing regression
   that the previous commit (parser priority bump) fixed.  Now the
   binder correctly attaches the predicate to the WITH query_part, so
   the original assertion holds again.

2. New test_comparison_strings.py — direct unit tests for
   _StringAllowingComparisonMixin on EQ/NE/GT/LT/GE/LE.  Each op
   verified for: validate() acceptance of raw-string val, JSON
   round-trip preserving string val, lexicographic Series application
   matching pandas semantics.  Closes the wave-2 IMPORTANT gap that
   only end-to-end coverage existed for these ops.

Validated on dgx-spark: graphistry/tests/compute/ → 2458 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…p tests

DRY pass on #1217 surface area:
- parser.py: drop pre/post-#1031 narration on _split_top_level_and_pattern_leaves,
  _has_pattern_descendant, _rebuild_and_tree, _build_where_with_pattern_lift,
  pattern_atom, and the Earley parser comment.  Replace defensive
  'pattern_atom invariant' branch with an assert.
- test_lowering.py: trim 9-line OR/NOT section header + per-test docstrings
  to one structural line each; pandas NaN/Cypher semantics covered once
  at the section level.
- test_parser.py / test_where_bool_conformance.py: drop 'Pre-#1031 LALR /
  Post-#1031 Earley' history comments — assertions speak for themselves.
- test_comparison_strings.py: parametrize the per-op lex-apply tests via
  a callable→expected-list mapping; 5 near-identical functions → 1
  parametrized test.

Net -140 lines; no behavior change.  Validated on dgx-spark: 1078 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Adds 4 native end-to-end tests locking the Earley-admitted row-boolean
shapes that were previously deferred to #1219:

1. cross_alias_or_returns_correct_union — `WHERE a.x = 1 OR b.y = 2`
   against a fixture where wrong-rows would surface if the OR were
   silently split past a join.  Pushdown correctly leaves the OR intact;
   2 expected rows returned.
2. optional_match_or_with_existing_validator — `OPTIONAL MATCH ... WHERE
   ... OR b IS NULL` raises a `GFQLValidationError` from the existing
   OPTIONAL-MATCH-projection validator before WHERE evaluates.  Locks
   the gate fires regardless of OR shape.
3. mixed_type_or_via_ast_evaluator_gate — `WHERE n.var > 'te' OR n.var > 0`
   against a mixed-type Series.  The row-where AST evaluator emits an
   explicit `GFQLTypeError` rather than silently propagating pandas's
   raw TypeError.  Locks the loud-failure mode.
4. homogeneous_or_returns_correct_union — sanity check that simple
   homogeneous-type OR continues working post-#1217.

CHANGELOG entry rewritten: removes "complex compositions ... unverified"
language; replaces with empirical coverage summary.  #1219 still tracks
the deeper compositional frontier beyond current fixtures.

Validated on dgx-spark: 1547 passed (1543 + 4 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…onesty

Wave-3 review on commit aacb2a6 surfaced two IMPORTANTs:

1. Shape 2 test name + docstring overstated what it gates.  The
   OPTIONAL-MATCH-projection validator fires regardless of WHERE shape
   (or absence of WHERE).  Renamed to *_seed_only_projection,
   parametrized over `("", "WHERE b.x = 1", "WHERE b.x = 1 OR b IS NULL")`
   so the test now empirically *proves* OR-equivalence — the gate fires
   identically across all three WHERE shapes.

2. Shape 3 narrative implied a "type-mismatch gate"; in reality the call
   executor wraps any non-typed exception as GFQLTypeError(E303,
   "unsupported row expression").  Tightened pytest.raises to drop the
   fallback `TypeError` (unreachable empirically) and pin the substring
   "unsupported row expression" so a future regression that drops the
   wrapper actually fails the test.  CHANGELOG language softened
   accordingly: "the call executor wraps pandas's TypeError as
   GFQLTypeError(E303) via the generic unsupported-row-expression path".

Validated on dgx-spark: 1549 passed (1547 + 2 from shape-2 parametrize).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…ixture, CHANGELOG accuracy

Wave-4 (targeted-amplification + full-fresh-eyes) found 6 IMPORTANTs.
Addressed:

1. Shape-2 `match="OPTIONAL MATCH"` could match any of 6 distinct
   error sites — pinned to "seed alias are not yet supported" so the
   test cannot drift onto an unintended validator.

2. Shape-3 `match="unsupported row expression"` was an inner-ValueError
   substring with 30+ emit sites in the row pipeline — pinned to the
   stable error code "invalid-node-reference" (E303) instead, which
   is the actual contract the wrapper enforces.

3. Cross-alias-OR fixture (test_string_cypher_executes_cross_alias_or_*)
   used 1:1 join topology where multiple wrong-pushdown variants
   coincidentally produce the same answer.  Rebuilt with 2:2 join
   so wrong variants discriminate.  Expected rows changed from 2 to 3.

4. 5-row OR test (`test_string_cypher_executes_disjunction_returns_correct_count_with_more_rows`)
   used `len == 5` + `all(... in r ...)` — could pass on wrong row
   identities.  Tightened to sorted-id assertion.

5. CHANGELOG drift — claimed 1539 tests + only `match-where1-10`
   changed on tck-gfql side.  Rewrote to 1549 + the actual 8-refinement
   contract scope (TYPE_ERROR_KEYS, MATCHES_EXPECTED, WRONG_ROW_KEYS,
   PROMOTION demotion).

6. Coverage gap — `test_parse_rejects_mixed_where_pattern_predicates_as_unsupported`
   only covered variable-length patterns (`-[:R*]->`).  Added the
   simple non-variable-length variant so future slice 2/3/4 lifts
   can't silently regress the common case.

Validated on dgx-spark: 1551 passed (was 1549; +2 from non-VL params).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Wave-5 SUGGESTION: the +2 non-VL parametrize entries from 912a4ac
brought the count to 1551 but the CHANGELOG still said 1549.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@lmeyerov
lmeyerov merged commit b22ad74 into master Apr 27, 2026
93 checks passed
@lmeyerov
lmeyerov deleted the issue-1031-grammar-mixed-where-pattern-expr branch April 27, 2026 23:57
lmeyerov added a commit that referenced this pull request Apr 28, 2026
…ttern, terser comments

Wave-2 (2a targeted + 2b broad) found 1 IMPORTANT + several SUGGESTIONs.

IMPORTANT (Wave-2b):
- expr_split.split_top_level_and docstring overstated the topology
  argument.  Original draft claimed distribute-OR breaks on fan-out
  topologies; the cross-alias-OR test in test_lowering.py:3206-3211
  (added in #1217) explicitly confirms distribute-OR converges to
  correct row-set semantics on a 2:2 fan-out fixture.  The actual
  reason pushdown leaves OR opaque is that the multi-alias references
  on the conjunct cause it to be retained post-join.  Rewrote
  docstring to describe the real mechanism + cite the test.

SUGGESTIONs (Wave-2a + 2b):
- _ids_for(graph, query) helper inlined to match the established
  inline-sorted-comprehension style used elsewhere in test_lowering.py
  (12+ existing call sites).  Removes inconsistency within this PR.
- Pattern-op branch comment in _boolean_expr_text.py compressed from
  9 lines to 3 — the verbose explanation duplicated the test docstring.
- Local-variable assignment for graph.gfql() result kept (matches
  existing test patterns + works around a Plottable.gfql pyright
  attribute warning that fires on fluent chains).

Test counts unchanged: 8 new tests; full gfql suite 1581 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
lmeyerov added a commit that referenced this pull request Apr 28, 2026
…, mirrored-guard clarification

Wave-4 (4a targeted + 4b from-scratch) — 0 BLOCKER + 0 IMPORTANT, 9
SUGGESTIONs.  Picked up the cheapest valuable ones:

- Wave-4b S1: mixed-string-numeric AND-inside-OR test claimed to
  exercise _StringAllowingComparisonMixin but used `n.s = 'a'` (plain
  EQ, supported pre-#1217).  Swapped to `n.s > 'a'` (string GT, the
  mixin-specific path); flipped fixture s-values to keep the same
  truth-table outcomes.

- Wave-4b S3: added test_string_cypher_executes_xor_with_null_uses_three_valued_logic
  — sibling to the OR/NOT 3VL test, locks NULL XOR T = NULL.  Reuses
  the 3VL fixture from the De Morgan tests.

- Wave-4a S1: `_split_conjuncts` mirrored guard now names the actual
  failure mode (`_combine_conjuncts` AND-joins residuals) before
  pointing to the fuller rationale in expr_split.

Skipped (out of scope or duplicate):
- Wave-4b S2: rightmost-only discriminator comment is already honest;
  test verified correct.
- Wave-4b S4: cross-alias OR / cross-product fixture — already covered
  by test_string_cypher_executes_cross_alias_or_returns_correct_union (#1217).
- Wave-4b S6: optional CHANGELOG bullet for test+docs PR.
- Wave-4a S2 (OR-analyzer in pushdown_safety.py:58-60): pre-existing
  unrelated, separate followup.

Test counts: 10 new tests; full gfql suite green (1583 passed).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
lmeyerov added a commit that referenced this pull request Apr 29, 2026
…ils (#1227)

* test+docs(#1219): residual row-boolean compositional matrix + guardrails

Worker B / independent-hardening stream of #1219.  After #1217's Earley
swap surfaced row-boolean shapes (OR/NOT/XOR among row predicates) that
LALR rejected, four compositional shapes remained unverified beyond the
fixtures #1217 covered.  This PR locks empirical correctness across the
residual matrix + adds two lightweight guardrail comments.

## Compositional matrix tests (test_lowering.py)

All four shapes verified correct empirically; locked with sorted-id
assertions against discriminating fixtures:

1. **Nullable NOT/OR** — `WHERE NOT n.x = 1 OR n.y IS NULL` against a
   4-row fixture mixing real and projected nulls.  Locks pandas-backed
   row-evaluator preserves Cypher 3VL truth table (NULL OR T = T):
   `{n2, n4}`.

2. **N-ary OR (3 branches)** — `WHERE n.x = 1 OR n.x = 2 OR n.x = 3`.
   Locks left-associative parse `or(or(=1, =2), =3)` doesn't degenerate
   under associativity bugs.  `{n1, n2, n3}`.

3. **De Morgan compositions** (parametrized × 4) — both `NOT(A OR B)`
   ≡ `NOT-A AND NOT-B` and `NOT(A AND B)` ≡ `NOT-A OR NOT-B` against
   a 4-row fixture covering all (x∈{1,2}, y∈{2,3}) combos.  Each form
   and its De-Morganed equivalent return the same row set.

4. **Mixed-string-numeric AND inside OR** — `WHERE (n.s = 'a' AND
   n.x > 0) OR n.x < -1`, exercising `_StringAllowingComparisonMixin`
   (#1217) paired with OR composition.  `{n1, n2, n4}`.

## Guardrails

- `expr_split.py::split_top_level_and` — added load-bearing AND-only
  docstring with #1219 cross-ref explaining why a sibling
  `split_top_level_or` would break OR-distributivity-over-join
  correctness.  No future maintainer should accidentally add it
  without first redesigning topology-aware pushdown safety.

- `_boolean_expr_text.py::boolean_expr_to_text` — added explicit
  `if expr.op == "pattern"` branch with docstring.  Currently
  unreachable in production (lift step extracts pattern leaves before
  the binder walks the tree) but documents the contract: emit the raw
  pattern source rather than silently falling through to empty string.

## Test impact

Validated on dgx-spark: `graphistry/tests/compute/` → 2524 passed
(7 new tests; remaining delta from baseline absorbed by #1224's
unrelated additions).

Closes the residual frontier portion of #1219.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* test+docs(#1219): wave-1 review fixes — discriminating fixtures, equivalence assertions, pattern-op unit test, mirrored guard

Wave-1 review on c196393 surfaced 3 IMPORTANTs + 5 SUGGESTIONs.
Addressed:

1. N-ary OR test now has a companion (duplicate-leftmost-branch) that
   isolates the rightmost-drop associativity bug from the any-branch-
   drop case.  Comment rephrased to be honest about what the original
   test covers (any branch dropped, not specifically rightmost).

2. De Morgan parametrize restructured: paired (compound, distributed,
   expected) tuples instead of independent rows.  Now asserts:
   - compound matches expected
   - distributed matches expected
   - compound == distributed (the actual De Morgan equivalence)
   Added separate double-negation test (NOT NOT A ≡ A).

3. New test_boolean_expr_to_text_emits_atom_text_for_pattern_op in
   test_boolean_expr.py exercises the explicit pattern branch added
   in c196393.  Locks the contract even though the branch is currently
   unreachable in production (lift step extracts pattern leaves before
   the binder walks the tree).

4. Mirrored AND-only guard comment near _split_conjuncts in
   predicate_pushdown.py — that's where future maintainers actually
   look when adding pushdown features; the load-bearing rationale
   stays in expr_split.py's docstring.

Test counts on dgx-spark: 2525 passed (was 2524 + 1 pattern_op unit
test; net 8 new tests in PR diff vs master).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* test+docs(#1219): wave-2 review fixes — accurate docstring, inline pattern, terser comments

Wave-2 (2a targeted + 2b broad) found 1 IMPORTANT + several SUGGESTIONs.

IMPORTANT (Wave-2b):
- expr_split.split_top_level_and docstring overstated the topology
  argument.  Original draft claimed distribute-OR breaks on fan-out
  topologies; the cross-alias-OR test in test_lowering.py:3206-3211
  (added in #1217) explicitly confirms distribute-OR converges to
  correct row-set semantics on a 2:2 fan-out fixture.  The actual
  reason pushdown leaves OR opaque is that the multi-alias references
  on the conjunct cause it to be retained post-join.  Rewrote
  docstring to describe the real mechanism + cite the test.

SUGGESTIONs (Wave-2a + 2b):
- _ids_for(graph, query) helper inlined to match the established
  inline-sorted-comprehension style used elsewhere in test_lowering.py
  (12+ existing call sites).  Removes inconsistency within this PR.
- Pattern-op branch comment in _boolean_expr_text.py compressed from
  9 lines to 3 — the verbose explanation duplicated the test docstring.
- Local-variable assignment for graph.gfql() result kept (matches
  existing test patterns + works around a Plottable.gfql pyright
  attribute warning that fires on fluent chains).

Test counts unchanged: 8 new tests; full gfql suite 1581 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* test+docs(#1219): wave-3b fixes — accurate split-OR rationale + XOR runtime sibling

Wave-3b from-scratch fresh-eyes review found 1 IMPORTANT + 3 SUGGESTIONs.
Addressed:

- IMPORTANT: split_top_level_and docstring's stated mechanism
  ('multi-alias refs cause retention post-join') was misleading.  Real
  mechanism: pushdown silently AND-recombines the pushed conjuncts
  inside PatternMatch.predicates.  AND distributes (split + AND-recombine
  ≡ original); OR does not (split + AND-recombine ≠ original).  An
  OR-aware split would need a UNION-of-pushed-branches recombine path
  the current pipeline doesn't implement.  Rewrote the docstring with
  the accurate split + AND-recombine rationale.

- SUGGESTION 3: XOR runtime row-set test (sibling to OR/AND/NOT tests
  this PR adds).  Locks symmetric-difference semantics.

Skipped:
- SUGGESTION 4 (NOT IS NULL standalone): nullable_not_or already
  exercises IS NULL composed with NOT/OR; standalone marginal.
- SUGGESTION 5 (cuDF parametrize): real scope creep, defer.

Test counts: 9 new tests; full gfql suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* test+docs(#1219): wave-4 polish — accurate mixin claim, XOR with NULL, mirrored-guard clarification

Wave-4 (4a targeted + 4b from-scratch) — 0 BLOCKER + 0 IMPORTANT, 9
SUGGESTIONs.  Picked up the cheapest valuable ones:

- Wave-4b S1: mixed-string-numeric AND-inside-OR test claimed to
  exercise _StringAllowingComparisonMixin but used `n.s = 'a'` (plain
  EQ, supported pre-#1217).  Swapped to `n.s > 'a'` (string GT, the
  mixin-specific path); flipped fixture s-values to keep the same
  truth-table outcomes.

- Wave-4b S3: added test_string_cypher_executes_xor_with_null_uses_three_valued_logic
  — sibling to the OR/NOT 3VL test, locks NULL XOR T = NULL.  Reuses
  the 3VL fixture from the De Morgan tests.

- Wave-4a S1: `_split_conjuncts` mirrored guard now names the actual
  failure mode (`_combine_conjuncts` AND-joins residuals) before
  pointing to the fuller rationale in expr_split.

Skipped (out of scope or duplicate):
- Wave-4b S2: rightmost-only discriminator comment is already honest;
  test verified correct.
- Wave-4b S4: cross-alias OR / cross-product fixture — already covered
  by test_string_cypher_executes_cross_alias_or_returns_correct_union (#1217).
- Wave-4b S6: optional CHANGELOG bullet for test+docs PR.
- Wave-4a S2 (OR-analyzer in pushdown_safety.py:58-60): pre-existing
  unrelated, separate followup.

Test counts: 10 new tests; full gfql suite green (1583 passed).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* test+docs(#1219): wave-5 polish — extract 3VL fixture helper, clarify pattern-op unreachability

Wave-5 formal review (multi-dim per .agents/skills/review/SKILL.md +
adversarial pressure-test) found 0 BLOCKER + 0 IMPORTANT, 3 SUGGESTIONs
of which 2 were actionable + cheap:

- Extract `_three_valued_logic_fixture_graph()` helper paralleling
  `_de_morgan_fixture_graph()` — eliminates byte-identical 4-row NaN-
  mixed fixture duplication between nullable_not_or and xor_with_null.
- Clarify pattern-op branch comment in `_boolean_expr_text.py` to name
  BOTH unreachability paths (top-level AND lift + nested-NOT/OR/XOR
  E108 rejection) instead of just the first.
- Adversarial-rejected: malformed-NOT-chain test would be a tautology
  (right-recursive grammar makes depth-N equivalent to depth-2; existing
  double-negation test already exercises the path).

Test counts unchanged: 11 new tests; full gfql suite 1583 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* docs(changelog): add #1227 row-boolean residual matrix entry

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant