Skip to content

perf(gfql): kill per-call O(E) terms in seeded Cypher/chain queries (#1755 slice 1)#1757

Closed
lmeyerov wants to merge 2 commits into
masterfrom
perf/gfql-seeded-chain-tax-1755
Closed

perf(gfql): kill per-call O(E) terms in seeded Cypher/chain queries (#1755 slice 1)#1757
lmeyerov wants to merge 2 commits into
masterfrom
perf/gfql-seeded-chain-tax-1755

Conversation

@lmeyerov

Copy link
Copy Markdown
Contributor

What

Extracts the foundational slice of the seeded-Cypher abstraction-tax fix (#1755) as ONE clean, review-gated PR off master — the [EXPERIMENTAL] prototype #1738's ee510f78, isolated from the still-in-design "lean gather-combine" slices (those never land as-is).

Eliminates the two per-call O(E) terms that made g.gfql("MATCH…") seeded point/short-query latency GROW with edge count (LDBC SF0.1→SF1 grew 4–5× on 11× data while Kuzu stays flat), even with the #1658 CSR index resident:

  1. Typed keep-mask cache (gfql/index/traverse.py) — the feat(gfql): physical adjacency indexes for O(degree) seeded traversal #1658 index path rebuilt its simple-equality edge keep-mask via a full column compare (eq_str, ~2.8ms/1M rows) on every seeded hop. Now cached per (id(frame), engine, scalar edge_match).
  2. Augmented-frame cache (lazy/engine/polars/chain.py) — the polars chain rebuilt with_row_index per call: an O(E) copy whose fresh object identity also defeated every downstream id-keyed cache (including Replace NaNs with nulls since node cannot parse JSON with NaNs #1). Now one augmentation per resident frame; stable identity restored (which is what lets cache Replace NaNs with nulls since node cannot parse JSON with NaNs #1 actually hit).
  3. Typed-edge slice cache (lazy/engine/polars/row_pipeline.py) — the bindings-row path re-filtered the full edge type column O(E) per call; now a cached eager slice per (id(frame), scalar edge_match).

All three use the merged #1735 clean-cache pattern (weakref.finalize on the source frame → a recycled id() can never serve a stale entry; a rebound/new frame re-computes). Non-scalar / predicate / membership / null edge-match forms decline to the general per-call path (never mis-cache).

Why this is safe

Tests

New TestSeededChainTaxCaches (in test_engine_polars_chain.py, already in the polars lane): cache-identity + weakref eviction-on-GC + every decline branch for each of the three caches, plus an end-to-end run-twice parity guard (repeated seeded typed query is stable + native + matches the pandas oracle). Local: 6/6 new + 1009 chain-parity + 82 non-GPU index-parity pass; mypy clean on all 3 files.

Pending before merge

Extracted from #1738 (ee510f78); base stack #1734/#1735/#1736/#1737 all merged. Fixes part of #1755.

🤖 Generated with Claude Code

lmeyerov and others added 2 commits July 21, 2026 01:56
…gmentation caches)

IS1 profiling found two linear-with-E costs per seeded Cypher call on a
resident graph: the index path's simple-equality keep-mask rebuilt via a full
column compare (eq_str), and the polars chain's per-call with_row_index O(E)
copy whose fresh identity also defeated downstream id-keyed caches. Both now
recycle-safe caches keyed on the resident frame (weakref.finalize eviction,
the _pl_nan_to_null pattern). Profiled: both terms eliminated; 274 tests pass.

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

White-box identity/eviction/decline-branch tests for the three recycle-safe
per-frame caches in #1755, plus an end-to-end run-twice parity guard proving the
caches never serve a stale/corrupt frame across repeated seeded queries.

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

A/B finding (honest, pre-merge): slice 1's caches don't engage on a naive seeded query

Ran a same-container baseline(master)-vs-this-branch A/B (Message→Person HAS_CREATOR, seeded 1-hop typed cypher, warm median, 4 engines, at 200k and 4M edges). Results were parity-identical (result_rows=1 everywhere; no corruption) but showed no speedup (4M: pandas 496→518, polars 111→114, cudf 79→83, polars-gpu 95→95 ms).

Root-caused locally (not inferred): after repeated polars gfql() calls, all three cache dicts are size 0 and gfql_explain reports used_index=False, resident=[]. The keep-mask cache lives inside the #1658 index path (no resident index here → never runs); the query also doesn't route through the polars augmentation branch or binding_rows_polars. So my probe measured a path where these caches are inert — it did not reproduce #1755's conditions (its root-cause probe explicitly makes the #1658 index resident).

Implication: this PR is a correctness-verified, typed, green refactor that removes O(E) terms on the index path, but the headline points-flip is not yet demonstrated. Before merge I'll redo the A/B on the resident-#1658-index LDBC IS1/IS5 harness (where #1738 measured the 67–79% cut), confirm cache engagement, and determine whether slice 1 alone suffices or slice 2 (e4cc21b9, combine-deferral) must stack on top. Not to be merged as "the tax fix" until that number is real.

@lmeyerov

Copy link
Copy Markdown
Contributor Author

DEFINITIVE A/B via scratch_tax_probe.py (the exact #1755 root-cause probe): slices 1+2 give ZERO tax reduction

Ran the original root-cause probe (same synthetic Message→Person graph, resident #1658 index confirmed) baseline(master) vs this branch + slice 2:

metric baseline fixed (1+2)
IS5 bare_hop (native ceiling) 0.131ms 0.133ms
IS5 native_chain_notype 13.0 15.3
IS5 native_chain_typed 21.1 23.0
IS5 cypher 30.1 31.6
IS5 tax (cypher/bare_hop) 229× 238×

Fixed is identical-to-slightly-slower. The decomposition shows the tax is bare_hop 0.13ms → +13ms chain machinery (combine_steps two-pass re-merge) → +8ms full-column type filters → +9ms cypher projection — and slices 1/2 touch none of those. #1738's "slices 1-2 cut points 67-79%" does not reproduce on its own probe.

Disposition: this PR is confirmed correctness-safe perf-hygiene (removes O(E) eq_str/with_row_index terms on the index path) but is not the seeded-points lever. Recovering the tax needs a combine_steps lean-path redesign (clean-authored, not a #1738 cherry-pick). Leaving #1757 open as optional hygiene — not to be merged as 'the tax fix'.

@lmeyerov

Copy link
Copy Markdown
Contributor Author

Restacked and measured (locked-step 5 report)

Restacked onto master cleanly. SF1 polars, both reps reported, perf lock held, rows_returned unchanged throughout.

query master r1 r2 change Kuzu
seed-lookup (IS1) 103.6 77.4 74.5 1.34–1.39x 1.35
message-creator (IS5) 92.0 70.3 72.3 1.27–1.31x 1.68
expand-order-limit (IS2) 107.8 89.3 90.9 1.19–1.21x 21.13
message-content (IS4) 4.7 4.1 4.2 1.12–1.15x 0.71
message-replies 14.7 15.1 14.8 0.97–0.99x
new-topics 203.0 194.7 190.8 1.04–1.06x
tag-cooccurrence 42.4 43.9 41.7 0.97–1.02x
recent-replies 52.5 54.0 56.6 0.93–0.97x

The four queries that move are exactly the four cells we lose to Kuzu. The four that don't move are exactly the ones we win. Non-targets sit at 0.93–1.06x while targets move 1.12–1.39x consistently across both reps — so this is signal, not host noise, and it confirms the PR is aimed at the right layer (per-call overhead, #1755).

It does not flip any cell: 74.5 ms against Kuzu's 1.35 ms is a 55x loss, down from 77x.

Blocked on three cache properties, not rejected

The caches are three module-level dicts holding data-plane objects (an O(E) boolean mask, a full with_row_index edge frame, a full eager typed slice), with no bound, no lock, keyed by id(). The sharpest issue is correctness, not memory: "a recycled id() can never serve a stale entry" holds under prompt refcount collection, but a frame in a reference cycle is collected later, leaving a window where a new frame reuses a freed id() and is served another frame's data.

Empirical follow-up (polars 1.35.2, python 3.13.12) says every objection has a cheap remedy:

property finding remedy
pl.DataFrame hashable False (rules out WeakKeyDictionary — that part of the original reasoning holds)
weakref-able True
accepts attributes True per-frame UUID token instead of id() — a token cannot be recycled, so the stale-entry window closes entirely
GIL released during collect yes (2.95x on 4 threads vs 4x serial) real concurrency, so a lock is warranted
bound none cap entries, or bind lifetime to the existing _gfql_index_registry rather than a module global

A check-then-act race probe (8 threads, 1 key) produced 1 build, but that is timing under the GIL, not a guarantee — and the threading result above shows real parallelism is available. Weak evidence; the structural argument stands.

Measured memory, stated precisely

25 live indexed frames took RSS 443 → 998 MB (22.2 MB/frame), but retained cache entries read 0 at inspection — so that growth is the adjacency indexes, not this PR's caches. The unbounded-growth concern is therefore structural (no cap, no lock, id() keys), not demonstrated. Worth saying plainly so the revision is argued against the real risk.

Suggested next revision: UUID token key + explicit lock + bounded size (or registry-scoped lifetime), then re-measure. The speedup above is real and on-target; only the cache mechanics are in the way.

@lmeyerov

Copy link
Copy Markdown
Contributor Author

Evidence from today that bears directly on this PR

This PR's goal is killing per-call O(E) terms in seeded Cypher/chain queries, using caches. Today I found and fixed one such term outright, with no cache and no global state — worth weighing before this lands.

#1782 (perf/gfql-indexed-edge-match-lazy): index_seeded_hop built the edge_match mask over all E edges and then read it only at the handful of CSR-matched positions. That is a per-call O(E) term inside an O(degree) traversal. Evaluating the predicate on the gathered candidate rows removes it structurally — no memoization, nothing to bound, no identity-safety question, no cross-query lifetime.

Measured on the real LDBC SF1 polars lane, position-balanced ABBA/BAAB with rows validated: IS1 −29.1%, IS5 −25.4%, IS2 −19.0%, IS4 −10.5%, IS7 −2.3%. 4 faster, 0 slower.

Where the remaining IS1/IS5 cost actually is (measured today, correctly-configured polars: polars-engine indexes + engine='polars'):

  • seeded point lookup: 0.92 ms — already competitive with Kuzu
  • full seeded pattern: ~135 ms, of which collect_all is ~110 ms across 2 collects
  • the CSR index does engage on the polars hop (1.40× vs index_policy='off') — 132 ms remains with it engaged

So the residue is the polars executor building and collecting a combine plan over the full 3.18M-node / 14M-edge frames regardless of how narrow the traversal already is. That is not a repeated-work problem a cache would fix — it is one collect over graph-sized inputs per query. Caching it would memoize a graph-sized intermediate, which is the memory-blowup risk already raised on this PR.

Suggestion: re-measure this PR on top of #1782 before deciding. If the O(E) term it was chasing is the one #1782 already removed, this can close as a quarry with the analysis, and the remaining work is making the polars combine proportional to the traversal result — a planner change, not a cache.

I have not measured this PR myself (speedup / peak_rss / repeated-query growth loop all still outstanding), so this is input to that decision, not a verdict on it.

@lmeyerov

Copy link
Copy Markdown
Contributor Author

Closing with the analysis, per the "restack on the current stack and measure; no movement →
close" test. The result is more clear-cut than a measurement: the code this PR optimizes has
largely been deleted by the stack underneath it.

I restacked it on top of #1784 (perf/gfql-polars-semi-no-dedup). The cherry-pick conflicts,
and the conflict itself is the finding.

Term 1 — typed keep-mask cache: the target function no longer exists

This PR adds _EDGE_KEEP_MASK_CACHE keyed on (id(edge frame), engine, scalar items) to
memoize _build_edge_keep_mask, described here as "a full O(E) column compare on EVERY seeded
hop (~2.8 ms per call at 1M rows)"
.

#1782 did not memoize that compare — it removed it. The seeded index path now evaluates the
predicate on the gathered candidate rows (_EdgeMatchRowFilter.mask_for), so there is no
full-column mask to cache. Grep for _build_edge_keep_mask across the stack returns 0 hits;
the function is gone. Caching it is not a smaller win, it is a no-op against a deleted code
path.

(#1782 does retain a whole-column mask, but only as a cost guard for fixed-point walks that
gather past E/8 — it is built at most once per query and never on the seeded path this PR
targets.)

Term 2 — augmented-frame cache: real, but now ~1 ms

The with_row_index term is genuinely still there, but #1783 removed one of the two passes by
reusing the synthetic edge id as the stable row order. Profiled on the current stack at
N=2M / E=2M: with_row_index is 1 call per query, 1.0 ms of a 21.84 ms query — 4.6%. It was
~16 ms/query (2 × 8 ms) before the stack.

Why that is not worth the price

The remaining 4.6% would be bought with a module-level mutable data-plane cache keyed on
id(frame)
— the property that has been treated as disqualifying until it is bounded in size,
locked for concurrent use, and keyed on something stronger than id() (CPython recycles ids;
the PR's own mitigation is a weakref.finalize on the source frame). That is a substantial
correctness and concurrency surface to take on for 4.6% of a query, when the O(E) term it was
designed to remove is already gone.

What survives from it

The insight was right and it did land, just not as a cache. The two things this PR identified —
a per-call O(E) edge-predicate compare, and a per-call O(E) frame augmentation whose fresh
object identity defeated downstream caches — were both fixed by deleting the work rather
than memoizing it (#1782 and #1783 respectively). #1784 then removed the O(N) semi-join key
dedup that dominated what remained. Net on the real LDBC SF1 polars lane, position-balanced with
rows validated: IS1 −50.5%, IS5 −52.3%, IS2 −42.6%, IS7 −17.3%, 4 faster / 0 slower.

Reopen if a future profile shows the augmentation term growing back into something worth a
cache — but it should then be fixed by removing the duplication, as #1783 did, before reaching
for global mutable state.

@lmeyerov lmeyerov closed this Jul 26, 2026
lmeyerov added a commit that referenced this pull request Jul 27, 2026
…peline.py

Pure code move, no behaviour change. row_pipeline.py 1638 -> 1528 lines; the
unbounded/variable-length arms live in polars/varlen_rows.py with a docstring
explaining the exhaustion-depth walk and why deduping by endpoint is sound.

Motivation is rebase surface: #1757, #1714 and the seeded-chain work all touch
row_pipeline.py, and ~270 lines of var-length specialization sitting in the core
flow made every one of those a conflict.

RowPipelineMixin stays imported FUNCTION-LOCALLY exactly as at the original site —
hoisting it to module scope reintroduces an import cycle (row.pipeline -> lazy
engine -> back), so the move preserves it and says why.

2244 polars tests pass, ruff clean.
lmeyerov added a commit that referenced this pull request Jul 27, 2026
…ops) — unblocks LDBC IS6 on polars (#1709) (#1781)

* feat(gfql): native polars unbounded directed var-length binding rows (LDBC IS6, #1709)

The Cypher multi-alias bindings table (`rows(binding_ops=...)`) lowered natively
for fixed-length and BOUNDED variable-length segments, but an unbounded
fixed-point segment (`-[*]->` / `-[*0..]->`) declined with
"polars engine does not yet natively support cypher row op 'rows'". That was the
last shape blocking engine='polars' on LDBC SNB interactive-short-6, the one
interactive-short query polars could not answer.

Unbounded DIRECTED fixed point now lowers natively. Termination is
data-dependent, so instead of pandas' expand-paths-until-empty (which
materializes every partial path at every hop) the lowering runs a dedup-by-node
frontier walk to find the exhaustion depth D, then reuses the SAME lazy bounded
pair-join loop the `-[*1..k]->` arm uses with max_hops=D. Deduping by endpoint
changes no walk's EXISTENCE, only its multiplicity, which the bounded loop then
reproduces in full -- so the emitted rows are pandas-identical while the
exponential path expansion happens once, lazily. A cycle reachable from the seed
means infinitely many paths: that raises the same E108 "require terminating
variable-length segments" error pandas raises, detected by a pigeonhole bound on
the distinct nodes rather than after the blow-up.

Also fixes a latent silent-wrong found by the differential fuzz: the polars
builder rebuilt bindings from the CHAIN OUTPUT while pandas rebuilds from the
pre-chain base graph. The traversal prunes to what IT considers matched, which is
not the bindings builder's match set -- a zero-hop var-length segment binds a seed
with no matching outgoing edge, and the traversal drops that node. So
`-[*0..k]->` could silently return fewer rows than pandas. The builder now sources
its node/edge tables from the same base graph pandas uses (and that the indexed
builder was already handed).

Honest declines unchanged in spirit (NIE, never a silent answer): undirected
unbounded, aliased var-length relationships, and unbounded segments without
to_fixed_point (pandas truncates at a bound this lowering cannot reconstruct).

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

* review: share the bounded var-length expansion; type the polars path bag lazily

- extract `_directed_varlen_reachable_polars` so the unbounded fixed-point arm and
  the bounded `-[*1..k]->` arm run literally the same loop instead of two copies
  kept in sync by a comment.
- pin the generic bindings builder's path bag as a LazyFrame. It always was one;
  mypy inferred eager because `filter_by_dict_polars` declared the eager type.
- make `filter_by_dict_polars` frame-polymorphic via a constrained TypeVar
  (DataFrame | LazyFrame) rather than declaring one flavour and being called with
  both -- the eager viz lane and the lazy chain lane take the same `.filter(expr)`
  path, and the TypeVar keeps the caller's flavour on the way out. Removes the
  mypy noise this file was carrying, so the new code lands at delta 0.

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

* review: keep the localized ignores matching the new error code

Widening filter_by_dict_polars to a constrained TypeVar changes the diagnostic at
its two engine-neutral DataFrameT call sites from arg-type to type-var, so the
existing localized ignore no longer applied. Retarget it and add the twin in
gfql_fast_paths. Both sites are already gated on a polars engine; the cast is what
the surrounding code has always asserted.

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

* refactor(gfql): move the var-length row specializations out of row_pipeline.py

Pure code move, no behaviour change. row_pipeline.py 1638 -> 1528 lines; the
unbounded/variable-length arms live in polars/varlen_rows.py with a docstring
explaining the exhaustion-depth walk and why deduping by endpoint is sound.

Motivation is rebase surface: #1757, #1714 and the seeded-chain work all touch
row_pipeline.py, and ~270 lines of var-length specialization sitting in the core
flow made every one of those a conflict.

RowPipelineMixin stays imported FUNCTION-LOCALLY exactly as at the original site —
hoisting it to module scope reintroduces an import cycle (row.pipeline -> lazy
engine -> back), so the move preserves it and says why.

2244 polars tests pass, ruff clean.

* review(#1781): decline `-[*k..]->` for k>=2, and fix the coverage baseline

Adversarial review of this branch found a silent-wrong regression and a red CI.

BLOCKER — `-[*k..]->` with k >= 2 returned WRONG COUNTS, no error.
The unbounded-directed arm of the `rows` gate had no `min_hops` guard. Cypher
`-[*k..]->` lowers to `{hops: null, min_hops: k, to_fixed_point: true,
direction: forward}`, so every k >= 2 was SERVED. Pandas' `step_pairs` come
from the var-length `edge_op.execute` hop, which empties when its
`max_reached_hop < min_hops` and otherwise drops edges labelled below
min_hops; `max_reached_hop` is a dedup-by-node BFS eccentricity, not a
longest-walk length, so the raw-edge reconstruction here expands a different
edge multiset. On a 7-node acyclic graph, both engines answering:

    MATCH (a)-[*3..]->(b) RETURN count(*)          pandas 0   polars 30
    MATCH (a {id: 0})-[*2..]->(b) RETURN count(*)  pandas 24  polars 41

85 of 1200 acyclic fuzz cases diverged, with zero declines. Master declines all
of them. The second gate this PR's description relies on (`_is_native_multihop`
in `_chain_traversal_polars`) never runs here: `RETURN count(*)` lowers to a
pure-CALL chain, so the `rows` gate is the only gate. The existing decline test
missed it because it pins `to_fixed_point=False` — a shape Cypher never emits.
Now the unbounded arm requires resolved `min_hops <= 1`, mirroring the
undirected arm; 0 and 1 (`-[*]->`, `-[*0..]->`, the IS6 walk) stay served.

BLOCKER — CI was red and the coverage gate never ran. `varlen_rows.py` was
missing from `coverage_baselines/ci-polars-py3.12.json`, whose own note requires
a complete list, so `test-polars (3.12)` exited 3 and `changed-line-coverage`
(which needs it) was SKIPPED — this branch's changed lines were never gated.

Also fixed:
  * the cycle path raised a different exception CLASS and `.code` per engine
    (pandas GFQLTypeError/E303 via execute_call's wrapper, polars a raw
    GFQLValidationError/E108 because the native kernel runs before that
    wrapper). Repo control flow keys on `.code`. Now wrapped identically.
  * cycle detection was bounded by the GLOBAL node count with an eager collect
    per hop, so a two-node cycle reachable from one seed cost O(graph) collects
    (measured linear in global N) before raising. Now bounded by the REACHABLE
    set: a walk of h edges visits h+1 nodes, all within `seen`, so h >= |seen|
    IS a repeat. Exact in both directions, and the O(E) `node_cap` scan it
    replaces is gone entirely (`pairs_df.height == 0` is the same test).
  * the "hoisting reintroduces an import cycle" note was FALSE — disproven by
    hoisting. The sibling import moves to the import block and the mid-file
    `# noqa: E402` goes; `RowPipelineMixin` stays function-local, matching the
    engine convention. Unused typing imports and a stale line count removed.
  * `to_fixed_point` WITH an explicit bound was newly served and undocumented.
    It is parity-correct (pandas ignores the flag once max_hops is set) but was
    pinned by nothing; now tested and in the changelog.

Tests, all mutation-checked (reverting each fix fails the matching test):
  * gate-level, k in 0..3, either side of the boundary
  * end-to-end through Cypher on the repro graph — the one that actually
    catches the blocker, since both engines answer and only values differ
  * the cycle test now asserts class and `.code` are ENGINE-INDEPENDENT rather
    than pinning polars' own code (the old form passed while they disagreed)
  * a long acyclic walk must not be mistaken for a cycle, and a small reachable
    cycle behind a large unreachable remainder must still raise — the two
    failure modes of the new bound

Verified in-container on dgx-spark: 86 passed in the binding-rows file, 276
with the row-pipeline file, and `graphistry/tests/compute` with `--gpus all`
gives an IDENTICAL 9-failure set to this branch's base (pre-existing dask/cuDF
coercion), 6821 passed vs the base's 6804. mypy differential: 166 errors on
both trees, so these changes add none. ruff clean under the repo config.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YsqAZQLbqjSDrYSFz2GoB

* review(#1781): decline to_fixed_point with an explicit bound

The re-review of the previous commit found that the gate rewrite which closed
the unbounded `-[*k..]->` hole left a narrower one open, and that the test I
added to pin it could not have caught it.

Master declined on `bool(op.to_fixed_point)` ALONE. Restructuring the gate to
key on the RESOLVED MAX incidentally let `to_fixed_point=True` combined with an
explicit bound fall through to the bounded arm — where it hits the same
reconstruction gap as the unbounded case, silently, for min_hops >= 3:

    fwd min=3 max=4 tfp=True   pandas 0   polars 30    (master: NIE)
    fwd min=3 max=5 tfp=True   pandas 0   polars 30
    fwd min=4 max=5 tfp=True   pandas 0   polars  6
    fwd hops=3     tfp=True    pandas 0   polars 24
    reverse spellings: identical

Not reachable from Cypher — `cypher/parser.py` sets to_fixed_point False for
`*k` and `*i..k`, and only leaves it True for `*` and `*k..`, which always have
max_hops None. So this is the AST / `rows(binding_ops=...)` wire surface only.
Hard to reach is not correct, and the previous commit additionally claimed in
the CHANGELOG that the shape "matches pandas", which is false.

Declining it restores master's behaviour exactly, so it cannot regress anything
that previously worked.

WHY THE OLD TEST WAS BLIND, since the same mistake is easy to repeat: it
compared flagged-vs-unflagged WITHIN each engine and its parametrization
stopped at min_hops=2. A within-engine comparison never consults the pandas
oracle, so it cannot see a divergence where BOTH engines answer; and min_hops
<= 2 happens to agree on that fixture. It now asserts the DECLINE, runs to
min_hops=4, and is joined by a value test on the divergence graph that compares
ROW COUNTS against the oracle (engine-neutral: a bare `rows()` call emits
engine-specific scaffolding columns on every shape, pre-existing and unrelated).

Mutation-checked: removing the guard fails 9 tests, including the value test —
the property the previous version lacked.

Verified in-container on dgx-spark: binding-rows file 90 passed;
`graphistry/tests/compute` with `--gpus all` gives 6825 passed and a failure set
byte-identical to this branch's base (the 9 pre-existing dask/cuDF coercion
failures).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YsqAZQLbqjSDrYSFz2GoB

---------

Co-authored-by: Claude Opus 5 (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