Skip to content

feat(gfql): plan connected join predicate pushdown#1729

Draft
lmeyerov wants to merge 36 commits into
masterfrom
perf/gfql-olap-split-2-connected-plan
Draft

feat(gfql): plan connected join predicate pushdown#1729
lmeyerov wants to merge 36 commits into
masterfrom
perf/gfql-olap-split-2-connected-plan

Conversation

@lmeyerov

Copy link
Copy Markdown
Contributor

Replacement stack for #1714 (rung 2/4), stacked on rung 1.

Scope: connected multi-pattern logical planning, predicate pushdown, and property pruning.
Size: 2 files, +476/-18 (494 changed lines).
Validation: four focused planning tests pass; Ruff and diff gates pass.

@lmeyerov
lmeyerov changed the base branch from perf/gfql-olap-split-1-foundation to master July 16, 2026 15:37
lmeyerov and others added 27 commits July 16, 2026 10:22
…ry literal branch

The py3.12 per-file coverage baseline gate failed on lowering.py (88.93% vs
89.05% floor). Two changes address it:

Add a mixed connected-comma test exercising unary pushdown alongside an IN
residual, covering a real planner path that had no coverage.

Remove the UnaryOp branch of _connected_join_expr_literal_value. It is
unreachable: the grammar folds signed numeric literals, so `p.age >= -1` parses
to Literal(-1) and a surviving UnaryOp always wraps a non-literal operand
(`-x` -> operand=Identifier). Param substitution rewrites text before parsing,
so `-$minAge` folds the same way or fails closed on `--2`. The two callers both
consume _parse_row_expr output via BinaryOp.left/.right, and the UnaryOp import
stays live for six other uses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
The dead-code removal alone left lowering.py at 89.0022%, passing the per-file
floor only by exact equality with the 0.05 tolerance, and left changed-line
coverage at 76.06% on the gfql lane, under the 80% gate.

The connected-join planner pushes LabelRef predicates onto pattern targets, a
real branch of this PR's new code that had no coverage. Cover it with a labelled
T1 fixture and a label-plus-property query, asserting both the count semantics
and that label__Person and age reach the pushed filters.

lowering.py now clears its floor outright at 89.14% (no tolerance needed) and
changed-line coverage reaches 80.28% on the gfql lane alone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
Commit aa06536 deleted the UnaryOp branch of _connected_join_expr_literal_value
claiming it was unreachable. That was wrong. NUMBER carries its sign as a lexer
terminal (/[+-]?.../), so a signed literal only folds when the sign is lexically
adjacent: `-1` folds to Literal(-1), but `- 1` and `-(1)` keep the UnaryOp node
and reach the helper with an ExprLiteral operand. The earlier probe only tried
`-1` and `-x` and over-generalised from them.

Removing the branch silently stopped pushing those comparisons onto pattern
targets, degrading them to a where_rows residual. Results stayed correct, so no
test caught it -- an optimization regression, not a wrong answer.

Restore the branch and cover it with a parametrized `- 26` / `-(26)` / `+(26)`
test asserting the filter reaches the pushed targets. Removing the branch again
fails all three. Rename the mixed test, whose `-1` folds and never exercised a
unary node, to say what it actually covers.

lowering.py clears its per-file floor outright at 89.16% (3149/3532) with the
branch restored, so the coverage gate needed real tests, not the deletion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
Connected-join pushdown moved WHERE atoms onto pattern targets without checking
the op/literal pairing was representable there, regressing two shapes against
master:

  WHERE p.nick = $v  (v=None)   master []   -> HEAD n=4 (every row, unfiltered)
  WHERE p.nick <> 'a'           master n=2  -> HEAD GFQLTypeError

Null equality: the executor re-binds the pushed chain through ASTNode.to_json,
and _filter_dict_to_json drops null-valued entries, so the predicate silently
disappeared and the residual that would have applied it had already been
dropped. Non-numeric ordering: !=/</<=/>/>= lower to NumericASTPredicate, which
admits only int/float, so a string or bool literal raised where master answered
from a where_rows residual.

Gate pushdown on representability and fall back to the residual otherwise.
Parameters are resolved before the check -- $v arrives as an unresolved
ParameterRef, and judging the ref rather than its value is how the null case got
through. Unresolvable refs stay residual so the missing-parameter error still
surfaces from its normal path.

