Skip to content

feat(gfql): add aggregate fast paths and final hardening#1731

Merged
lmeyerov merged 7 commits into
masterfrom
perf/gfql-olap-split-4-aggregate
Jul 19, 2026
Merged

feat(gfql): add aggregate fast paths and final hardening#1731
lmeyerov merged 7 commits into
masterfrom
perf/gfql-olap-split-4-aggregate

Conversation

@lmeyerov

Copy link
Copy Markdown
Contributor

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

Scope: direct one/two-hop aggregates, equal-domain and compile caches, Polars binding-row fix, typing/CI/coverage hardening.
Size: 22 files, +1318/-341 (1,659 changed lines).
Validation: cumulative tree exactly equals reviewed #1714 head c50a0f7; local static gates and 199 CPU/Polars cases pass. GPU validation remains gated on DGX safe_run.

lmeyerov added a commit that referenced this pull request Jul 18, 2026
…erage floor to its owning rung

The exact-head CI failure on test-gfql-core (3.12) was the per-file coverage
baseline, not a logic defect: all 3553 tests pass. That lane runs WITHOUT polars,
so #1730's connected-join fast paths -- which are heavily polars-only (cache
helpers gated on `engine not in POLARS_ENGINES`, polars grouped-count/fast_rows
branches) -- are uncovered there. gfql_unified.py measures 67.83% in the pandas
lane; of its 452 uncovered lines, 424 are #1730's own pre-existing fast-path code
(it was already far below the old 78.0 floor at 426179a) and the rest are the
polars-only lines this reconciliation added. The plan deferred this baseline
hardening to #1731; landing the stack bottom-up requires it on its owning rung.

- Lower the ci-pandas-py3.12 per-file floor for gfql_unified.py 78.0 -> 67.5
  (actual 67.83, small margin). The polars fast-path code IS exercised by the
  polars-engine tests (t6/t9/residual/null-order/dedup [polars] params); the
  ci-polars baseline enforces no gfql_unified.py floor.
- Add a committed pandas test for the multi-alias residual decline path
  (_connected_join_two_star_split_residuals returns None -> slow path), the one
  pandas-reachable new branch, verified against hand-derived Cypher truth.

Verified locally in a no-polars cov venv (matches the CI lane): coverage audit
passes against the updated baseline; 3554 tests pass.

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

Propagate the reconciled + CI-green #1730 head into the aggregate rung (normal merge,
never force). #1731 inherits #1730's already-reconciled connected-join contract, so the
merge was clean apart from feature interactions where #1731 and #1730 touched the same
code:

- _compile_string_query: combine #1731's per-graph compile cache with #1730's node_dtypes
  threading. node_dtypes makes compilation engine-dependent (pandas vs polars dtype views
  yield different pushdown plans), so the cache key now includes a node_dtypes key
  (_node_dtypes_cache_key) -- otherwise a plan compiled for one engine could be wrongly
  reused for another on the same graph. Compile via compile_cypher_query(parse_cypher(...))
  (the #1730 path) inside the cache.
- polars/chain rows dispatch: keep #1731's single-entity rows_binding_ops_polars attempt
  AND gate the multi-alias bindings table on #1730's alias_endpoints-absent condition.
- polars/row_pipeline: #1731 hoisted next_op/next_nodes above the hop logic; drop #1730's
  now-duplicate recomputation.
- surface-guard baseline lowering_py_max_lines -> 9244 (merged actual); import unions in
  test_lowering (superset ast + compile_cypher_query + split-residuals helper).
- Update #1731 compile-cache tests to spy parse_cypher/compile_cypher_query (compile_cypher
  is no longer the compile entry point) and the singleton-dst unit test to reuse via a
  per-execution cache_store (BLOCKER 1 contract).

Merge-affected suites: 1825 passed / 0 failed (system python3, polars 1.42). ruff clean;
surface guard pass; mypy gfql_unified.py 70 (no region errors; base c2138b0 was 93).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
Semantic review of #1731's never-reviewed aggregate execution found a real bug: the
single-hop grouped-aggregate fast path's polars sort omitted nulls_last, so an
`ORDER BY <group key> ASC ... LIMIT` returned the NULL group first (polars defaults
nulls-first) instead of the smallest non-null group. openCypher orders NULL as the
largest value (ASC -> last, DESC -> first). Pin nulls_last per key, mirroring the
connected-join grouped-count fix. pandas na-last default already matches for ASC.

Mutation-verified regression test on both engines (removing nulls_last fails polars,
pandas unaffected). Adversarial review of the aggregate fast paths otherwise clean:
count/count(DISTINCT)/sum/avg grouped, empty match, two-hop count(*)/count(a), and
single-hop multiplicity all match hand-derived Cypher truth on both engines; min/max
are an honest unsupported-query error (not this fast path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVsx9Fk3BDFvaZXhDvgv5V
lmeyerov and others added 2 commits July 18, 2026 17:15
… + two-hop count)

