Skip to content

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

Open
lmeyerov wants to merge 3 commits into
masterfrom
fix/gfql-1726-pl-nan-to-null-identity
Open

gfql: engage the #1658 seeded index on Polars/cuDF (identity-stable coercion #1726 + small-frontier cost gate)#1727
lmeyerov wants to merge 3 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
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