Pushdown is unaffected where it was already valid: numeric compares, string
equality, and unary literals still push with no residual.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
… dropping identity

Three more regressions this PR introduced against master, all found by independent
adversarial review and verified against master 770d2a5:

  p.age = $v {"v":{"type":"GT","val":26}}   master []  -> n=2 (ran `age > 26`)
  count(DISTINCT p), node id column != 'id' master 3   -> 1
  p.nick STARTS WITH 'a'                    pre-guard 1 -> error (my guard over-rejected)

Predicate injection: _filter_dict_to_json passes a raw dict through verbatim and
maybe_filter_dict_from_json revives it via predicates_from_json, so a caller-supplied
map param executed as an arbitrary predicate. Gate `==` to scalars.

count(DISTINCT): the count() early-return left a bare alias unrewritten. It never
checked FunctionCall.distinct, and a bare alias only resolves when the node id column
is named `id`/`p`; otherwise it degrades to a constant and collapses the count. Master
rewrites to the identity column, so drop the early-return and let it.

String predicates: contains/starts_with/ends_with/regex lower to real round-trippable
ASTPredicates and the residual cannot render them at all, so rejecting them turned
working queries into errors. Allow them to push.

Tests use a real Plottable, not _CypherTestGraph: the executor's serialization
round-trip is where the dropped-null and revived-predicate filters bite, and the test
double cannot observe it -- which is why the PR's own tests missed all of this.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
A pushed filter_dict is schema-validated against the real column, while the row
residual evaluates leniently (Cypher 3-valued logic yields no rows). Pushing a
type-incompatible atom therefore turned a correct empty result into a hard error:

  p.age = 'foo'   master []  -> GFQLSchemaError    (string literal, numeric column)
  p.nick >= 26    master []  -> GFQLTypeError      (numeric literal, string column)

No value-type rule can fix this, because safety depends on the column's dtype and
not the literal's. Compilation had no way to know: compile_cypher() takes no graph,
so `p.age = 'foo'` and `p.nick = 'a'` are indistinguishable at lowering.

Thread the node dtypes gfql() already has down to the pushdown decision, and admit
an atom only when the column accepts it (mirroring compute/validate_schema.py).
Without dtypes -- bare compile_cypher(), which never executes -- the value-type
decision stands, so translation-only callers are unaffected.

The planner now picks per atom: numeric compares, string equality, string
predicates, and unary literals push with no residual; dtype-incompatible atoms fall
back to the residual and answer correctly. Every probed shape now matches master,
except STARTS WITH/CONTAINS/=~, which this PR makes work where master errors.

Also set lowering_py_max_lines to the exact count rather than leaving headroom a
later rung could absorb silently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
Independent review of bfde19b found the gate mirrored the wrong file and left three
divergences from master:

  p.flag = 'yes'  (bool column)         master []          -> GFQLSchemaError
  p.flag = 99999999999999999999         master range error -> raw OverflowError
  p.age <> 99999999999999999999         master range error -> n=4

compute/validate_schema.py is dead code -- only a docs test imports it -- and it
disagrees with the validator that actually runs, compute/filter_by_dict.py, on bool:
the live one counts bool as numeric. The gate's `and not is_bool_dtype(...)` carve-out
therefore admitted a string against a bool column, straight into the error the gate
exists to prevent. Reuse the live validator's own _is_numeric_dtype_safe /
_is_string_dtype_safe so the two agree by construction and stay correct on non-pandas
dtypes.

The 64-bit literal guard lives on the row-expr path, so pushing an out-of-range int
evaded it and reached pandas. Reject those and let the residual report the range error.

_node_dtypes_for_pushdown used dtypes.items(), which only pandas and cuDF expose;
polars' .dtypes is a plain list, so it raised, was swallowed, and returned None --
silently reverting every polars-backed graph to the value-type-only decision. Zip
columns with dtypes, which is correct on all three.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
…olars

The dtype gate was inert on polars-typed nodes, so the exact bug it was added for came
back on them:

  polars nodes, WHERE p.age = 'foo'   master []  -> GFQLSchemaError

