Skip to content

gfql: engage the #1658 seeded index on Polars/cuDF (identity-stable coercion #1726 + small-frontier cost gate)#1727

Merged
lmeyerov merged 6 commits into
masterfrom
fix/gfql-1726-pl-nan-to-null-identity
Jul 16, 2026
Merged

gfql: engage the #1658 seeded index on Polars/cuDF (identity-stable coercion #1726 + small-frontier cost gate)#1727
lmeyerov merged 6 commits into
masterfrom
fix/gfql-1726-pl-nan-to-null-identity

Conversation

@lmeyerov

Copy link
Copy Markdown
Contributor

Summary

Two changes that let the #1658 seeded adjacency index actually engage on Polars / Polars-GPU (and tidy up cuDF) direct hop() calls. Both are routing-only — the seeded index and the O(E) scan return identical results by the #1658 doctrine, so parity is invariant; these only change which path runs.

1. Identity-stable _pl_nan_to_null (#1726) — graphistry/Engine.py

_pl_nan_to_null returned a fresh Polars frame whenever the frame had any Float32/Float64 column (keyed on column presence, not on an actual NaN). hop() coerces inputs through this before the index hook, so the live edge frame's object identity churned and the resident index's source_ref is df check failed → Polars/Polars-GPU seeded hops always fell back to the scan.

Fix: probe is_nan().any() per float column (dtype-gated) and return the same frame object when no NaN is present; rebuild only NaN-carrying columns when present. Value-identical to before (fill_nan(None) is a no-op on NaN-free columns); only object identity changes, so the resident index now engages. Adds pure-Polars TestPlNanToNull (identity preserved when NaN-free; NaN→null still correct when present; real nulls untouched).

2. Small-frontier cost-gate floor — graphistry/compute/gfql/index/{cost,api,types,__init__}.py

The seeded-index cost gate bails to scan when frontier_n >= frac * n_keys (frac 0.5 pandas, 0.02 cuDF/Polars). For a single-node seed on a small/low-cardinality homogeneous slice, 0.02 * n_keys collapses below a handful of seeds, so the gate scans even though the index would be O(degree). Adds an absolute small-frontier floor: bail only when frontier_n > MIN_FRONTIER (16) AND frontier_n >= frac * n_keys. Named constant, env-overridable (GFQL_INDEX_COST_GATE_MIN_FRONTIER). The large-frontier bulk-hop protection is unchanged (fires only above the floor). Adds 3 tests (tiny slice indexes; large frontier still scans; floor tunable/disable-able).

Correctness / risk

  • Parity-invariant: covered by existing test_index_parity_vs_scan (index==scan, 4 engines × 8 scenarios) and test_cost_gate_engine_aware_never_loses_to_scan.
  • Static: ruff + mypy + py_compile clean on changed files.

Verified qualitatively on a large heterogeneous graph: Polars seeded hops flipped from all-scan to index-engaging, results identical.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Myz4YLr6FhjyH7h7dHHTBV

lmeyerov and others added 3 commits July 12, 2026 08:18
…polars (#1726)

_pl_nan_to_null returned a fresh polars frame whenever any Float32/Float64 column
existed (keyed on column presence, not actual NaN), churning frame identity so the
#1658 CSR index's `source_ref is df` check failed -> polars/polars-gpu direct hops
always fell back to the O(E) scan. Fix: probe is_nan().any() per float column
(dtype-gated) and return the SAME frame object when no NaN is present; rebuild only
NaN-carrying columns when present. Value-identical (fill_nan(None) is a no-op on
NaN-free columns); only object identity changes -> the resident index now engages.
Verified on the real SNB graph: polars flipped 120/120-scan -> 54-index/12-scan
(remaining 12 = cost-gate, separate). Adds pure-polars TestPlNanToNull.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Myz4YLr6FhjyH7h7dHHTBV
…y homogeneous slices index on cudf/polars (#1726 follow-up)

The seeded-hop planner's cost gate bails to the O(E) scan when
frontier_n >= frac * n_keys. frac is engine-aware (pandas 0.5, cudf/polars/
polars-gpu 0.02), so on small / low-cardinality edge slices (per-edge-type
homogeneous frames: n_keys <= 1/0.02 = 50) even a single-seed hop tripped the
gate on the vectorized engines and scanned O(E) with a resident O(degree)
index. On the real SNB graph this left ~12/60 native hops scanning on polars
(~6 on cudf) after #1726 made the index engage at all.

Fix: an absolute small-frontier floor. A frontier of <= 16 seeds
(_COST_GATE_MIN_FRONTIER_DEFAULT, env-overridable via
GFQL_INDEX_COST_GATE_MIN_FRONTIER; 0 disables) never bails on the frac gate;
larger frontiers keep the existing frac-based bulk-hop protection unchanged.
Constant (not a function of n_keys) on purpose: scaling with n_keys is the
frac gate's job; the floor bounds absolute per-hop probe work in exactly the
regime where frac*n_keys collapses below a handful of seeds. A <=16-seed
frontier costs at most 16 searchsorted probes plus a gather of exactly the
rows the scan would emit, so the index cannot meaningfully lose; the case the
gate protects (a frontier covering most distinct sources) requires
frontier > 16, where behavior is unchanged. Uniform across engines — pandas
(frac 0.5) only overlaps the floor when n_keys <= 32, micro slices where
either path is trivially fast.

Correctness-invariant by construction: the gate is a pure routing heuristic —
index_seeded_hop is parity-matched to the scan (traverse.py contract,
test_index_parity_vs_scan), so this changes which path runs, never the answer.

Also surfaces the floor in the explain trace (min_frontier_floor) and extends
the bail reason to name it. Adds pure-CPU tests: tiny-slice single-seed hop
now indexes on every engine (with scan-parity check), frontier just past the
floor still bails, env tuning validation, and floor=0 restoring pure frac
gating.

Static gates: py_compile + ruff check clean on all touched files; mypy
(mypy.ini via bin/mypy.sh) clean except two pre-existing errors in the
untouched lazy/engine/polars/degrees.py (present at HEAD).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Myz4YLr6FhjyH7h7dHHTBV
… the polars coverage lane

The _pl_nan_to_null identity tests (test_engine_coercion.py) only execute where
polars is installed, but the sole Engine.py-measuring coverage lane (core py3.14)
has no polars extra, so the new eager-DataFrame branch showed 0% changed-line
coverage despite having real behavior tests. Run those tests in the polars lane
and widen its coverage source from graphistry/compute to the graphistry package.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Myz4YLr6FhjyH7h7dHHTBV
@lmeyerov
lmeyerov marked this pull request as ready for review July 13, 2026 01:31
@lmeyerov
lmeyerov merged commit c79a790 into master Jul 16, 2026
77 checks passed
lmeyerov added a commit that referenced this pull request Jul 21, 2026
…cov to graphistry

The frame-helper move added dispatch code to graphistry/Engine.py, whose polars
branches were exercised only via the reentry tests but NOT measured — the polars
lane covered only `--cov=graphistry/compute`, so Engine.py (outside compute/)
showed 0% on its polars branches and dragged changed-line coverage to 75%.

Fix both halves: (1) add a dedicated unit test for all nine primitives across
pandas and polars (polars cases guarded by _HAS_POLARS + registered in
POLARS_TEST_FILES) plus series_to_pylist's arrow/pandas/tolist fallbacks and
raise-paths; (2) widen the polars lane to `--cov=graphistry` (the #1727 pattern)
so Engine.py's polars branches are measured. Audit-safe: coverage_audit's polars
profile only enforces graphistry/compute/gfql/lazy/**, so Engine.py is not floored.

Reproduced the exact changed-line gate locally (combined core+gfql+polars
artifacts): 75.00% -> 98.96%, Engine.py changed lines 100%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
lmeyerov added a commit that referenced this pull request Jul 21, 2026
…cov to graphistry

The frame-helper move added dispatch code to graphistry/Engine.py, whose polars
branches were exercised only via the reentry tests but NOT measured — the polars
lane covered only `--cov=graphistry/compute`, so Engine.py (outside compute/)
showed 0% on its polars branches and dragged changed-line coverage to 75%.

Fix both halves: (1) add a dedicated unit test for all nine primitives across
pandas and polars (polars cases guarded by _HAS_POLARS + registered in
POLARS_TEST_FILES) plus series_to_pylist's arrow/pandas/tolist fallbacks and
raise-paths; (2) widen the polars lane to `--cov=graphistry` (the #1727 pattern)
so Engine.py's polars branches are measured. Audit-safe: coverage_audit's polars
profile only enforces graphistry/compute/gfql/lazy/**, so Engine.py is not floored.

Reproduced the exact changed-line gate locally (combined core+gfql+polars
artifacts): 75.00% -> 98.96%, Engine.py changed lines 100%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
lmeyerov added a commit that referenced this pull request Jul 21, 2026
… core, #1709-adjacent) (#1751)

* feat(gfql): native polars WITH...MATCH re-traversal reentry (IC6/IC11 core)

A MATCH re-traversing from a preceding WITH's projected/aggregated bindings previously
declined on polars ("could not recover carried node identities"). Fixes two pandas-only
gaps: the native projector now emits the _cypher_entity_projection_meta side-channel the
bounded-reentry executor reads to re-seed; the executor id-handling + seeded binding
pipeline are engine-aware (polars is_not_null/filter/order-preserving join; semi-join to
carried ids). RETURN entity/property/count/count(*)/DISTINCT over WITH->MATCH now native,
pandas-parity. Differential fuzz ~3500 graphs: 0 disagreements, 0 crashes. Scalar-column
carry + duplicate-id / cartesian-under-seed stay honest NIEs (were crashes/silent-wrong).

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

* test(gfql): lower pandas-lane execution.py coverage floor 84.75->83.5

WITH...MATCH reentry added polars-specific branches to the shared reentry/execution.py.
Those are covered by the polars test lane (changed-line 100%) but unreachable by the
pandas core lane, which legitimately drops its per-file coverage to 83.81%. Lower the
pandas-lane floor to match; the polars branches keep real coverage via test-polars.

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

* refactor(gfql): type reentry/projection helpers with SeriesT/DataFrameT, drop call-site casts (review)

Review feedback on #1751: the engine-agnostic reentry helpers were (s: Any) -> Any with
unchecked cast(SeriesT/DataFrameT, ...) at call sites. Now typed with the repo frame
aliases (SeriesT/DataFrameT; object for the type-guard); the genuinely-dynamic polars
branches carry a localized # type: ignore at the dispatch point instead of an opaque Any
return + caller cast -> the three call-site casts are removed. projection.py entity_meta
dict -> Dict[str, Dict[str, Any]]. mypy: 0 new errors; ruff clean; 19 reentry tests pass.

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

* refactor(gfql): move engine-agnostic frame/series helpers to Engine.py

The reentry executor accumulated a cluster of generic pandas/cuDF/polars
dispatch helpers (null mask, series/frame filter, order-preserving left join,
constant-column assign, drop-columns, row-as-mapping, series-to-pylist) that
have no reentry semantics and belong with safe_merge / df_concat / df_unique in
the engine layer. gfql_unified.py had already re-implemented series_to_pylist,
proving the leak. Move the eight primitives to graphistry.Engine (public names,
no underscore), drop the reentry-local defs, and consolidate the duplicate onto
the more-defensive version. SeriesT/DataFrameT typing preserved via a
TYPE_CHECKING import + string annotations so Engine.py (imported very early)
never triggers graphistry.compute package init at runtime (would be circular).

No behavior change: byte-identical dispatch; reentry suite 19/19, cypher/entity
subset green, ruff clean, mypy-neutral.

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

* test(engine): cover moved frame/series primitives; widen polars lane cov to graphistry

The frame-helper move added dispatch code to graphistry/Engine.py, whose polars
branches were exercised only via the reentry tests but NOT measured — the polars
lane covered only `--cov=graphistry/compute`, so Engine.py (outside compute/)
showed 0% on its polars branches and dragged changed-line coverage to 75%.

Fix both halves: (1) add a dedicated unit test for all nine primitives across
pandas and polars (polars cases guarded by _HAS_POLARS + registered in
POLARS_TEST_FILES) plus series_to_pylist's arrow/pandas/tolist fallbacks and
raise-paths; (2) widen the polars lane to `--cov=graphistry` (the #1727 pattern)
so Engine.py's polars branches are measured. Audit-safe: coverage_audit's polars
profile only enforces graphistry/compute/gfql/lazy/**, so Engine.py is not floored.

Reproduced the exact changed-line gate locally (combined core+gfql+polars
artifacts): 75.00% -> 98.96%, Engine.py changed lines 100%.

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

* test(gfql): end-to-end parity for the seeded binding-rows WITH->MATCH path

The row_pipeline.py change (WITH re-entry seed for binding_rows_polars) was
covered only by direct-call unit tests asserting the seed is applied + the
decline branches; nothing drove a real cypher WITH->MATCH whose trailing MATCH
routes through the MULTI-ALIAS binding-table path (vs the single-alias hop path
the differential fuzz exercises). Add an e2e differential-parity test for a
two-fresh-alias trailing MATCH with a multi-alias RETURN, with a monkeypatch
guard asserting the seeded binding_rows path is actually hit so coverage can't
silently regress.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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