Skip to content

fix(gfql): restore identity-stable _pl_nan_to_null (regression from #1731)#1733

Closed
lmeyerov wants to merge 2 commits into
masterfrom
fix/gfql-pl-nan-identity-reland
Closed

fix(gfql): restore identity-stable _pl_nan_to_null (regression from #1731)#1733
lmeyerov wants to merge 2 commits into
masterfrom
fix/gfql-pl-nan-identity-reland

Conversation

@lmeyerov

Copy link
Copy Markdown
Contributor

Summary

Restores the identity-stable Engine._pl_nan_to_null from #1726 (3809ecd9), which #1731's "final hardening" (c2138b0d) accidentally reverted. The revert is live in current master and silently re-broke the #1658 seeded-index engagement on Polars / Polars-GPU for any graph carrying a float column.

Root cause

_pl_nan_to_null returned a fresh polars frame whenever any Float32/Float64 column existed (keyed on column presence, not actual NaN). fill_nan(None) on a NaN-free column is a value-level no-op but still yields a new object, and that identity churn defeats the #1658 CSR index's source_ref is df residency check — so a resident index is invalidated across a df_to_engine re-conversion and the query silently falls back to the O(E) scan.

#1726 fixed this by probing is_nan().any() per float column and returning the same object when no NaN is present. c2138b0d restored the unconditional df.with_columns([fill_nan(None) ...]), reintroducing the churn. This PR re-lands the identity-stable probe (rebuild only — and only the columns where — a NaN is genuinely present; the rare LazyFrame path keeps the unconditional rewrite).

Impact

Validation (dgx-spark, 26.02-gfql-polars image, via safe_run)

  • 113 passed — restored TestPlNanToNull (4 unit tests) + the full 4-engine test_index.py parity suite (pandas/cuDF/polars/polars-gpu).
  • Mechanism proof at the df_to_engine boundary: regressed → df_to_engine(polars→polars) identity preserved False; fixed → True, with NaN-present input still converting to null (null_count=1). Value-identical to prior behavior; only object identity changes.
  • Note: a gfql_index_all + gfql_explain path did not surface a difference by itself (it reuses the resident polars frame without re-conversion); the impact is confined to paths that re-cross df_to_engine (the SNB native-hop lane, per GFQL: #1658 index does not engage on polars/polars-gpu direct hops (frame-identity churn) #1726's original measurement of 120/120-scan → 54-index).

Change

Regression fix only; no behavior change for NaN-carrying frames or non-float frames.

🤖 Generated with Claude Code

lmeyerov and others added 2 commits July 19, 2026 02:05
…1731)

#1726 (3809ecd) made Engine._pl_nan_to_null identity-stable: probe is_nan().any()
per float column and return the SAME frame object when no NaN is present, because
fill_nan(None) on a NaN-free column is a value-level no-op that still yields a fresh
frame, and that identity churn defeats the #1658 CSR index's `source_ref is df`
residency check (so a resident index is silently invalidated across a df_to_engine
re-conversion on polars/polars-gpu, falling back to the O(E) scan).

#1731's "final hardening" (c2138b0) accidentally reverted this to the unconditional
df.with_columns([fill_nan(None) ...]), re-breaking identity for any Float32/Float64
frame. This restores the identity-stable probe (rebuild only the columns that carry a
genuine NaN; rare LazyFrame path keeps the unconditional rewrite). Value-identical;
only object identity changes.

Validated on dgx (26.02-gfql-polars): 113 passed (restored TestPlNanToNull + full
4-engine test_index.py parity suite). Mechanism proof at the df_to_engine boundary —
regressed: identity preserved False; fixed: True (NaN-present still converts, null_count 1).
Pokec (all-int/str) was unaffected via the no-float-columns fast return; SNB/LDBC and
graph-bench (float columns) regressed.

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

Review nit N1: the original tests used a single float column, so they did not
exercise the "rebuild only the NaN-carrying columns" branch. Add a case with a
clean float column + a NaN-carrying float column, asserting only the dirty column
is converted (nulls introduced), the clean column is byte-identical, and column
order + non-float columns are preserved.

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

Copy link
Copy Markdown
Contributor Author

Closing as subsumed by #1735 (perf/gfql-pl-nan-clean-cache): #1735 contains this PR's identity-stable _pl_nan_to_null restoration (the #1726 regression fix from #1731) verbatim in behavior, plus the recycle-safe clean-cache that removes the per-call O(E) NaN re-scan. Diff-verified: both touch only graphistry/Engine.py + test_engine_coercion.py, and #1735's Engine.py change is a strict superset (identity guard + weakref.finalize id-cache). Review/land #1735 instead — it is the bottom of the GFQL perf stack (#1735#1734 → Cypher-Search fast path).

@lmeyerov lmeyerov closed this Jul 19, 2026
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