pd.api.types.is_numeric_dtype(pl.Int64()) returns False rather than raising, so
filter_by_dict's helpers never reach the kind/text fallback that exists to handle
non-pandas dtypes. Every polars column came back neither-numeric-nor-string, and
`isinstance(value, str) -> return not is_numeric_col` read that as pushable. The gate's
dtypes came from the polars frame while the validator saw the pandas-converted one.

Classify explicitly: use the helpers, and when both say False re-derive from kind/text.
Unrecognized dtypes now fail closed rather than being read as safe. Polars verdicts are
now identical to pandas, so ordering pushdown -- previously rejected outright on polars,
making the optimization dead there -- engages as intended.

Tests cover the fallback with synthetic dtype objects so the pandas lane exercises it
(the gfql lane has no polars and would skip), plus real polars cases via importorskip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
The dtype text fallback matched substrings, and container dtypes embed their element
type, so it read them as scalars:

  interval[int64, right] contains "int"  -> numeric
  List(Int64)            contains "int"  -> numeric
  struct                 contains "str"  -> string

An IntervalDtype node column then pushed a comparison and crashed where master answers:

  WHERE p.iv > 1    master []        -> raw builtins.ValueError
  WHERE p.iv <> 1   master n=8       -> GFQLTypeError

The underlying pandas ValueError is pre-existing -- g.chain() with GT on an interval
column raises identically on master -- but pushdown made it reachable from Cypher, where
master went through the residual and answered. It also leaked unwrapped, while master
wraps it as a GFQL error.

Test container tokens before the scalar ones so these fail closed. Exposure was low --
pd.cut yields category dtype, which already failed closed -- but the crash direction was
the gate's stated contract inverted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
A connected comma join with a string-valued inline property map and a pushable WHERE atom
on that same property hard-errored on a query master answers:

  MATCH (person:Person {id:'p1'})-[:L]->(city:Place),
        (friend:Person {nick:'aa'})-[:L]->(city)
  WHERE friend.nick = 'aa' RETURN count(friend) AS n
  master [{'n': 1}]  ->  GFQLTypeError: val must be numeric (int or float) | value: 'str'

Pushing merges with the inline value via _merge_filter_predicates, which wraps raw scalars
using comparison.eq. That serializes to {'type': 'EQ'}, but predicates/from_json.py binds
the EQ tag to predicates.numeric.EQ, which admits only int/float -- so the executor raises
when it rehydrates the string.

That write/read registry split is pre-existing and lives well outside this PR; master never
reached it because it never pushed connected-join WHERE atoms, leaving inline values as raw
scalars that serialize without an EQ wrapper. Fixing the split belongs in its own change.
So don't create the shape: skip pushdown when an inline map already set a non-numeric scalar
on the property, and let the residual answer as before. Absent properties, numeric inline
values, and already-built predicates are unaffected and still push.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
The merge guard inspected only the existing value, so it was order-dependent. filter_dict
is mutated as earlier atoms in the same WHERE push, and a previously-pushed string
predicate is an ASTPredicate -- which the guard green-lit -- so the next raw string merged
behind it into the same broken EQ shape:

  WHERE p.name CONTAINS 'al' AND p.name = 'alice'   -> GFQLTypeError: val must be numeric
  WHERE p.name = 'alice' AND p.name CONTAINS 'al'   -> blocked correctly

The error named no numeric anywhere in the query, and CONTAINS works standalone on this
branch, so the shape is one a user would reasonably write.

Check the incoming value too: `_predicate_value` keeps `==` as a raw value while every other
op builds a real predicate that round-trips on its own tag, so refuse the merge when `==`
carries a non-numeric. Both orders are now safe, and the first answers correctly (n=4) where
master could not render the clause at all. Numeric merges still push.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
…terializes

The gate classifies the dtype the planner sees, but filter_by_dict validates the frame the
executor materializes, and those disagree for polars Decimal -- numeric to the planner
("decimal" token), object to pandas, which the validator calls string:

  polars Decimal column, WHERE p.age > 25    master n=8  ->  GFQLTypeError E302
  polars Decimal column, WHERE p.age = 26    master n=4  ->  GFQLSchemaError E302