Documents #1731's delivery: single-hop grouped-aggregate and two-hop connected
count fast paths, byte-identical across all four engines, with openCypher-correct
NULL-as-largest ordering (fixes the polars nulls-first divergence) and the
dtype-keyed, data-independent string-Cypher compile cache. Merges the updated
#1730 (connected-pattern entry + inherited changelog corruption fix).

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

Amplification (round 001) found a stack-introduced pandas/cuDF vs polars divergence in
`_execute_single_hop_grouped_aggregate_fast_path`: the pandas/cuDF branch sorted with a
single `sort_values(ascending=[...])` and a SCALAR na_position (default 'last'), so on
`ORDER BY <key> DESC` the NULL group sorted LAST — while openCypher ranks NULL as the
LARGEST value (DESC -> nulls first) and the polars branch already pins `nulls_last` per
key. This silently returned the wrong `ORDER BY ... DESC LIMIT` top-k on pandas/cuDF
(dropped the NULL-key group).

Fix: express null placement with an explicit per-key null-indicator column and sort in
ONE pass (a key sorted ascending=(not desc) with its indicator sorted the same way puts
NULL at the correct largest end). This needs neither per-key na_position (pandas scalar-
only) nor a stable sort — cuDF's kind='stable' silently falls back to non-stable quicksort,
so a per-key multi-pass would lose multi-key tie order on GPU.

Regression test `test_single_hop_grouped_fast_path_orders_nulls_first_on_desc` (both CPU
engines), MUTATION-VERIFIED (pandas fails reverted; polars already correct via the :2085
fix). DGX-validated on all four engines (pandas/polars/cuDF/polars-gpu): 0 silent-wrong on
F1 + a multi-key `ORDER BY c DESC, city DESC` case. The ASC sibling was coincidentally
correct (pandas na-last == openCypher ASC).

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

Pure code move, no behavior change. gfql_unified.py had grown to ~3.7k lines as
the connected-join (single/two-star) and grouped-aggregate / two-hop-count fast
paths accumulated across #1730 and #1731. Move both families (22 functions,
~1.63k lines) into a new sibling module graphistry/compute/gfql_fast_paths.py so
the orchestrator stays readable.

- One-directional import: gfql_unified -> gfql_fast_paths (imports the 4 entry
  points it dispatches to); the module imports only leaf modules
  (Engine/ast/chain/filter_by_dict/dataframe/lowering) — no back-edge, no cycle.
- Within the module, the aggregate family calls the connected-join family; both
  self-contained (verified none reference code that stayed in gfql_unified).
- `# pragma: no cover` markers on polars/cuDF-only helpers move with them.
- test_lowering.py import repointed to gfql_fast_paths (only test referencing the
  moved symbols).

Validated on dgx (26.02-gfql-polars): import-clean (no circular/missing);
test_lowering.py 1383 passed/0 failed; polars cypher conformance + matrix 406
passed/0 failed. ruff clean; mypy 0-new (the 11 errors are pre-existing in
untouched polars/row_pipeline.py + degrees.py).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
@lmeyerov
lmeyerov changed the base branch from perf/gfql-olap-split-3-connected-exec to master July 19, 2026 06:55
@lmeyerov
lmeyerov marked this pull request as ready for review July 19, 2026 06:55
@lmeyerov
lmeyerov merged commit f3cc959 into master Jul 19, 2026
77 checks passed
pull Bot pushed a commit to admariner/pygraphistry that referenced this pull request Jul 20, 2026
… per-hop NaN re-scan on polars)

Every hop() runs _coerce_input_formats -> _pl_nan_to_null, which scanned is_nan().any()
over every float column of the (unchanged) resident edge frame ON EVERY CALL. So repeated
seeded hops on a resident graph (seeded-Search / native-hop) grew O(E) with edge count on
polars/polars-gpu while pandas stayed flat.

_pl_nan_to_null now probes an eager polars frame for real NaN once, returns a clean frame
UNCHANGED (restores the graphistry#1726 identity guard reverted by graphistry#1731 -> frame-identity caches like
the graphistry#1658 index keep engaging), rewrites only columns that genuinely carry NaN (values
identical to the old unconditional fill_nan), and caches the id of clean frames so later
calls skip the probe. Recycle-safe via weakref.finalize (a rebound/new frame re-probes ->
NaN->null semantics unchanged).

Measured (dgx): indexed polars g.hop on 8M edges + one float column: 33.8ms (growing O(E))
-> 0.20ms FLAT (~140x, O(degree)), matching pandas. This is the per-call cost that made
polars/polars-gpu seeded LDBC Search grow with scale and lose to Kuzu.

Regression: 184 passed (full 4-engine test_index.py parity + test_engine_coercion) — the one
failure (test_chain_dask_edges) is pre-existing on clean master (RAPIDS-image dask issue).
New TestPlNanCleanCache: NaN-present cleaned, clean-frame cached same-object, distinct frames
independent. Subsumes graphistry#1733 (identity-stable reland) and adds the clean-cache.

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