Master answered these correctly because it never pushed. Categorical/Enum split the same way
(polars Categorical -> pandas category, which classifies as neither).

Drop decimal/categorical/enum from the token lists so they fall through to fail closed. The
answer stays correct via the residual and only pushdown is lost -- the same trade pandas
`category` already makes. The robust fix is to read dtypes after materialization so planner
and validator judge the same frame; that is a larger change than this rung.

Adds the first end-to-end polars test that actually pushes: every prior polars assertion was
negative (asserting a rejected predicate), which is why this went unseen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
Arrow-backed columns raised on queries master answers:

  ArrowDtype(dictionary(int32,int64)), WHERE p.year > 2000   master n=2 -> GFQLTypeError E302
  ArrowDtype(bool),                    WHERE p.flag >= 1     master n=2 -> GFQLTypeError E302

Reachable from pd.read_parquet(dtype_backend='pyarrow') and any dictionary-encoded column.

Two independent causes, and the earlier token patches only papered over the second:

The gate fell back to kind/text whenever both filter_by_dict helpers returned False, but
those helpers fall back only on `except`. pd.api.types.is_numeric_dtype returns False
without raising both for a dtype pandas cannot parse and for one it authoritatively knows
is not numeric, so the trigger cannot tell those apart -- and overriding an authoritative
False is what raised here, since the executor re-applies it. With pandas nodes df_to_engine
is a no-op, so planner and executor held the identical dtype object and still disagreed.
Drop the fallback and defer to the helpers, so the two agree by construction.

That alone would lose all pushdown on polars, whose dtypes satisfy neither helper. So
classify the frame the executor materializes rather than the one the caller passed:
df_to_engine(nodes.head(0), ...) preserves dtype classes, and polars Int64 -> int64 pushes
while Decimal -> object correctly does not.

This subsumes the decimal/categorical/enum and container token lists, which are removed:
polars List materializes to object, so an ordering push is declined without a special case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
… without head()

pyarrow.Table nodes are supported and master executes against them, but pa.Table has no
.head(), so the dtype probe raised, the bare except swallowed it, and the gate returned
None. None means "no graph, use value-type rules", so it pushed dtype-blind -- the exact
bug class the gate exists to prevent:

  arrow nodes, WHERE p.name = 5    master []  ->  GFQLSchemaError

Two conflations, both fixed:

None answered both "there is no graph" and "we could not read the graph we have". The
first is safe: a bare compile_cypher() has no dtypes and value-type rules apply. The
second must fail closed. Now only a missing _nodes returns None; a schema we failed to
read returns an empty mapping, which fails every column lookup and falls back to the
residual. `return mapping or None` also turned an empty read into the unsafe answer.

The probe assumed .head(). Fall back to the frame itself when absent -- df_to_engine
converts a pa.Table to pandas, so the gate now reads real dtypes for arrow nodes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
polars -> pandas conversion is data-dependent, so an empty probe reports a different class
than the real thing: a nullable Boolean is `bool` at head(0) and `object` once converted.
The gate called it numeric while the executor filtered an object column:

  polars nodes, nullable bool, WHERE p.flag > 0    master n=4  ->  GFQLTypeError

The claim that head(0) preserves dtype classes was wrong. Nullable Int64 widens int64 ->
float64, which stays numeric and is harmless, but Boolean crosses the class boundary.

Convert the whole frame instead of guessing which conversions are data-dependent. For
pandas nodes df_to_engine is identity and costs nothing; for others it is one conversion the
executor performs anyway. No heuristic to get wrong.

The old test asserted probe dtypes in isolation and would pass with this bug present. It is
replaced by one that compares against the full conversion -- the property that actually
failed -- and asserts the empty probe disagrees.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
Reading dtypes converts the whole nodes frame, and the gate was evaluated eagerly as a call
argument for every string gfql(). Only connected-join pushdown consumes it, and that path
needs an aggregate RETURN, so most queries paid for a frame that was immediately discarded:

  2M-row polars nodes, MATCH (n) WHERE n.age > 900 RETURN n   187ms -> 307ms (+64%)

The conversion is linear (~65ms per million polars rows) and hits every query on a
polars-backed lane. pandas is unaffected -- df_to_engine is identity there.

The comment claiming it was "one conversion the executor performs anyway" was wrong: the
probe's frame is discarded and the executor converts again, so it was 2x, not free.

Return a lazy Mapping that materializes on first lookup and caches. Callers are unchanged,
queries that never reach pushdown pay ~0.01ms, and the full-frame read -- which is what makes
the dtype classes correct -- still happens whenever a decision actually needs it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
Reverting the empty-mapping fallback to None survived all 1275 tests: the two are
observationally equivalent end-to-end, since a frame whose schema cannot be read also fails
execution the same way either way. But the distinction is load-bearing -- None means "no
graph, use value-type rules" and pushes dtype-blind -- so assert it directly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
Numeric comparisons against a pandas bool column raised on queries master answers:

  MATCH (i)-->(p), (p)-->(c) WHERE p.flag > 0    master n=2  ->  GFQLTypeError
  (same for >=, <>, and = 1; only = true survived)

The gate reads the source nodes frame, but the connected-join executor filters a joined
one, and an unmatched row introduces NaN. bool cannot hold NaN, so pandas widens it to
object -- numeric to the gate, string to the validator. So "classify the frame the executor
filters" was still not what the code did: it classified the frame before the join.

bool is the only common dtype whose widening crosses the class boundary; int64 -> float64
stays numeric and keeps pushing. Decline bool rather than model the join's type algebra --
the residual answers, and guessing at conversions is what produced most of the defects here.

The single-pattern path fails the same way on master, so the defect is pre-existing; this
join shape was immune only because it never pushed. Existing bool tests covered equality
only, and the polars nullable-Boolean test passes because that dtype converts to object and
is declined -- neither exercised a pushed numeric op on a native bool.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
A bool column with a null is object, and string predicates are admitted on dtype alone --
by this gate and by filter_by_dict alike -- so .str failed on the values and leaked a raw
exception where master raised a GFQL error:

  flag=[True,False,None,True], WHERE p.flag CONTAINS 'a'
  master GFQLValidationError  ->  builtins.AttributeError: Can only use .str accessor...

Both checks are dtype-level while .str fails at the value level, so "planner and validator
agree by construction" held and still failed.

Declining `object` is not the fix: pandas stores ordinary strings there, so that would
delete CONTAINS/STARTS WITH entirely. Instead omit object columns whose infer_dtype is a
homogeneous non-string kind. An absent column has nothing to look up, so the gate fails
closed and the residual answers, as it did before. Mixed and empty contents are untouched --
they already work.

Real string columns keep pushing: p.name CONTAINS 'o' still answers where master cannot
render the clause at all.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
The omit-set listed kinds to reject and missed three that .str also rejects, so they still
leaked a raw AttributeError where master raised a GFQL error:

  score = [1.5, 2.5, None, 4.5]  (mixed-integer-float)  -> builtins.AttributeError
  clock = [time(1), time(2), None, time(3)]  (time)     -> builtins.AttributeError
  object column of pd.Interval  (interval)              -> builtins.AttributeError

mixed-integer-float is the sharp one: it is what a float column with a None infers as, while
np.nan infers as floating. So the previous commit caught "a bool column that acquired a null"
and missed the float column that acquired one -- its own motivating case, one dtype over.

StringMethods._validate admits exactly {string, empty, bytes, mixed, mixed-integer}. Keep
those and omit the rest, minus bytes, which str.contains forbids. Mirroring the rule fixes
all three and fails closed on kinds pandas adds later; an enumerated denylist can do neither.

Also repairs the totality premise of the full-conversion test, which the previous commit
broke: the map is partial by design now, and the test asserted every column was present. It
failed only under system python3 -- the polars lane skips it in CI, so 77/77 stayed green
with a broken test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
Adding "bytes" back to the allowlist survived all 1292 tests, yet it is load-bearing:
without the carve-out, an object column of bytes leaks a raw TypeError where master raises
a GFQL error.

The subtlety is two-layer. bytes passes StringMethods._validate, so mirroring that allowlist
verbatim looks right -- but the methods themselves reject it via @forbid_nonstring_types, so
str.contains still fails. Nothing pinned the difference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
Admitting "floating" or "integer" back into the keep-set survives all 1293 tests, yet each
reintroduces the raw AttributeError the omit-set exists to prevent -- an object column of
plain floats or ints is the most trivially constructible case of it.

The existing test parametrized only mixed-integer-float, time, and boolean; bytes got its own
test. The two simplest kinds were pinned by nothing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
mixed and mixed-integer passed pandas' accessor rule on the source frame, but the pushed
filter runs on the join's candidate subset -- and those kinds are not closed under it. Drop
the strings and they collapse to integer/floating/boolean, which .str rejects:

  value=["alpha",10,20,30,"beta"], p-candidates hold only numbers
  WHERE p.value CONTAINS 'a'  ->  builtins.AttributeError (raw)

Master never raises there, but only because it never applies the filter -- its n=2 is wrong.

This is the third defect from one root: the gate reasons about a frame the executor does not
filter. Pre-conversion dtypes, then post-join widening, now post-subset content. So admit only
what no subset can invalidate: string and empty. A subset of strings is string or empty, both
accepted.

Mixed columns now decline to a typed error, matching master, which cannot render CONTAINS at
all. Pure string columns keep pushing and answering where master rejects them.

The durable fix is to make the pushed predicate itself value-safe, which would also repair
master's single-pattern leak; that reaches into shared predicates and belongs in its own
change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
Dropping "empty" from the keep-set discriminates zero tests across the whole 4,530-test gfql
suite, yet it is load-bearing: an all-null object column infers `empty`, and `.str` handles it,
so CONTAINS correctly returns [] (3VL: NULL CONTAINS 'a' is NULL, no rows). Drop it and those
queries silently revert to master's unsupported error.

Its sibling "string" causes 15 failures when dropped -- the asymmetry is what exposed the gap.
"empty" is also load-bearing for the subset-closure argument itself: a subset of strings is
string or empty.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
lmeyerov and others added 7 commits July 17, 2026 01:17
… pattern

A pushed filter that emptied a pattern returned a schema-less frame and skipped
post_join_chain -- where count(p) AS n lives -- so the RETURN column vanished:

  WHERE p.s_col = 'zzz'   (string, pushed)      cols=[]     <- master: cols=['n']
  WHERE p.m_col = 'zzz'   (mixed, not pushed)   cols=['n']

Same query shape, same emptiness; the only discriminator was whether the dtype gate admitted
the column, so consumers doing df["n"] hit a KeyError on one and not the other.

Both early-returns are byte-identical on master. The regression is reachability: master never
pushed into the pattern, so the filter ran post-join and the aggregate survived. This is the
fourth defect from that one root -- the pushdown reaching code paths master never exercised.

An emptied pattern still carries its columns, so let it flow: the joins propagate
empty-with-schema and post_join_chain runs on empty input, which is what produces the column.
Projections were never affected; they don't take this path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
…mpties a pattern"

This reverts commit 77df4f7. Its central claim -- "an emptied pattern still carries its
columns, so let it flow" -- is false for edge aliases, and I shipped it without checking.

The `rows` binding-op emits no edge-alias payload at 0 rows, so `e1.w` and the `e1.e1` marker
vanish. `joined_alias_columns` recovers node aliases via its `suffix == "id"` fallback -- which
is why count(p)/count(q) passed and the claim looked true -- but edge aliases have no `.id` and
are only recoverable from the marker the emptied pattern never emits. post_join_chain then
dereferences a column that isn't there:

  MATCH (p)-[e1]->(q), (p)-[e2]->(r) WHERE p.i_col = 999 RETURN count(e1) AS n
  master cols=['n'] rows=0  ->  GFQLTypeError

So the commit made the very thing it set out to preserve strictly worse: master returns the
['n'] schema, and it raised. A MEDIUM schema drop was traded for a HIGH failure on valid
queries.

Reverting restores the prior behaviour, which reinstates the original schema drop (#25). That
defect stands, recorded, with its real fix: make the `rows` binding-op emit the full binding
schema at 0 rows, so the invariant is established rather than assumed. Restoring the
early-return is not that fix -- it just stops making things worse.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
The except path returned False, keeping the column -- the opposite of what its own docstring
claimed and of what its sibling _read_node_dtypes does (returns {} for a schema it cannot
read). An object column we cannot inspect tells us nothing about whether .str would reject
its values, so keeping it pushes blind.

Split the two failure modes: a dtype check that raises means the column isn't ours to judge
(keep); an unreadable object column means omit and let the residual answer.

Latent rather than live -- the reachable paths (duplicate column labels, non-str labels) are
already outside the supported envelope or unreachable through the parser. Fixed because "fail
closed on what you cannot verify" is the rule the rest of this gate follows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
…through

#25 root fix. A pushed filter that emptied a connected-join pattern returned a schema-less
frame and skipped post_join_chain, dropping the aggregate's RETURN column. #27 showed why
removing the early-returns alone (77df4f7, reverted) is wrong: the `rows` binding-op drops
edge-alias columns at 0 rows -- `_gfql_connected_bindings_state` assembles the schema
incrementally, so an emptied hop returns before adding edge-alias and later-node columns.
Node aliases survive via the row-frame merge's empty lookup; edge aliases have no `.id`
fallback, so count(e1) on an emptied pattern raised.

Fix the root: at the end of the bindings row-frame build, when 0 rows, add the declared
alias.alias markers and edge-alias columns (derivable from ops + the base edge frame). That
makes the emptied frame carry its full schema -- instrumented: the rows op now emits all 13
columns at 0 rows, matching the non-empty shape. Only then remove the two early-returns in
_apply_connected_match_join so the empty-with-schema frame flows through post_join_chain.

Verified vs master: count(e1)/count(DISTINCT e1)/edge-in-WHERE on an emptied pattern, plus
the #25 node case, all match master (cols=['n'], rows=0); non-empty controls unchanged;
OPTIONAL MATCH (shares the rows op) and polars unchanged. Both halves mutation-verified.

The 0-row bare aggregate still returns rows=0 where Cypher wants one row n=0 -- pre-existing
on master, unchanged here, recorded as a separate latent finding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
The #25 schema fill used an untyped None broadcast, so an emptied edge-alias column came back
object where the non-empty path and master give int64:

  WHERE a.v > 99999 RETURN sum(e1.w)   master int64 []  ->  object []

Values were always correct, but the object dtype upcast the aggregate and escaped a 0-row
branch into a non-empty result via UNION ALL (the concatenated column became object). Fill
from a typed empty slice of the source edge frame instead, mirroring the node path's typed
lookup; the alias.alias marker has no source dtype and keeps the None broadcast.

sum/avg/max/count over an emptied edge-alias pattern, and the UNION ALL escape, now match
master's dtype exactly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
Companion to the edge-column dtype fix. A non-anchor node reaches the row via a hop whose
unmatched rows introduce NaN, so the non-empty connected join widens its integer columns to
float64. The 0-row path sourced them from the base node frame (int64), so an emptied
sum(b.i_col) returned int64 where the non-empty run and master give float64 -- observable
through UNION ALL, the same class as the edge-column bug:

  WHERE a.i_col = 999 RETURN sum(b.i_col)   master float64 []  ->  int64 []

At 0 rows, cast the integer columns of every node op after the first to float64. The anchor
never NaN-widens (stays int); float columns are unchanged. sum/max over an emptied non-anchor
node property, and the UNION ALL escape, now match master's dtype.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
Wave 37 caught two 0-row schema-fidelity defects in the downstream-node
widening added for #31:

- Finding 1 (regression): `is_integer_dtype` returns True for nullable
  `Int64`, so `astype("float64")` collapsed it to numpy float64 --
  diverging from both the non-empty run (keeps `Int64`, avg -> `Float64`)
  and master. Guard the cast to numpy int/uint only, skipping extension
  dtypes, which hold NA natively and are never widened by the hop.

- Finding 2: a downstream node's numpy `bool` column is upcast to `object`
  by the hop's NaN-introducing left-join in the non-empty path, but the
  0-row path left it as raw `bool`. Widen numpy bool -> object to match;
  nullable `boolean` (extension) stays put via the same guard.

Both escape via UNION ALL, both were uncovered by the existing tests
(plain int64/float64 only). Add nullable-Int64 and bool/boolean
regression tests, mutation-verified against non-empty and master.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
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