# Changelog All notable changes to the PyGraphistry are documented in this file. The PyGraphistry client and other Graphistry components are tracked in the main [Graphistry major release history documentation](https://graphistry.zendesk.com/hc/en-us/articles/360033184174-Enterprise-Release-List-Downloads). The changelog format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) and all PyGraphistry-specific breaking changes are explictly noted here. ## [0.59.0 - 2026-08-31] ### Breaking - **GFQL strictness levels for absent labels/properties, defaulting to `warn` (#1916)**: an absent label or property no longer raises `GFQLSchemaError [column-not-found]` by default. Working on a subgraph with partial columns is normal usage, not a typo, so the default now follows openCypher and resolves an absent name to null: an absent label matches nothing (`MATCH (n:Nope)` is 0 rows, not an error), an absent property in `WHERE` or in a pattern map makes the predicate null so the row does not match (0 rows), `IS NULL` on an absent property is true (all rows), and an absent property in `RETURN` stays a null column. Three levels are selectable through the existing precedence chain (explicit `strict=` parameter, then `bind(schema=...)`'s `strict`, then its `metadata['strict']`, then the default): `"strict"` raises exactly the errors master raised, `"warn"` (default) warns once per distinct absent name per call, `"quiet"` is silent. The legacy boolean spelling maps on: `strict=True` is `"strict"` and `strict=False` is `"quiet"`, so both existing spellings are behavior-preserving and only the unset default moves. `strict=` is now accepted on `gfql()`, `chain()`, `gfql_validate()`, `gfql_remote()`, `gfql_remote_shape()`, `chain_remote()` and `chain_remote_shape()`. The validator and every executor (pandas, polars, cuDF, remote preflight) consult one shared resolution, so they cannot disagree the way they did on master, where `gfql_validate(strict=True)` rejected `RETURN n.nope_col` that execution then served. Two consequences of that agreement: under `"strict"`, `RETURN` of an absent property now raises where master returned a null column, and a direct `g.filter_nodes_by_dict({'absent': 1})` — not a GFQL call — keeps raising unchanged. A name absent from a DECLARED schema is still a typo and raises at every level; only a name the schema declares but this instance lacks is served leniently, which is the narrow-subgraph case `bind(schema=...)` exists to distinguish. Relatedly, a `type`/`labels` equality now resolves through a per-label boolean `label__X` column when the frame carries labels that way -- the mirror of the existing `label__X: True` rewrite -- so such a graph answers `-[:X]->` instead of matching nothing under the new default. - **Remote GFQL sends the resolved strictness level (#1916)**: `gfql_remote()` previously hardcoded its client-side preflight to `strict=False` and sent the server nothing, so the same query was strict locally and loose remotely. The preflight now honors the resolved level, and the request body carries a new `strictness` field (`"strict"` / `"warn"` / `"quiet"`) alongside the existing `engine` field. **Server-side honoring is a server change and is not in this repository**: a server that does not read `strictness` applies its own default, so a non-default level requested remotely warns once, in the same shape as the existing Let/DAG compatibility warning (#1955). A client holding only a `dataset_id` can now also preflight names when `bind(schema=...)` supplied them, since a declared schema is names without data. ### Fixed - **The 30M-edge GPlus filter/PageRank page now reports the completed Neo4j + GDS lane**: a locked twelve-slot follow-up produced a direct 354.47 s median-of-slot-medians with exact selected-node parity. The page and regenerated chart read the value from the vendored pyg-bench document. They publish no GFQL-vs-Neo4j ratio because Neo4j includes server round trips and a per-iteration GDS projection rebuild while GFQL retains resident frames. - **Polars graph-preserving Cypher CALLs retain their requested engine**: a CALL inside a compound `GRAPH ... USE ... CALL` query could return pandas frames after an igraph analytic even when the query requested `engine="polars"`. CALL-based graph constructors now restore the requested dataframe engine before returning, with value-parity and node/edge frame-type regression coverage. - **GFQL benchmark docs now enforce the benchmark contract v3 boundary**: the vendored pyg-bench artifact and contract suppress invalid cross-profile ratios. Filter/PageRank no longer divides resident in-process GFQL timings by a per-iteration Neo4j projection rebuild, and the GraphBench q1–q9 board no longer divides reused GFQL bindings by Kuzu's execute-text-per-call profile or treats cache-contaminated q8 values as results. The pages and generated charts retain direct timings and same-profile ratios only, and independently re-verify ratio operands, profiles, disclosure propagation, and derived-cell strength before rendering. - **Explicit unsupported remote engines now decline before side effects (#1957 completion)**: `gfql_remote` and `python_remote` preserve explicit `pandas` and `cudf` requests on the wire, while unsupported requests such as `polars` and `polars-gpu` raise typed `GFQLRemoteError` `E405` before credential refresh, upload, or POST. This is the remote service boundary, distinct from the release's local `polars`/`polars-gpu` GFQL engines; the existing `engine='auto'` policy is unchanged. - **Strict GFQL validation now rejects relationship types that the edge schema proves absent (#1916)**: a strict binder previously deferred any relationship type when its catalog listed no known types, even when the edge schema had no generic `type` carrier, so `gfql_validate()` passed a query that execution rejected. It now raises typed `E301` when absence is provable. A generic `type` carrier with no declared catalog remains unjudgeable without scanning values and still defers, while an explicitly empty declared catalog is judgeable and rejects. Focused validator/executor and binder tests pin all three boundaries. - **Cross-kind `WITH` whole-entity rebinds now fail early with typed `E108` (#1937)**: the local Cypher compiler guarded node-to-node and edge-to-edge rebinds but let a bare MATCH-bound node alias take a live edge alias's name, or the reverse, which could resolve rows and properties against different bindings. The guard now rejects any bare entity alias renamed onto another live pattern alias at compile time. Carries and self-renames, fresh targets, scalar/property shadows, terminal `RETURN` renames, `WITH`-to-`MATCH` reentry, and earlier, more specific validation errors keep their existing behavior. Focused tests pin both cross-kind error directions and the adjacent valid and precedence boundaries. - **All-null Boolean `sum()` on `engine='polars-gpu'` now returns integer zero instead of null (#1997)**: cudf-polars 26.02 reports null for an all-null Boolean reduction, while GFQL's documented Boolean aggregate extension follows the Cypher `sum()` empty-input identity and returns `0`. The result normalization now fills only Boolean `sum` before the shared Int64 cast; Boolean `count` and non-Boolean `sum` keep their existing null behavior at this helper boundary. Direct boundary tests pin the positive cell and both negative controls. - **Polars-GPU contract tests now distinguish correct fallback from fused-lane capability (#1997 follow-up)**: explicit `engine='polars-gpu'` strictness tests now cover absent-label and absent-property values plus strict/warn/quiet behavior. Grouped-aggregate engagement canaries runtime-xfail only when the GPU fused lane actually declines, and only after the generic GPU-targeted fallback matches its eager/pandas oracle; CPU Polars and any future serving GPU path remain strict must-serve assertions. This keeps a known cudf-polars capability gap visible without treating correct fallback answers as regressions or letting an expected failure mask a wrong answer. - **A whole-entity endpoint projection (`RETURN b`) answered a deduplicated node set instead of the openCypher bag (#1994)**: on nodes 1-5 with edges (1,2) (1,3) (2,3) (3,4), `MATCH (a)-->(b) RETURN b` returned 3 rows where openCypher returns 4 — node 3 is bound twice, once from node 1 and once from node 2 — and `MATCH (a)-->(b) RETURN a` returned `[1, 2, 3]` for the 4-row bag `[1, 1, 2, 3]`. Parallel edges made it starker: two 1->2 edges are two matches, but the answer could not represent them at all. It was **silent**, and the engine disagreed with itself: every *property* spelling of the same projection (`RETURN b.id`, and even `RETURN a, b`) was already bag-correct, so only the single whole-entity spelling was wrong. Two independent vetoes sent it to the per-alias node table, which *is* a set: the multiplicity predicate bailed on any bare-alias projected item, and the projection lowering vetoed binding rows whenever the plan had a whole-row output. Fixing the lane alone was not enough — the polars projector could not render a whole entity off a binding-row frame at all, which is why `MATCH (a)-->(b) RETURN a, b` raised `NotImplementedError` on polars while pandas and cuDF answered it. That projector now resolves each alias's `{alias}.{field}` columns through a per-alias view (the polars twin of the pandas `_projection_alias_rows`), so single-entity and multi-entity binding rows render alike and the polars decline is gone. Four scopes are deliberately unchanged: `RETURN DISTINCT b` keeps the node-set lane (DISTINCT asks for exactly that dedup, and the binding-row frame carries sibling-alias columns a lone whole-row output does not functionally determine), a whole-row `WITH` carry into a trailing `MATCH` keeps it too (re-entry cannot yet separate matched from unmatched rows on a duplicated prefix, so #1935 item 1 stays open rather than turning into a decline), a variable-length arm keeps it (its bag is the relationship-unique walk expansion, not the edge bag this lane counts — that shape's own whole-entity/property disagreement is left open rather than swapped for a second unvalidated answer), and a pattern with no relationship has no multiplicity to keep. The seeded fast path recognizes the whole-entity bag lowering and re-expands one destination row per matched edge, so the LDBC IS5 entity shape (200k nodes / 1M edges, pandas, median of 20) stays on the fast lane at 7.8ms against 7.9ms before, rather than the 65ms the general lane costs; it defers on a zero-row bag so the empty-frame dtype contract stays single-sourced in the full path. An unseeded whole-entity scan necessarily gets slower in proportion to the rows it stopped dropping (94ms/199k rows before, 863ms/1.0M rows after). - **Every string predicate over a CATEGORICAL column answered an empty/null result on cuDF where pandas answered rows**: `MATCH (n) WHERE searchAny(n, 'x', {columns: ['cat']}) RETURN n.id` over a categorical `cat` returned 15 rows on pandas and **0 rows on cuDF** — silently, with no warning and no error. A categorical-of-strings is string-VALUED on every engine, but only pandas lends it a `.str` accessor; cuDF raises `AttributeError` on `.str` for a categorical. The predicates' accessor probe read that raise as "this column is not string-valued" and returned the non-string result for the whole column — null, or `False` under the `na=False` that `searchAny` passes, so every row was dropped. `Contains`/`Startswith`/`Endswith`/`Match`/`Fullmatch` now decode a categorical whose CATEGORIES are strings back to its string values before the accessor, which is exact and null-preserving on both engines, and the unguarded `isalpha()`-family predicates take the same path instead of surfacing the raw `AttributeError` cuDF-only. A categorical with NUMERIC or temporal categories is unchanged and still refuses to stringify — `searchAny` keeps declining it on cuDF with a typed `NotImplementedError`, because that rendering diverges pandas↔cuDF. pandas answers are unchanged; polars already declined `searchAny` with explicit `columns=` and is unaffected. - **NULL edge endpoints now follow one identity-resolution contract on all three engines (#1995)**: production answered this both ways -- eight sites implemented "a null never links" while the polars hop's `_keep_edges_with_both_endpoints_resolvable` (#1888 round 6) resolved a NULL endpoint to a NULL node id, so `MATCH (a)-[x]-(b) RETURN count(*)` over a graph with one NULL endpoint answered polars 4, pandas 6, cuDF 6. The contract is now stated in `docs/source/gfql/spec/language.md`: **a NULL id is not a graph identity**, so an edge with a NULL endpoint matches no pattern edge on any surface, from either direction, with a bound or synthesized node table. Input validity is a separate policy: this change preserves permissive DataFrame ingestion and current node-only row scans without declaring NULL-id source rows valid graph nodes, while `OPTIONAL MATCH` NULL bindings remain valid result values. Two defects that were wrong under either endpoint policy are fixed: the polars hop kept a NULL-endpoint edge whose NULL endpoint got no node row, and pandas/cuDF answered the same undirected chain with 2 edges unnamed and 3 edges named. Three kernels enforce endpoint resolution (shared pandas/cuDF `hop`, polars `hop_eager`, polars chain fast path). The seven strict-xfail cells from #1888 rounds 6-7 are removed and replaced by 13 green contract pins plus one non-strict compatibility probe (136 engine-parametrized cells, 39 red at the merge base) over both-sided-NULL, NULL-free, and string-id fixtures. - **`hop()` on polars returned duplicate node rows where pandas returned one (#1895 residual)**: a node table with a repeated id (`id = [0, 0, 1]`) came back from `hop()` with both `id = 0` rows on polars and one on pandas — a silent cross-engine row-count divergence, not an error. The polars node output is a semi-join against the input table, which emits *every* matching left row; pandas de-dups by id in its edge-guarded output epilogue. The polars kernel (eager and lazy arms) now runs the same edge-guarded de-dup, so both engines emit one output node row per id. - **Two silently-wrong row counts: a seeded typed 1-hop and a leading `OPTIONAL MATCH` both answered a deduplicated node set instead of the openCypher bag (#1899, #1903)**: `MATCH (a {name:'Ann'})-->(b) RETURN b.id` returned `[2]` where Ann has two parallel edges to Bob and openCypher returns `[2, 2]`, and `OPTIONAL MATCH (a)-->(b) RETURN a.id` returned `[1, 2, 3]` where the four-edge bag is `[1, 1, 2, 3]`. Both were **silent** — no warning, a plausible answer, and the unseeded/non-OPTIONAL spellings of the same patterns were already right, so the two shapes disagreed with their own siblings. Two compile-time carve-outs caused it. The first claimed a selectively-seeded single hop projecting only destination properties was "already answered value-correctly" by the seeded typed-hop fast path; the fast path answers with a destination-node SET, so the claim was false exactly when parallel edges exist. The second vetoed binding rows for *any* query containing an OPTIONAL clause, which is far broader than the null-extension it protects: a **leading** OPTIONAL MATCH binds nothing before it, so no row can go unmatched and it is a plain MATCH for row purposes (the zero-match case is served by the empty-result-row null extension, which never consults binding rows). Both carve-outs are gone, so the lowering is now unconditionally bag-correct and the fast path is a pure optimization: it recognizes the multiplicity-preserving `rows(binding_ops=...)` lowering of the same seeded shape and re-expands one destination row per matched edge, keeping the LDBC IS5 shape on the fast lane (measured ~7.6ms vs ~7.4ms before, against ~124ms on the general lane). Two consequences are deliberate and named. (a) Shapes where the Cypher fast path DECLINES (a datetime property, a requested-vs-actual engine mismatch) no longer reach the *native* chain fast path either, so on an un-indexed pandas graph they now show the same `int64 -> float64` rows-pivot upcast every other un-indexed pandas lane already shows. That artifact is pre-existing and still tracked; what changed is that the declined and served shapes now agree. (b) The fast path's pandas dtype rule now follows the lane it is standing in for: the upcast is a rows-pivot artifact, and an INDEXED bag lowering is served by the indexed connected-bindings kernel, which never pivots — so on an indexed graph the seeded projection keeps `int64`/`bool`, which is both its own generic answer and what polars and cuDF already return. Un-indexed keeps the pivot dtypes. Both directions are pinned (`test_indexed_bindings::test_destination_property_projection_dtype_parity`, `test_seeded_typed_hop_fastpath::test_pandas_int_bool_dtype_parity`). - **`size()` over a non-list, non-string column answered with the TABLE ROW COUNT (#1985)**: `size(n.age)` on an `int64` column returned `3` for every row of a 3-row table and `7` for every row of a 7-row table — the height of the containing frame, not a property of the data, so adding unrelated rows changed the answer. In a predicate it was worse: `WHERE size(n.age) = 3` kept **every** row of a 3-row table and **no** row of a 4-row table. The pandas/cuDF row pipeline swallowed the typed `AttributeError` from its sequence-length helper and fell through to `len()`. Two sibling call sites swallowed the same failure and answered from a fabricated element count of zero: `any()`/`single()` said `false` and `all()`/`none()` said `true` about a column with no elements at all, and a list comprehension over such a column yielded `[]`. All three now raise a typed decline naming the limitation, matching the handling the slice-subscript and `ORDER BY`-list call sites two lines away already had, and matching the native polars lowering, which already declined a non-sequence operand rather than replicate the quirk. **What still answers is unchanged**: openCypher defines `size()` over strings, so `size()` remains CHARACTER length on pandas, polars and cuDF, `size()` remains the element count, `size([1,2,3])`/`size('abc')` still count the literal, and an all-null column still answers null rather than declining. Only inputs for which no size is defined — numeric, boolean, temporal — changed, and they changed from a wrong answer to a decline. - **`OPTIONAL MATCH` after `WITH` blanked a carried whole-entity alias on its null-extended row, and cuDF rendered the blanks as `False` (#1897)**: for `MATCH (a:P) WITH a AS p, a.id AS pid LIMIT 2 OPTIONAL MATCH (p)-[:KNOWS]->(b) RETURN p, pid, b.id AS bid`, the row synthesized for the carried row that matched nothing came back with every `p.*` column NULL — even though `pid` on that same row proved `p` was still bound to `a2`. OPTIONAL MATCH cannot unbind an alias the prefix already bound, so only the suffix-bound outputs (`bid`) may go NULL; `p` keeps its own node columns. pandas answered NULL, and cuDF turned the NULL booleans into `False`, a definite wrong VALUE rather than a missing one. The null-fill copied only the carried SCALAR columns off the prefix frame; it now also copies the carried aliases' flat `alias.prop` entity columns, which the prefix frame already carries under the same names the result uses. Anti-join keys are unchanged (still the scalar columns), so no shape that previously null-extended changes which rows it fills. polars declines this shape earlier with its typed scalar-carry `NotImplementedError` and is unaffected. - **An adjacency index changed the answer to `EXISTS { }` pattern predicates on polars (#1986)**: attaching an index (`gfql_index_all()`) made `MATCH (n) WHERE EXISTS { (n)-->(n) } RETURN n.id` return every node with *any* out-edge, where the un-indexed graph declined the shape with a `NotImplementedError`; `NOT EXISTS { (n)-->(n) }` mirrored it by dropping rows that genuinely satisfy the predicate. The adjacency-membership shortcut answered pattern participation from edge-table CSR keys alone, which drops two conditions the scan enforces: a **repeated endpoint alias** (`(n)-->(n)` means a self-loop, not "n has an out-edge"), and the **node-table intersection** (an edge endpoint absent from the node table is not a node, so the edge witnesses nothing). Both are now preconditions of the shortcut — a repeated alias never takes it, and neither does a graph whose node table does not cover every edge endpoint — so an indexed graph and an un-indexed graph return the same rows or decline identically for every EXISTS/NOT EXISTS shape. An index is an optimization and must never change an answer, least of all turn an honest decline into wrong rows. - **`lazy_import_has_min_dependancy()` returned a 3-tuple on its generic-exception path, so a broken (not missing) scipy/sklearn raised `ValueError: too many values to unpack` instead of the real dependency error.** Every one of its five callers unpacks two values; only the `except ModuleNotFoundError` and success paths returned two. An ABI-mismatched or otherwise broken install therefore surfaced as an unrelated unpack error at `assert_imported()`, hiding the actual ImportError. The generic path now returns the same 2-tuple as the others, and a pin locks the arity of every return path (the sibling 3-tuple probes keep their own shape). - **`sum()`/`avg()` over `BOOLEAN` adopted as a documented extension, with its return TYPES pinned across engines (#1820)**: `sum`/`avg` over a boolean column is a type error in Cypher (*"expected Float, Integer or Duration but was Boolean"*) and has always been answered here as a deliberate GFQL extension — a strict superset, so no Cypher-valid query changes meaning. The values already agreed on every engine; the **return types did not**. Polars answered `sum(BOOLEAN)` and *every* `count()` with `UInt32` where pandas and cuDF answered `int64`, the all-null substitution answered `sum` with an `Int32` literal, and cuDF answered `count(DISTINCT ...)` with `int32` — same values, four different return types, which is exactly the cross-engine divergence class the aggregate type contract exists to close. Exercising the cuDF arm (previously unverified) also turned up a **wrong value**, not just a wrong type: cuDF's grouped `sum` answered a group with no non-null values with NULL, where Cypher says **0** and pandas/polars already said 0 — on `BOOLEAN`, `Int64` and `float64` alike. The contract is now `sum(BOOLEAN) -> INTEGER(int64)`, `avg(BOOLEAN) -> FLOAT(float64)`, `min`/`max`(BOOLEAN) `-> BOOLEAN`, `count(BOOLEAN) -> INTEGER(int64)`, enforced at all seven aggregate sites across the pandas/cuDF row pipeline, the native polars row pipeline and both OLAP fast paths. `min`/`max` are stated and pinned as the standard boolean **ordering** `false < true` returning null over zero non-null values — NOT as an `AND`/`OR` fold, which agrees on populated input but predicts the conventional empty identities (`true`/`false`) where every engine answers null. `sum -> 0` over zero rows is Cypher conformance (SQL returns NULL), not a compromise. Non-boolean aggregates are unaffected: polars already summed every other numeric input to `Int64`/`Float64`/`Duration`, and `FLOAT`/`DURATION` sums are explicitly excluded from the widening. `count()` is now `INTEGER` on polars for every input type, matching pandas/cuDF and Cypher. (Registered on the #1665 per-engine semantics matrix and the #1664 openCypher conformance tracker.) - **Scaling a duration raised instead of splitting a month (#1937)**: `duration('P1M') / 2` and `duration('P1M') * 0.5` declined with a `GFQLTypeError` whenever the result was fractional in month-space, even though `duration('P2M') / 2` answered `P1M` and the `duration('P0.5M')` constructor had always split a fractional month. Scaling now cascades the fractional month DOWN into days at the average month of 30.436875 days (365.2425 / 12, the constant the constructor already used) and on into time, so `duration('P1M') / 2` is `P15DT5H14M33S` as openCypher specifies. Every cascade step truncates toward zero rather than rounding, which also corrects the seconds group by a nanosecond (`duration('PT2S') / 3` is `PT0.666666666S`, previously `PT0.666666667S`) and makes a negative scale the exact mirror of its positive twin (`duration('P1M') / -2` and `duration('-P1M') / 2` both give `P-15DT-5H-14M-33S`). A result that is whole in month-space still stays in month-space — `P2M / 2` is `P1M`, `P1Y / 2` is `P6M`, `P1M * 2` is `P2M` — so the average month only appears where a month genuinely has to be split, and scaling is deliberately not round-trippable there: `(duration('P1M') / 2) * 2` is `P30DT10H29M6S`, not `P1M`. - **`engine='polars-gpu'` ran the Cypher OLAP fast paths on CPU and reported them as GPU (#1824)**: the fast-path call sites pinned the lazy execution target to CPU no matter which engine was requested, and the connected-match-join two-star arms were never wrapped in a target at all, so every fast-path-served OLAP shape collected on CPU polars under a GPU label — undetectable downstream, and exactly the mislabelling `lazy._engine_for`'s `raise_on_fail=True` contract exists to prevent. One shared seam, `_run_fast_path_on_requested_target`, now runs every fast-path arm on the requested engine's target, so an explicit `polars-gpu` collects on the GPU or raises. A plan node cudf-polars cannot execute surfaces as the usual `NotImplementedError` and is treated as a fast-path DECLINE, letting the generic route — itself GPU-or-raise — answer; it is never quietly served on CPU. `engine='polars'`, `engine='auto'`, `pandas` and `cudf` are unchanged (still CPU target, and a `NotImplementedError` there is still a real error rather than a decline). Expect `polars-gpu` to get SLOWER on these shapes: the previous numbers were CPU numbers wearing a GPU label. - **Remote GFQL/Python error surfacing leaked raw plumbing exceptions and could silently bind the wrong table (#1956)**: a shared `remote_response` helper now backs both `chain_remote` and `python_remote`, so a public remote call raises a typed `GFQLRemoteError` carrying the HTTP status and the server's own message instead of a `requests.exceptions.JSONDecodeError` (an error body with a JSON content-type but a non-JSON payload — `JSONDecodeError` subclasses `ValueError`, so the module's own "re-raise our ValueError" arm swallowed the fallback), a raw `HTTPError` (`python_remote` had no non-JSON branch at all, while `chain_remote` wrapped it), a `KeyError: 'edges'` (a 200 whose JSON body is an error document), or an `IndexError` (a zip missing an expected member). The zip handlers no longer build an informative message inside a `try` that catches and replaces it, so the server's real validation text survives. Most importantly, zip member selection no longer guesses: a member whose stem is exactly `nodes`/`edges` wins, and a looser name match is accepted only when it mentions one kind and not the other, so a prefixed `graph_nodes.parquet` still resolves while a compound `nodes_and_edges.parquet` — which previously bound the EDGE table as nodes with no error, and could be bound as BOTH tables at once when it was the only member — is now a typed decline naming the ambiguity. - **Remote GFQL request/result plumbing: NaN leak, dropped `output=`, shape variant without `params`, stranded bindings (#1960)**: non-finite filter values (`float('nan')`, `inf`, and NaN inside a predicate) leaked a raw `requests.exceptions.InvalidJSONError` while every other non-JSONable value already got a typed `GFQLTypeError`; the wire body is now scanned before the POST and declined typed, naming the offending path. `output=` was silently dropped for non-Let queries — it names a binding to return, which a flat chain does not have, so it is now declined with a typed `GFQLSyntaxError` rather than ignored (Let/DAG queries are unaffected). `gfql_remote_shape()`/`chain_remote_shape()` now accept `params` and `output`, so a parameterized Cypher query can be shape-queried and not just executed. A `node_col_subset`/`edge_col_subset` that drops a column the returned graph is bound to now raises a typed `GFQLSchemaError` naming the column, instead of returning a Plottable whose `_node`/`_destination` points at a column that no longer exists. - **`python_remote` rejected the function name its own contract mandates (#1959)**: the callable branch only converted `code` to source when the function was named something OTHER than `task`, so a function literally named `task` — the name `validate_python_str` requires and every doc example uses — fell through as a callable and hit `assert isinstance(code, str)`. Any other name worked. The same path never dedented, so the verbatim docstring example (an indented triple-quoted literal) died in `ast.parse` with `IndentationError`, as did source captured from a nested `def`. Normalization is now one shared `normalize_task_code()` — convert callables to source, rename to `task` only when needed, then `textwrap.dedent` — so name and indentation no longer decide whether the call works. Relative indentation inside the body is preserved, and already-flush source is returned unchanged. - **Remote calls on unsupported frame types decline before the request (#1957 partial)**: `gfql_remote`/`chain_remote`/`python_remote_*` resolved the DataFrame library only *after* the POST, so a polars-backed graph sent the request, let the server do the work (and, with `persist=True`, create a dataset), and only then died with an untyped `ValueError: Unknown DataFrame types`. `python_remote` also inspected only `_edges`, so a polars node frame paired with pandas edges was never caught at all. Both surfaces now resolve the library once, before any request, via a shared helper, and decline with a typed `GFQLRemoteError` (`E404 remote-unsupported-frames`) naming the actual types (`nodes=polars.DataFrame`) and the remedy. The same resolution is reused at decode, so the pre-request check and the reader selection cannot drift. - **Remote `format='csv'` no longer silently rewrites result values (#1958)**: `gfql_remote`/`chain_remote`/`python_remote_*` decoded csv responses with a bare `read_csv`, so pandas/cudf re-inferred dtypes from text: `'007'` came back as `7.0`, `'08'` as `8.0`, and `'NA'`/`''`/`'null'` as `NaN`. Worse, node `id` landed as `float64` while the edge endpoints stayed `int64`, so the returned graph could not join to itself and every downstream local op on it was wrong or empty. csv is untyped on the wire -- `format='parquet'` carries an Arrow schema, csv carries none -- so the client cannot reconstruct the server's schema; csv now emits a `UserWarning` naming the risk and pointing at `format='parquet'` (the default, and faithful), then serves the result -- `format='csv'` keeps working without new required arguments. Callers who want fidelity from csv pass the new `df_import_args` reader kwargs to take explicit control, e.g. `df_import_args={'dtype': {'id': str}, 'keep_default_na': False, 'na_values': []}`, which round-trips values and keeps the node/edge join coherent. (The warning predicate this entry described -- any dict silences it -- was corrected below.) A malformed (non-dict) `df_import_args` is rejected before the request is sent with a typed `GFQLRemoteError` (`E403 remote-format-lossy`, which also subclasses `ValueError`), so a caller typo never costs a round trip or an implicit upload. `format='parquet'` and `format='json'` are unchanged. - **`group_in_a_box_layout()` returned duplicate node ids at conflicting coordinates (#1961)**: the default `bulk_mode=True` path lays out the WHOLE graph in one pass, but `partitioned_layout` then re-appended the singleton (`id_count == 1`), pair (`id_count == 2`) and edgeless (`degree_max == 0`) partitions on top of that already-positioned frame instead of replacing those rows. Every node in such a partition came back TWICE, each copy carrying a different `x`/`y`, so the returned node frame had more rows than it was given, plotted the same node at two positions, and fanned out any downstream node-keyed join; on a fully edgeless graph every node was duplicated. Those small-partition fallbacks exist for `bulk_mode=False` (where `layout_non_bulk_mode` only positions partitions with `id_count > 2 and degree_max > 0`) and are now scoped to it; the bulk pass already positions those nodes, and does so at least as well — pairs now spread across their box instead of clustering at `0`/`0.33`. Two adjacent defects in the same function are fixed alongside: the edgeless branch assigned `x` twice and never assigned `y`, and the NaN backstop asserted the negation of its own guard (`assert combined_nodes.y.isna().sum() == 0` inside `if combined_nodes.y.isna().any()`), so an unpositioned node raised a message-less `AssertionError` instead of reaching the `fillna` two lines below; the cuDF half of that backstop also built a 2-D `cupy` array that `cudf.Series` rejects. Pinned by an id-multiset invariant (output ids == input ids, no duplicates, no drops) across mixed partition sizes on both pandas and cuDF. Unseeded RNG for edgeless placement is unchanged and still undocumented. - **GFQL serialization no longer drops `None` filter values, turning "match nothing" into "match everything" (#1954)**: `_filter_dict_to_json` skipped every entry whose value was `None`, so `n({'x': None})` serialized to `{"filter_dict": {}}` — an unconstrained match. Blast radius was every `to_json()` consumer, not just the remote path: `gfql_remote`/`chain_remote` put `filter_dict: {}` on the wire and asked the server for the entire graph while the identical in-process query returned zero rows; `Chain.to_json()`/`Chain.from_json()` round trips, saved query JSON, and any stored wire form silently widened the same way. The whole family was affected — node `filter_dict`, `edge_match`, `source_node_match`, `destination_node_match` — including Cypher patterns whose value came from a null parameter (`MATCH (a {x: $p})` with `params={'p': None}`). `None` values are now serialized as JSON `null`; `from_json` already revived them unchanged, so local and wire answers now agree. No query that previously answered correctly changes: the only affected inputs are ones whose serialized form did not mean what the caller wrote. - **GFQL `min_hops` prune dropped qualifying branches that end below `max_hops` (#1944)**: `min_hops`/`max_hops` are documented as INCLUSIVE traversal bounds and the prune removes only "dead-end branches that do not reach `min_hops`", but the backward retention walk seeded its targets from the TOP hop level only, then narrowed them level by level. A branch that reached `min_hops` and then TERMINATED — strictly below `max_hops` — was therefore never a target when its own level was processed, so its terminating edge and everything feeding it exclusively were silently pruned. An edge traversed at a level `>= min_hops` now ends a qualifying walk ITSELF and is retained outright; only sub-`min_hops` levels still have to feed a retained longer walk. Landed PAIRED across engines because the polars chain mirror reproduced the identical under-retention: on `hop(min_hops=2, max_hops=3, direction='forward')` from a seed whose branch ends at hop 2, pandas and cuDF dropped both tail edges while LEAKING the tail's endpoint node (an incoherent node/edge frame) and the polars chain dropped the endpoint too; all four surfaces now return the hand oracle. Reverting either engine's half alone turns 31 cross-engine chain/hop parity cells red, which is what pins the pairing. Genuine sub-`min_hops` dead ends are still pruned (anti-vacuity control at `min_hops == max_hops`). - **`gfql_validate` now agrees with execution on unqueryable graph shapes (#1889)**: the validator advertised itself as preflight ("validate without executing") yet returned `{ok: True, diagnostics: []}` for graphs it could not possibly answer — a graph with neither nodes nor edges bound then died at execution with a bare `ValueError: Missing edges` on pandas/cuDF or an empty-message `AssertionError` on polars, and an edge pattern against a graph with no edges bound was declined by the executor (`E304`) while the validator stayed silent. Validator and executors now consult one shared predicate, so both surfaces return the same typed verdict for the same shape: a new `ErrorCode.E305 graph-not-bound` when neither frame is bound, and the existing `E304` when an edge operation meets an unbound edge frame. This is a **behavior change for callers who treated `ok: True` as unconditional**: shapes that previously validated clean and then crashed now report a `GFQLSchemaError` up front, on both the chain and Cypher entry points and on every engine. Nothing that previously executed is refused — queries that answered still answer with identical values; only bare crashes became typed diagnostics. `schema=False` skips the check, so `chain_remote` preflight (whose frames live server-side) is unaffected. - **GFQL Cypher temporal/error-leak family (#1915 B-5/B-7/B-8 + A-4, #1880 temporal half)**: (B-5) literal temporal comparisons are constant-folded engine-agnostically to the openCypher CIP2016-06-14 semantics — zoned instants compare on the UTC global timeline, so `datetime('2020-01-02T05:00:00+05:00') = datetime('2020-01-02T00:00:00Z')` is now `true` on every engine (polars compared rendered text and answered `false`, row-set-visibly in WHERE), while values of DIFFERENT temporal types are never equal and order as null — `datetime(...) = localdatetime(...)` was `true` on BOTH engines and is now `false`, with `<` etc. null. (B-7/#1880) temporal-vs-string comparisons no longer leak raw backend errors: polars filter predicates and scalar equality over Datetime/Duration/Date/Time columns parse the string with the SAME pandas parse the pandas engine applies (`pd.Timestamp`/`pd.to_timedelta`) and answer with pandas-identical rows in that SAFE subset, and otherwise raise the scalar half's typed `GFQLSchemaError` E302 instead of `polars.exceptions.InvalidOperationError`; tz-suffixed ISO temporal text comparisons stay a `where_rows` residual instead of a filter-dict pushdown, so `n.ts > '2021-01-01T00:00:00Z'` answers on pandas/cuDF instead of raising a raw numpy `bitwise_and` TypeError (and its `=` no longer silently matches zero rows); a tz-naive vs tz-aware datetime column pair in a same-path WHERE aligns onto UTC-naive (GFQL reads naive as UTC, matching the row pipeline) instead of raising a raw pandas TypeError, and the forward bounds prune skips incomparable dtypes rather than crashing. (B-8) non-reserved keywords are valid property names — `n.when`, `n.then`, `n.end`, `n.order`, `n.is`, `n.all`, `n.any`, `n.contains`, and `{when: 1}` property maps — via a dot/map-key-context `PROP_NAME` terminal in both grammars; keywords stay reserved everywhere else, and the WHERE-chain grammar keeps the filter-dict pushdown for them. (A-4) UNION branches projecting the same output names in a different order now align by name (Neo4j semantics; the output keeps the first branch's column order) on all three engines, and only a genuinely different name multiset keeps the typed decline. Pinned red-at-master across pandas/polars/cuDF with cross-type fold matrices, parity row sets, typed-decline assertions by name, and mutation-killing guards. - **The remote csv warning was defeated by any dict, and silenced outright on GPU; master shipped a RED cuDF pin (#1958 follow-up)**: three linked defects behind the warn-and-serve `format='csv'` contract. (1) The warning fired only when `df_import_args` was `None`, so `df_import_args={}` or `{'sep': ','}` bought ZERO fidelity -- measured, both still decode `['007', '08', 'NA']` as `[7.0, 8.0, nan]` in `float64` -- while removing the only signal that the frame may be corrupt. The predicate now tracks whether the caller actually took control, per lossy axis, and the two axes are independent: `dtype=str` alone still maps `'NA'`/`''`/`'null'` to `NaN`, and `keep_default_na=False, na_values=[]` alone still reads `'007'` as `7`. dtype inference counts as governed by `dtype` or `converters`; NA substitution by `keep_default_na`, `na_values`, `na_filter` or `converters`; the warning names each axis left ungoverned and clears only when both are, so a partially-controlled read is told which half is still lossy. (2) On a cuDF graph the warning never reached anyone: `lazy_cudf_import()` called `warnings.filterwarnings('ignore')` outside any `catch_warnings`, so the FIRST engine resolution against a GPU frame installed a process-global ignore-everything filter -- silencing this warning and every other library's for the rest of the session. Probing for cudf (and cuml, which leaked a redundant unscoped filter alongside an already-scoped block) now restores the caller's filters. (3) The cuDF pin in `test_remote_csv_fidelity.py` still asserted the pre-#1974 hard decline (`pytest.raises(ValueError)`, `assert not mock_post.called`), so `TEST_CUDF=1` was RED on master; it now pins the shipped contract -- warns, issues the request, returns the rows. No refusal is re-introduced: `format='csv'` still serves, and `format='parquet'` still needs no reader args. - **cuDF test gates are audited and the missing GPU lane is stated, not implied**: no CI lane runs a cuDF arm -- `ci.yml` never sets `TEST_CUDF` and installs no `cudf`, and `ci-gpu.yml` is gated on an unset `GRAPHISTRY_ENABLE_GPU_PUBLIC` variable, needs the `gpu_public` self-hosted runner, and hard-fails any manual trigger -- which is how a cuDF pin asserting a removed contract stayed red on master. A GPU lane cannot be wired from this repo (GitHub-hosted runners have no NVIDIA device, and cudf has no CPU fallback), so the gap is made loud instead: `bin/ci_gpu_gate_audit.py` (new `gpu-gate-audit` lane) counts the cuDF gates (37 across 23 files today), requires each to carry a `reason=` naming `TEST_CUDF` so `pytest -rs` on a CPU lane names what was not run, requires each to actually read the flag from the environment, and cross-checks the DEVELOP.md unprotected-receipts note against whether any workflow sets `TEST_CUDF` -- wiring a real GPU lane retires the note, deleting the note without wiring a lane fails the audit. The audit is static: it proves the gates are well formed, never that the gated assertions hold. DEVELOP.md now says plainly that a `TEST_CUDF=1` receipt is developer-local evidence only. ### Added - **Precomputed in/out degree facts (`DegreeFact`)**: the two-hop `count(*)` kernel spends O(E) per query on a `bincount` plus gather over every edge. With degrees precomputed the identical answer is `dot(indeg, outdeg)` — O(N). Measured at board scale (2.4M edges / 107k nodes): 6.76 ms of query work becomes 0.046 ms, a complexity-class change rather than a constant factor, built once per relationship type. Keyed by `(src, dst, type_column, type_value)` on the existing registry shape, because a typed pattern counts over one relationship type and a global degree array would be the wrong denominator for it. Declines by explicit precondition — absent column, non-integer or null-bearing endpoints, an endpoint outside the proved interval, or an oversized span — and an out-of-interval endpoint DECLINES rather than clamping, since a clamp would silently miscount. Note this is the first fact kind where staleness is a WRONG ANSWER rather than a lost optimization, so the identity+fingerprint guard is the correctness guard here and refuses on any mismatch. Pins: dot-vs-gather-sum equivalence on hand and random graphs, every decline, rebind/engine invalidation, and typed-vs-global key separation. - **Fast-path engagement is visible in `gfql_explain`**: each fast path now records whether it SERVED or declined, with the engine, under `op: fast_path`. Fast paths are contracted "same answer, faster" — every one falls back — so a dead one is otherwise invisible: the query still returns the right result and every value test still passes. `assert_fast_path(g, query, path, served=)` makes engagement assertable against a public surface rather than by monkeypatching private callees, which fails open when another module imported the name directly. Covers the single-hop grouped aggregate, the two-hop count and the seeded typed hop; free outside `index_trace()`. Pinned across pandas/polars/cuDF, including that a short-circuit is distinguishable from a decline (a path never consulted is absent, not False) and that the helper fails when the path did not fire. - **Structured GFQL index-planner diagnostics**: `gfql_explain()` and the planner trace now expose a stable `decision_code` alongside the human-readable `decision_reason`. Codes distinguish policy-off scans, missing resident indexes, unsupported query shapes, missing graph bindings, cost-gate declines, selected indexes, engine mismatches, and unavailable index paths. `index_policy=force` bypasses only the cost gate for an index-coverable shape. Unsupported or unavailable shapes still scan and return the same result. Tests cover no resident index, missing graph columns, automatic cost decline, forced selection, policy-off diagnostics, and scan/index parity. - **Static enforcement for the type-hygiene defect classes that keep failing code review**: five recurring review rejections -- unannotated function contracts, dynamic attribute access, `Any` + call-site `cast()`, unparameterized `list`/`dict`, and a bare `str` where a `Literal` belongs -- are now checked by tooling instead of by a human. `bin/ci_type_hygiene_guard.py` is a stdlib-only AST pass over `graphistry/` (tests excluded, matching `mypy.ini`) wired into `bin/lint.sh`, so it runs in the existing `python-lint-types` matrix on py3.8-3.14 with no new workflow and no new job; it takes ~1.4s and returns byte-identical counts on all seven interpreters. Enforcement is a **per-file count ratchet** against `bin/ci_type_hygiene_baseline.json` -- a file may not gain findings and a file absent from the baseline must have zero -- because the honest measurement is that the codebase carries 1704 missing annotations, 1605 `Any`-in-annotation sites and 959 `cast()` calls, and a rule that flags those as errors today is a rule nobody can turn on. Two checks are effectively hard rules rather than ratchets because their real counts are tiny: `plottable-setattr` has **3** sites repo-wide (one of them is the open bug #1825, where caching by `setattr` onto the caller's `Plottable` keyed on `id()` returned a stale answer after an in-place frame mutation), and `plottable-attr-write` -- the `param.attr = ...` form the same hazard takes when nobody writes `setattr` -- has 39, all in five files. A blanket `getattr`/`setattr` ban was measured and rejected: 300 non-test `getattr` sites are overwhelmingly legitimate optional-attribute reads, and the leak requires a *write*, so only writes onto a parameter annotated as a `Plottable` are flagged. Class 5 gets the narrowest defensible rule rather than a plausible one: `vocab-str-param` knows exactly six parameter names (`table`, `kind`, `direction`, `how`, `mode`, `engine`) whose vocabularies this repo has already committed to as `Literal` aliases, and covers 42 sites; the general "this `str` should be a `Literal`" question was prototyped as a call-site/comparison heuristic, measured at roughly 80% precision over 44 hits, and deliberately **not** shipped -- a column name is legitimately `str`, and a rule that needs manual triage every PR is worse than no rule. Ruff gains `B009`/`B010` (constant-name `getattr`/`setattr`) as genuine errors, with today's 31 offenders across 14 files seeded into `per-file-ignores` and retired one file at a time by `ruff check --fix`. Findings that are genuinely correct are annotated in place with `# hygiene-ok: -- ` rather than by raising a baseline cap. Conventions and escape hatches documented in DEVELOP.md "Type Hygiene Guard". No production code changed. - **Static enforcement for the comment-encoding rules (`bin/ci_comment_density_guard.py`)**: the "Encoding: names, tests, and structure -- not prose" rules in `agents/skills/review/SKILL.md` were the last rule class on the 2026-08 GFQL stack still enforced only by human review, and the only class the owner rejected across four consecutive PRs (#1894, #1895, #1897). Everything else that mattered already had a gate -- `bin/ci_type_hygiene_guard.py`, `bin/ci_cypher_surface_guard.py`, per-file coverage floors, `test_polars_lane_completeness.py` -- so this converts the prose into one. Stdlib-only `tokenize` + `ast` over `graphistry/`, wired into `bin/lint.sh` so it shares the existing `python-lint-types` matrix (py3.8-3.14) with no new workflow and no new job; ~3s, identical counts across interpreters. Three checks: `comment-block` (a run of 2+ adjacent full-line `#` comments -- 3+ for a Sphinx `#:` run, which the owner flagged as a prose loophole), `perf-claim` (complexity notation or performance vocabulary, which belongs in pyg-bench as a measured test), and `issue-rationale` (a standalone comment or docstring citing an issue number as the explanation instead of a test name). `comment-block` is a form rule and reads `#` comments only; the two content rules also read docstrings, because a claim does not become admissible by moving into one. Tests are exempt from `comment-block`/`issue-rationale` -- a test may explain its oracle -- but not from `perf-claim`. Enforcement is a **per-file count ratchet** against `bin/ci_comment_density_baseline.json` (948 `comment-block` / 216 `perf-claim` / 169 `issue-rationale` grandfathered), identical in shape to the type-hygiene guard. Acceptance was measured against reality rather than asserted: replayed over the three rejected branches at the commits the owner reviewed, the guard flags all 16 sites he flagged by hand, and every one of the 9 touched files exceeds its master cap, so the ratchet would have failed each PR. Two false-positive classes were measured and tuned out rather than left as noise: `regress`/`A/B` also name correctness concepts ("regression guard", "when A/B are disconnected") so they count only next to performance vocabulary, and a comment naming `pyg-bench` is a pointer to where the measurement lives rather than a claim -- together 128 of 344 raw `perf-claim` hits. Escape hatch is `# guard-ok: -- ` on the finding or above the run. Conventions in DEVELOP.md "Comment Density Guard". No production code changed. - **Native polars `rows(binding_ops=...)` for UNBOUNDED directed variable-length patterns (`-[*]->` / `-[*0..]->`) (#1709)**: the Cypher multi-alias bindings table already lowered natively for fixed-length and *bounded* variable-length segments, but an unbounded fixed-point segment declined with `NotImplementedError: polars engine does not yet natively support cypher row op 'rows'`. This was the last shape blocking `engine='polars'` on LDBC SNB interactive-short-6 (`MATCH (m:Message)-[:REPLY_OF*0..]->(p:Post)<-[:CONTAINER_OF]-(f:Forum)-[:HAS_MODERATOR]->(mod)`), the one interactive-short query polars could not answer. It now runs natively: a dedup-by-node frontier walk finds the exhaustion depth, then the SAME lazy bounded pair-join loop the `-[*1..k]->` arm uses materializes one row per distinct edge SEQUENCE (Cypher path multiplicity, parallel edges included) — no pandas bridge, no `to_pandas()` round trip. A cycle reachable from the seed means infinitely many paths; that raises the same E108 "require terminating variable-length segments" error pandas raises — same exception class and same `.code` on both engines — and is detected before the path expansion blows up rather than after, bounded by the REACHABLE node count so an unreachable remainder of the graph costs nothing. Still declining honestly (NIE, never a silent answer): UNDIRECTED unbounded (`-[*]-`, needs the min_hops == 1 multiplicity reconstruction plus backtrack-aware termination — pandas rejects it outright), aliased variable-length relationships (pandas rejects those too), unbounded segments WITHOUT `to_fixed_point` (pandas silently truncates at a bound this lowering cannot reconstruct), unbounded segments with `min_hops >= 2` (`-[*2..]->`: pandas' step pairs are pruned by min_hops against a dedup-by-node eccentricity, which this raw-edge reconstruction cannot reproduce — serving it would return a different count with no error), and `to_fixed_point` combined with an explicit bound (declined on master too; it is not Cypher-reachable, since the parser only sets the flag for `*` / `*k..` where there is no maximum, but through the AST surface it hits the same reconstruction gap for `min_hops >= 3`). Cross-engine parity is the gate: differential fuzz vs the pandas oracle over random DAGs and cyclic graphs with self-loops and parallel edges, plus pinned IS6/zero-hop/multiplicity/cycle tests. - **GFQL secondary node property indexes (`create_index('node_prop', column=...)` / `g.gfql_index_node_props([...])`)**: a seed predicate on a NON-key column — `MATCH (m {id: 42})` where the graph's node id binding is some other column — previously cost a full node scan, because the registry only indexed the node-id binding and the CSR adjacencies. A property index is the same pay-as-you-go sidecar as the existing kinds: sorted distinct values over node **row positions** (CSR, so duplicate values are indexable), never reorders `.nodes`, fingerprint-validated so a `.nodes()` rebind is treated as absent (safe miss, never a wrong answer), engine-polymorphic (numpy host / cupy on-device), and policy-gated (`off`/`use`/`auto`/`force`). The seeded fixed-hop planner picks the **most selective** indexed scalar predicate in the seed filter using a free CSR-offset estimate, gathers those candidates, and applies every remaining predicate to them — so results are identical whether the index is present, absent, stale, or cost-gated out. `show_indexes()` lists property indexes; `drop_index('node_prop', column=...)` drops one. Only integer columns are indexable today (float NaN ordering, strings on cupy, and nulls all decline to the scan); widening that is additive. **Perf (dgx-spark, official LDBC SNB SF1, 3.18M nodes, warm median, value-identical 19-row result):** interactive-short IS7 `71.6 ms -> 19.5 ms` (**3.7x**), with a one-time `112 ms` build — the seed lookup itself goes from a `51.2 ms` scan to `0.096 ms`. ### Performance - **The two-hop `count(*)` kernel consults precomputed degrees, replacing its O(E) pass with an O(N) dot.** With per-type degree facts resident, `sum_e indeg(src(e))` is computed as `dot(indeg, outdeg)` over the domain rather than a `bincount` plus gather over every edge — measured at board scale as 6.76 ms of query work becoming 0.046 ms. Values are identical by construction (the degree product is the same quantity) and pinned differentially against the scan on random typed graphs. Two subtleties are pinned because neither is visible to a value test: the consult SLICES the degree arrays to the proved domain interval, where an off-by-one would silently miscount rather than fail; and the fact is validated against the BOUND edge frame rather than the transient per-type subset it was counted over — anchoring identity to the subset makes every lookup miss, so the facts are built and never used while answers stay correct. A gapped node space builds no degree facts at all, since the arrays are indexed by `id - lo` and have no valid indexing there. - **Column-stat fact decisions are visible in `gfql_explain`**: facts are a pure accelerator, so a dead one is INVISIBLE — the query still returns the right answer by falling back to the scan, and every value test stays green while you pay the build for nothing. Each consult now records a decision under `op: col_stats`, distinguishing the cases whose fixes differ: `absent` (no fact for this key), `stale` (a fact exists but the frame was rebound since), `insufficient` (live, but cannot prove what the plan needs — what a whole-frame fact always is against a typed pattern), and `served` (a scan was skipped). Gated by the same `_trace_active()` check the adjacency decisions use, so it costs nothing outside `index_trace()`/`gfql_explain`. This also makes engagement testable against a public surface instead of by monkeypatching private callees — which matters, because patching a name another module imported directly silently fails to intercept. - **Per-type column-stat facts become opt-in, and build in one pass**: per-type facts cost a grouped pass per type column at build time, and only typed count shapes can spend them, so two things change. The edge role now aggregates BOTH endpoint bindings in a SINGLE grouped pass instead of one pass per endpoint. And building them from a bound `GraphSchema` moves behind `col_stats_by_type=` on `gfql_index_col_stats()` / `gfql_index_all()`, defaulting False, so binding a schema no longer changes index cost; under the `label__X` convention the cost scales with label count. The flag is itself an EXPLICIT request, so it RAISES when it can satisfy nothing — no schema bound, or a schema declaring no type column the frames carry — rather than no-oping; partial coverage (a declared label the frame lacks) still skips, since a schema is a contract for the whole graph. Explicit `node_type_column=` / `edge_type_column=` requests are unaffected: asked for by name, still built, still raising when unusable. Values are unchanged in every arm — a regression where the shared pass fingerprinted facts across all aggregated columns (making each look stale on lookup) was caught by the engagement pins and fixed by fingerprinting per column. Pins: single-pass vs per-column equivalence, all-or-nothing decline across columns, and the opt-in gate on both entry points. Measured effects live with the receipts in pyg-bench. - **Per-type column-stat facts (`gfql_index_col_stats(node_type_column=..., edge_type_column=...)`)**: whole-frame facts are provably useless on a TYPED graph — a typed pattern's domain is a strict subset of the node frame (so no dense-interval hint is derivable) and the edge endpoint interval spans every label (so containment can never be proved), which is why the multi-type GraphBench board measured the v1 facts as no-harm/no-help. Facts are now additionally keyed by `(type_column, type_value)`, built one grouped aggregation pass per column, and consulted via the single scalar equality a typed pattern lowers to; whole-frame facts remain the fallback and a miss anywhere still costs the scan, never an answer. The partition gate admits ONLY a lone scalar equality, because a further-filtered domain is no longer the partition and its ids need not stay dense. Per-type requests are made BY NAME, so an unusable one raises rather than silently skipping. Pins: the three-arm gate (no facts / whole-frame / per-type — hint and proof appear only in the last, answer identical in all) across pandas, polars and cuDF; the partition-key admission matrix; extra-predicate hint refusal; unusable-request raise. Measured effects live with the receipted micro in pyg-bench. - **The single-hop grouped-aggregate fast path declares its plan columns (round-4 follow-up)**: the node/edge filters project to the ids plus the props the compiled plan actually references, on every engine, via the same monotone `_filter_project` admission rule the count path uses. A referenced prop missing from the frame stays excluded, so the existing missing-prop decline is untouched. Pins: narrow widths + decoy exclusion per engine with eager-twin value parity; missing-prop decline parity. - **Small-frame gate for the projected filters (fix-forward on round 4)**: the polars lazy filter+select carries a fixed per-call plan cost that small frames cannot amortize — the receipted 20k board caught the q8 cell tripping its locked floor. Frames under a fixed row threshold now filter eagerly and cut columns via a buffer-share select (identical output contract, no added overhead); large frames keep the lazy narrow gather that improved the 100k board. Projection pins unchanged (both arms return exactly the projected columns). - **GFQL verified column-stat facts (`g.gfql_index_col_stats()`, folded into `gfql_index_all()`)**: the index registry gains per-column facts (min/max/null count, integer flag) for the bound node id and edge endpoint columns, under the same identity+fingerprint validity contract as the physical indexes — a rebind is a safe miss, never a stale answer. Consumers use facts conservatively: full-frame bounds contain every subset's bounds and zero nulls on the frame means zero nulls on any subset, so the dense two-hop `count(*)` kernel now skips its O(E) endpoint-bounds scan when valid facts already prove containment, and falls back to the scan (never declines) when they cannot. Integer columns only in v1; unfact-able columns are skipped. Registry copy methods move to `dataclasses.replace` so added fields can never be silently dropped by a copy. Pins: fact build + identity invalidation per engine, both sides of the fact gate (skip with facts / scan without), conservative-miss fallback (out-of-domain value on a filtered-away row must scan and serve), `gfql_index_all` inclusion. This is typed-ontology phase 1's first fact kind; measured effects live with the receipted lanes in pyg-bench. - **The two-hop `count(*)` fast path projects its polars filters to the id columns (round 4).** Every consumer in the count-shaped path reads only the node id and edge endpoint columns, so on polars the node/edge filters run as one fused lazy filter+select each instead of materializing full-width frames. The filter expr is built against the full schema (predicates may reference projected-away columns) via the same validated builder, so the typed error/NIE contract is unchanged; pandas/cuDF keep full width. Pins: projection widths + value on the decoy-column fixture (both engines), narrow-vs-full row parity, and E302 error parity through the narrow lane. Measured effects live with the receipted lanes in pyg-bench. - **The dense-domain two-hop `count(*)` kernel elides its second degree table (round 3).** Profiling the round-2 kernel showed the call dominated by the counting body itself, not the proofs or conversions. One algebraic change, values identical by construction: `sum_b indeg(b)*outdeg(b)` equals `sum_e indeg(src(e))` — each middle node contributes its in-degree once per out-edge — so the out-degree bincount and the aligned product-sum collapse into a single gather-sum of in-degrees at each edge's source. Exactly one O(E) bincount remains, on every backend (numpy and cupy; on cupy this also drops a kernel launch). No new guards, no new state. Mechanism pins updated to the one-bincount shape via the array-namespace spy; a new pin checks the gather-sum against the degree-product oracle on duplicate-heavy random graphs in both the raw and shifted lanes. Measured effects live with the receipted lanes in pyg-bench and the docs board. - **The dense-domain two-hop `count(*)` kernel elides its interval shift (round 2).** Line-level profiling overturned round 1's residual attribution: the dominant cost was materializing two fresh shifted id arrays per call, not the bincounts. Three changes inside the proof-gated kernel, values identical by construction: when `lo >= 0` and `hi+1` fits the table budget the domain guard already enforces, the raw arrays are counted directly (the bounds proof guarantees the `[0, lo)` prefix is all-zero); distant or negative intervals shift through one reused scratch buffer; and the polars bounds proof fuses its six reductions into one parallel select. Lane pins via an array-namespace spy plus both sides of the table-budget boundary; all round-1 parity and decline pins unchanged. Measured effects and the honest hardware-transfer note live with the receipted lanes in pyg-bench (`results/graphbench-board-{20k,100k}-cand-20260803`) and the docs board. - **GFQL fused two-star grouped count: minimal-join plan.** The fused polars lane for `MATCH (p)-[r1]->(i), (p)-[r2]->(c) WHERE ... RETURN keys, count(p)` drops two provably redundant semi-joins (the left-arm shared-domain semi commutes into the inner join that follows it; the right-arm second-leaf semi is subsumed by the unique-keyed group-property lookup join) and folds the eager group_by/sort/head tail into the single collect. The empty-match openCypher n=0 probe rebuilds restricted counts on its branch only, and grouped LIMIT 0 keeps the eager tail; both pinned. Values are byte-identical to the pandas oracle and the forced-decline eager twin. Measured effects live in the receipted lanes in pyg-bench (`results/graphbench-board-{20k,100k}-cand-20260803`) and the docs board in `docs/source/gfql/performance.rst`. - **GFQL fused single-hop grouped aggregates (graph-benchmark q1/q3/q4 class) stop paying for domain semi-joins their property joins already subsume.** The fused polars lane emitted both endpoint domain semi-joins unconditionally, then inner-joined the SAME filtered node frames for the property lookups. When an alias carries property columns, that inner join keeps exactly the member rows the semi-join keeps — null endpoint ids match neither join, a semi-join never multiplies, and the inner join's multiplication on duplicate node ids happens with or without the semi-join in front of it — so the semi-join is provably pure cost, and polars 1.42 does not eliminate it. An endpoint's semi-join is now emitted ONLY when its alias has no property join (e.g. the `count(*)` start arm of q4, where it is the sole membership restriction). The proof is algebraic subsumption, not a data property, so there are no decline guards and no new kernel; value identity is pinned by the existing 10-graph × 14-shape fused-vs-eager-vs-pandas differential matrix plus a new plan-shape pin (a domain semi-join is emitted iff its alias carries no property join). On the receipted DGX candidate lanes (combined publication build `938f22851` = master `f875724ce` + the four graph-benchmark perf branches; pyg-bench `results/graphbench-board-{20k,100k}-cand-20260803`), the comparator's verdicts move: q3@20k `TIE` (polars 5.80 ms) → `WIN 1.41x` (4.60 ms), q4@20k `LOSE 1.19x (WEAK: slot ranges overlap)` (3.97 ms) → `WIN 1.13x` (2.88 ms), q1@20k `WIN 1.71x` (8.95 ms) → `WIN 2.01x` (7.54 ms); at 100k q1 `WIN 4.91x` → `WIN 5.77x`, q3 `WIN 2.51x` → `WIN 3.45x`, q4 `WIN 1.28x` → `WIN 1.36x`. - **GFQL two-hop `count(*)` (graph-benchmark q8 class): a proof-gated dense-domain kernel replaces the domain-restriction semi-joins.** The equal-domain branch of the two-hop count fast path (`MATCH (a {t})-[{r}]->(b {t})-[{r}]->(d {t}) RETURN count(*)`) spent its time restricting the rel-filtered edges to domain × domain — two semi-joins on polars, two isin masks on pandas/cuDF — not counting. When the domain's ids form a dense integer interval `[lo, hi]` (`n_unique == hi - lo + 1`) and both endpoint columns are provably integer, null-free and bounded by `[lo, hi]`, interval membership IS set membership: the restriction is provably the identity and the degree product collapses to two O(E) bincounts plus one O(N) aligned product-sum. The proof — a handful of O(E) min/max reductions — is a data property, not a query-shaped hack (dense type-partitioned ids are the idiomatic multi-table graph encoding); any failed guard declines to the existing memoized semi-join path unchanged (out-of-domain endpoints, gapped/non-integer/null ids, empty edges, memory guard). Engine-polymorphic via the index module's array helpers (numpy host for pandas/polars/polars-gpu, cupy for cudf). When the kernel serves, no cross-call memo is written: one-shot and warm calls converge, removing the #1825 binding sensitivity for this shape. Values byte-identical vs the forced-decline semi-join path; every decline guard is pinned. On the receipted DGX candidate lanes (combined publication build `938f22851` = master `f875724ce` + the four graph-benchmark perf branches; pyg-bench `results/graphbench-board-{20k,100k}-cand-20260803`), the comparator's q8 verdict moves from `LOSE 2.94x` (polars 8.24 ms) to `WIN 1.27x (WEAK: slot ranges overlap)` (2.22 ms) at 20k, and from `LOSE 2.44x` (33.25 ms) to `LOSE 1.52x` (14.72 ms, with the round-2 shift elision) at 100k. - **GFQL compile: the row-expression Transformer class is built once per process, not per parse** (2.5-2.8x compile on the cached-parser path): `@dataclass` re-executed its generated `__init__`/`__eq__` source on every rebuild, ~40% of compile time. Instances are still created per parse, so nothing stateful is shared; the class cache is registered as a process singleton in the GFQL cache registry with a survives-clear pin. - **Compiled Cypher plans are shared process-wide instead of per-`Plottable`, so a one-shot query stops recompiling a plan the process already has.** `compile_cypher_query(parse_cypher(query), params=..., node_dtypes=...)` never sees the graph, so its result is a pure function of the query text, params, node dtypes and resolved engine. The cache nevertheless hung off the caller's `Plottable` by `setattr`, which partitioned it by something that cannot change the answer: the *second* query on a graph was fast and the *first* was not, and a one-shot query was always the first. Measured on dgx-spark under the perf lock at graph-benchmark 20k, that cost the first query on a `Plottable` **+1.6 to +2.5 ms (+21% to +52%)** versus the second, on q3/q4/q5/q7/q9 alike, while a bind-only control cost 0.02 ms. The cache is now a bounded module-level LRU (128 entries) under a lock, keyed by `(language, query, params, node dtypes, resolved engine)`; values are `@dataclass(frozen=True)` chains and plans, so no DataFrame is reachable from a cached entry and a process-lifetime cache cannot pin user data. Removing the `setattr` also drops one of the three `plottable-setattr` sites the type-hygiene guard tracks. Position-balanced A/B against master `99c149758` (Kuzu 0.11.3 in the same session, cold binding, RUNS=51, 4 slots per arm, per-slot medians, values byte-identical on every arm) moves **every** cell at both scales by +0.6 to +2.7 ms and changes four verdicts: at 20k the board goes 4W/1T/4L to **5W/2T/2L** (q5 loss to win, q4 loss to tie) and at 100k 5W/2T/2L to **6W/2T/1L** (q4 tie to win, q7 loss to tie). - **The polars two-hop `count(*)` builds ONE lazy plan instead of five eager collects**: `MATCH (a {..})-[{..}]->(b {..})-[{..}]->(c {..}) [WHERE ..] RETURN count(*)` lowers to a fast path that computes the answer as a degree product — semi-join each edge arm against its two node domains, count in/out degree per middle node, sum `in*out`. Every one of those ops was issued EAGERLY, i.e. as its own `lazy().collect()`, so each intermediate materialized in full and with every edge column still attached, and no filter or projection could be pushed across an op boundary: profiled on the 20k graph-benchmark dataset the four-semi-join chain built a **199,939-row** wide frame that the degree join immediately reduced to 120,586, and 88–96% of the query's wall time sat inside those collects. The DISTINCT-domain case — the three node domains and/or the two edge matches are not all equal, which is what a `WHERE` on the middle or end node produces — is now expressed as a single lazy plan collected once, so polars pushes the src/dst projection into the semi-joins. The algebra is unchanged (same semi-joins, same `group_by().len()`, same `(in*out).sum().fill_null(0).cast(Int64)`), so the value is identical including openCypher's count-over-no-rows `0` on an empty match. The EQUAL-domain case is deliberately NOT routed through it: that shape takes a degree-count branch whose counts are memoized on the Plottable across calls, and fusing it would trade a cross-call cache hit for a per-call replan — it is byte-identical to before, and a test asserts the fused lane is not even *called* for it. The lane DECLINES (falling through to the untouched eager code, never answering differently) for non-eager-polars frames and for an edge column already carrying a degree-counter name. Not a GPU change: like the eager code and the fused two-star lane, it collects on CPU polars for both `polars` and `polars-gpu`. **Measured** on dgx-spark, matched cross-engine graph-benchmark lane (all nine OLAP queries, same query text per engine), one perf lock per scale, position-balanced `K M S S M K` slots ×2, per-slot medians (never best-of), rows and canonical values compared on every cell: **`engine='polars'` distinct-domain two-hop `count(*)` `16.91 → 10.02 ms` at 20k (−40.7%) and `68.04 → 37.96 ms` at 100k (−44.2%)**, per-slot ranges non-overlapping at both scales (20k 15.90–18.05 vs 9.23–10.29; 100k 65.9–69.7 vs 36.5–39.3). Against same-session embedded Kuzu that turns the 20k cell from a 1.55× LOSS into a **1.09× WIN** (Kuzu 10.90 ms, ranges non-overlapping) and widens the 100k cell from a 1.25× win to **2.23×** (Kuzu 84.73 ms). The equal-domain cell is UNCHANGED by this PR at both scales (20k 2.44 → 2.30 ms, 100k 5.46 → 5.62 ms — ranges overlap = TIE, which is the point of the measurement). That cell's headline number is NOT one-shot-honest and this changelog does not claim it as a win: the degree counts it reports are memoized across calls onto the caller's `Plottable` (#1825), so a warm repeat call is fast while a one-shot query loses to embedded Kuzu; this PR neither causes nor fixes #1825. What this PR does do for that shape is make its MEMO-MISS branch — the branch a one-shot query, a rebound `Plottable`, or any future removal of the cross-call memo actually runs — build the two degree frames as ONE lazy plan (`collect_all` over a shared filtered-edge sub-plan) instead of materializing the whole filtered edge frame and grouping it twice eagerly. Same algebra, same values; the memo HIT returns before reaching it, so a warm call is untouched. **Measured** the same way (dgx-spark, perf lock, position-balanced `A B B A B A A B` slots, per-slot medians, 21 runs + 5 warmup per slot, value- and row-identical across arms): equal-domain memo-MISS **`10.94 → 8.25 ms` at 20k (−24.5%, ranges 10.24–11.10 vs 7.65–8.71, non-overlapping)** and **`48.27 → 33.13 ms` at 100k (−31.4%, 47.65–48.89 vs 32.68–33.66, non-overlapping)**; the same query issued ONE-SHOT on a fresh `Plottable` goes `12.19 → 9.18 ms` at 20k and `49.67 → 34.62 ms` at 100k. The memo-HIT cell (20k 1.84 → 2.14 ms, 100k 5.41 → 5.84 ms), the distinct-domain cell, and a `bind_only` control all come back as TIEs, which is what shows the change is confined to the miss branch. Every other cell on both engines is a TIE. The pandas arm — untouched by this change — reproduces the reference board to +0.3%…+4.1%, which is what shows the harness matches it. Value identity is the gate throughout: a differential over 972 query shapes × 6 graphs (5,832 comparisons, 4,815 with the fused lane engaged) found zero divergences from the eager code or from the pandas oracle, and pinned tests cover multiplicity (parallel edges, self-loops, duplicate node rows), empty matches, non-numeric ids, degenerate column bindings, and both declines. - **A single-key pure `count(*)` with provably LOW group cardinality skips polars' partitioned group-by**: inside the fused single-hop grouped-aggregate lane, `group_by(maintain_order=True).agg(pl.len())` carries a FLAT ~2 ms coordination cost that exists only at low group cardinality — measured on dgx-spark (polars 1.35.2, 20 threads, interleaved, 90 samples/arm/cell, 214 cells, ZERO value mismatches), int keys at 20,000 rows go 32 groups `2.054 ms` → 48 groups `0.411` → 64 groups `0.291`. `value_counts` has no such cost, so for a pure `count(*)` it is the same value for a fraction of the time. It is NOT a drop-in: `value_counts` scales WORSE with input rows and at 1,000,000 rows loses even at 2 groups (`4.137 → 8.591 ms`), and applied ungated the identical formulation makes the matched graph-benchmark **q1** cell (~20,000 groups over ~200,000 rows) **2.7 ms slower at 20k and 8.6 ms slower at 100k** — q1 is a cell that currently wins, so an ungated swap trades one cell's loss for another's regression. The formulation is therefore chosen only behind two STATIC, O(1), **upper** bounds: group cardinality ≤ the HEIGHT of the alias node frame supplying the group key (every group value is a property value of some row of that one frame, so distinct values cannot exceed its height), and aggregate input rows ≤ the height of the already-filtered EDGE frame (the semi-joins only remove rows; the property inner-join can multiply them, so the row bound is only claimed once exactly one alias carries properties and its node ids are unique — a check that runs on a frame already known to be ≤ 32 rows). Both bounds over-estimate, and over-estimating is the safe direction: a loose bound can only DECLINE a shape the fast formulation would have served, never route a high-cardinality aggregate into it. The thresholds — **32 groups and 100,000 rows** — were fixed from the crossover curve BEFORE the formulation was validated on any query, because a threshold chosen after seeing the verdicts is unfalsifiable; 48 groups already fails at 0.96× and 150,000 rows at 0.80× on string keys. Strictly additive: every decline falls through to the untouched `group_by`, so the blast radius is a decline away from zero. It DECLINES a non-single-key or non-pure-`count(*)` aggregate (including `count()`, which counts non-null values rather than rows), a group key not supplied by exactly one alias, a second alias also contributing property columns, a group-key alias frame that is too tall / missing its node-id column / carrying duplicate node ids, and an edge frame over the row bound. **Measured** on dgx-spark under the exclusive perf lock, matched graph-benchmark q1–q9 lane on the canonical query text (`gb_queries.py`, md5 `6e7ae268a5a41742587fcb87854b6e27`), 24 position-balanced slots per scale (12 per arm), Kuzu 0.11.3 re-run in-session, per-slot medians: **q4 at 20k `4.96 → 3.73 ms` (−1.23 ms, −24.7%), the two arms' slot ranges NOT overlapping**, which takes the board's last 20k loss from `1.65×` to `1.25×` of same-session Kuzu. Under the board's overlap rule that scores a TIE rather than a loss, but the overlap is **0.015 ms** and the median still favours Kuzu, so it is reported as *a loss narrowed to near-parity, not parity*. Engagement is exactly one cell: on the real board data the gate is consulted for q1/q2/q3/q4 and **admits only q4 at 20k** — q4 at 100k declines because its 7,117-row City frame carries only 3 distinct countries and an O(1) height bound cannot see the 3, and q1/q2 decline on BOTH bounds at both scales. q1, q2, q3 and q8 are arm-vs-arm ties with overlapping ranges, and the pandas arm — which never enters this polars-only lane — ties on all nine cells at both scales, a built-in null control. Value identity is the gate on the number: one canonical value per query across every slot, both arms, both engines and both scales, matching Kuzu on every cell. - **The polars single-hop GROUPED AGGREGATE builds ONE lazy plan instead of ~7 eager collects**: `MATCH (a {..})-[{..}]->(b {..}) [WHERE ..] RETURN . AS k, AS v ORDER BY .. [LIMIT n]` lowers to a fast path that semi-joins the edge frame against both node domains, inner-joins the projected properties on, groups, sorts and limits. Every one of those ops was issued EAGERLY — each its own `lazy().collect(_eager=True)` — so each intermediate materialized in full, the `select([src, dst])` projection could not be pushed into the semi-joins, and the `head()` could not reach back into the plan at all. That path serves three of the nine matched graph-benchmark cells (q1, q3, q4), and 74–96% of each of those queries' wall time sat inside those collects. The same op sequence is now expressed as a single lazy plan collected once — the algebra is character-identical (same `.unique()` id frames, same semi-joins, same un-deduplicated property lookups, same `group_by(maintain_order=True).agg(..)`, same per-key `nulls_last` sort, same `head`), so the value is identical, row ORDER included. The lane is strictly additive: the eager code is untouched and is the fallback on every decline. It DECLINES — never answering differently, only forgoing the speedup — for a non-eager-polars input frame, a property column missing from its alias' node frame (the eager twin discovers that MID-CHAIN and declines the whole fast path, so the guard is hoisted ahead of plan construction rather than left to be discovered after a plan already exists), source and destination bound to the same edge column, a projected column colliding with an endpoint column or the internal lookup key, an untranslatable aggregate, and — the correctness crux — **a result row order that ORDER BY does not fully determine**. Without every group key in the sort, the eager twin's order falls back to `maintain_order=True` group first-appearance order over an EAGER join output, which a lazy plan is free to change by re-ordering or re-siding joins; measured with an ungated variant of the same plan over 4 graph sizes × 4 seeds × 4 order-undetermined shapes, 47 of 64 comparisons diverged from the eager twin, and under `LIMIT` the divergence is a different ROW SET rather than a different row order. Not a GPU change: like the eager code and the fused two-star lane, it collects on CPU polars for both `polars` and `polars-gpu`. **Measured** on dgx-spark, matched graph-benchmark q1–q9 lane, one perf lock per experiment, master tree vs PR tree position-balanced `M P P M P M M P`, per-slot medians (never best-of), rows and canonical values compared on every cell, and replicated end to end in a second independent run: **`engine='polars'` q1 `13.31 → 8.96 ms` (−32.7%), q3 `8.54 → 5.53 ms` (−35.3%), q4 `6.99 → 5.05 ms` (−27.8%) at 20k**, per-slot ranges non-overlapping on all three in both runs; at 100k q1 `42.59 → 31.45 ms` (−26.2%) and q4 `12.19 → 10.43 ms` (−14.4%), with q3 `−9.1%…−13.0%` (non-overlapping in one run, overlapping in the other). Against same-session embedded Kuzu at 20k that widens q1 from a 1.12× win to **1.66×**, moves q3 from a **1.37× LOSS to a TIE** (Kuzu 6.29 ms; the two slot ranges overlap, so it is a tie and not a win), and narrows q4 from a 2.10× loss to **1.51× — still a loss**. The cells this lane is not called for are unchanged: q5 and q8 are ties at both scales in both runs. **Correction (#1825):** the claim originally made here that q8 *"stays a win over Kuzu"* does not survive re-measurement and is withdrawn. That cell's headline number is a CROSS-CALL MEMOIZATION artifact — the two-hop equal-domain degree counts are cached onto the caller's `Plottable` keyed by `id()`, so a warm repeat call is fast (2.02 ms @20k / 5.09 @100k) while a cold or never-warmed one is not (13.18 / 49.68 and 14.60 / 53.02 fresh), with a `bind_only` control at 0.02–0.04 ms ruling out re-binding as the confound. A ONE-SHOT q8 loses to embedded Kuzu by 3.1–5.2× at 20k and 2.8–6.0× at 100k. The measurement this entry actually reports — that this lane leaves q8 unchanged — is unaffected by the correction. The withdrawal is carried out in the published data rather than footnoted onto it: the two `graphbench.{20k,100k}.q8.polars_vs_kuzu` ratio cells are DELETED (the docs data contract refuses a ratio whose operands are not established as comparable, which is the right rule — a caveated ratio is still a ratio someone will quote), while the raw per-engine q8 figures remain as non-quotable, non-comparable cells carrying the disclosure. `gfql/performance.rst` no longer reads "GFQL wins q8". The pandas arm — untouched by this change — reproduces the reference board to +0.8%…+9.4% and same-session Kuzu reproduces it to −2.2%…+1.5% on the cells at issue, which is what shows the harness matches it. Value identity is the gate throughout: a differential over 19 shapes × 10 graphs, compared ROW-ORDER and COLUMN-ORDER sensitively against both the eager code and the pandas oracle, found zero divergences from the eager code; pinned tests cover multiplicity on BOTH arms of the hop (duplicate node rows, parallel edges, self-loops), null placement on group keys and on aggregate values, empty matches, dangling endpoints, non-numeric ids, degenerate column bindings, and every decline. One PRE-EXISTING divergence is disclosed rather than quietly changed: the polars property lookup is not deduplicated by node id while the pandas one is, so a node table carrying the same id twice multiplies matched rows on polars only — the fused lane reproduces the eager polars answer exactly, and a test pins both sides. - **Plan-time constant folding for GFQL row expressions, and one canonical residual shape (#1800)**: the Cypher lowering serializes every row predicate it cannot push into `filter_dict` back to canonical predicate *text*, and both the row evaluators and the connected-join fast-path residual translator consume that text. There was no constant folding, so `toLower(i.interest) = toLower('Fine Dining')` and `toLower(i.interest) = 'fine dining'` reached those consumers as two different strings for the same predicate — and the fast-path translator recognized only the first. Since a *single* untranslatable residual declines the **entire** fused single-collect two-star plan, the second (equally idiomatic, and the spelling the graph-benchmark suite uses) dropped the query onto the eager per-op-collect path and onto the `where_rows` chain evaluator for every alias. A new pass (`graphistry/compute/gfql/expr_const_fold.py`) now evaluates pure, deterministic, literal-only sub-expressions at plan time, bottom-up, so the two spellings collapse into one and the translator learns a single shape; the residual matcher is correspondingly *narrower* than before, and covers `toLower`/`lower`/`toUpper`/`upper` uniformly rather than `toLower` alone. Folding is gated by a stated criterion, not a list: a call folds only if it is (P) pure and deterministic, (A) argument-closed after bottom-up folding, (E) **engine-invariant on those argument values**, and (T) total on them. (E) is load-bearing rather than ceremonial — pandas>=3 defaults to an Arrow-backed `str` dtype whose `utf8_lower`/`utf8_upper` are SIMPLE per-codepoint case mappings where polars' and Python's are FULL ones (#1802), so `toUpper(n.name) = 'STRASSE'` already answers differently on the two engines — and the region where Python, Rust (polars), Arrow (pandas>=3), libcudf and Java (the Cypher reference) provably agree is ASCII. **String folds therefore require ASCII arguments and decline otherwise**, which is a deliberate narrowing: the previous two-sided fast path compared a polars-lowercased COLUMN against a Python-lowercased LITERAL, an unproven Rust-vs-Python case-table assumption that a non-ASCII literal now declines out of instead of guessing at. Nine functions fold: `toLower`/`lower`/`toUpper`/`upper`/`size`/`substring`, plus `head`/`tail`/`reverse` — which on GFQL's surface are STRING operations (`row/dispatch.py` implements them as `.str.get(0)` / `.str.slice(start=1)` / `.str[::-1]`, and `eval_sequence_fn_scalar` checks `isinstance(value, str)` FIRST for `reverse`) and therefore fold under the same ASCII gate as the case folds; their LIST overloads parse to a `ListLiteral` argument, which the per-call argument guard already declines on its own. **Every decline is now filed under the MECHANISM that stops it, and every mechanism carries a witness a test executes**, to a stated bar: a disqualifier with no constructible violating expression is a guess, not a criterion, and the function either folds or its reason is restated as POLICY rather than CORRECTNESS. Three mechanisms have witnesses. (1) The **aggregates** — the only genuinely load-bearing deny-set here, because `count(1)` is argument-closed and `int`-valued and would sail through every structural guard the driver has; the witness is that the same call answers `1` over a one-row match and `12` over a twelve-row one, so folding it to a literal would be wrong. (2) The functions that are **not argument-closed in the shape the lowering emits** — `keys(n)`, `labels(n)`, `type(e)`, the internal `__node_entity__(a)` markers, and the quantifiers, which parse to a `QuantifierExpr` and so never reach the name lookup at all; the witness is the parsed node. (3) The functions whose literal-only call the engine answers with a **type the driver's contract guard rejects**: `sqrt`/`floor`/`ceil`/`ceiling`/`round`/`toFloat` return `float`, `toBoolean` returns `bool`, `range` returns a `list`, and the internal simple-CASE marker returns `bool` — so even a perfect folder could not fold them, and the witness is the engine's own value. (That is also where the `round` entry gets put in proportion: its neo4j-tie / JDK-6430675 reasoning is real and lives in the row kernel's docstring, but it does no work in this pass, because `round(1.5)` is `2.0` and the guard rejects it before any tie rule can matter.) Two groups have **no witness and now say so** rather than dressing a preference up as a correctness claim: `abs`/`sign`/`coalesce` are declined **by POLICY** — their literal-only value is guard-passing and identical to a plain Python fold, so folding could not change an answer, and the perf gain on literal-only arithmetic is nil — and `toString`/`toInteger` are labelled **UNVERIFIED**, because the claimed cuDF-vs-pandas float→string divergence could not be reproduced on any engine a CI lane here runs; a test asserts agreement on every engine it CAN reach, so a GPU lane would either turn that claim into a real witness or expose these as policy declines too. This replaces a 40-entry free-text reason table that nothing read — `fold_constants`' only name-keyed gate is the registry lookup, so the table could be neither right nor wrong — and three of its entries (`head`/`tail`/`reverse`) were in fact misclassified, citing an argument-closure reason that describes only their list overload. The classification is still enforced as a **partition of GFQL's entire Cypher function surface** by a test, so a newly added function cannot silently default to any side, and a folder that raises is treated exactly as a decline — a fold can never turn a runtime error into a plan-time crash. Each foldable entry carries three tests: the literal-only call folds to a PINNED literal, the same function with a non-literal argument comes back untouched, and — the one that actually protects the change — the folded and unfolded plans answer identically on the same data, per engine. One asymmetry is disclosed rather than smoothed over: on polars the UNFOLDED spelling of `head`/`tail`/`reverse` over a literal has no native row-op lowering and raises, while the folded spelling is a plain literal comparison it runs natively, so on that engine the pass WIDENS native coverage rather than merely renaming a predicate. A `$param` is substituted before the pass runs, so a parameterized `toLower($p)` canonicalizes exactly like a written literal and reaches the same fast lane; that is safe because the compiled-plan cache already keys on the parameters as well as the query text, and a test pins it end to end across engines. Measured on the graph-benchmark board's own query text (dgx-spark, polars, perf lock held, Kuzu 0.11.3 re-run in the same interleaved session): see the PR for the per-slot numbers. - **Native polars chain combine is proportional to the traversal result, not to the graph**: two graph-sized terms sat inside a combine whose answer is a handful of rows, and both are gone. (1) `_combine_edges` ran the prev/next endpoint gates for EVERY step, including the node steps whose edge frame is `g._edges.clear()` — zero rows. The eager combine skipped those, but the collect-once rewrite lazified the step frames and `.lazy()` erases the height, so the skip silently went dead. The cost lands on the side that is NOT empty: for the first step the gate's key side is the whole node table, and polars builds the hash table on that side before discovering the probe side has no rows (isolated: 6.99 ms for one such join at N=2M, and a chain pays one per node step). The pre-lazy row count is now recorded when the step frame is still eager and an empty step is dropped from the id union — it can contribute no ids, so the result is unchanged by construction. The skip keys on KNOWN-empty only; a frame that arrives already lazy reports no height and is planned normally. (2) The output node rows were materialized in TWO passes over the node table — one for the ids the steps kept, one more for the surviving edges' endpoints the first pass missed — then concatenated. The output node set is the UNION of those two id sides, so the ids are unioned first and the node table is read ONCE. The row-level `unique(subset=[node])` is preserved verbatim: those rows feed `how="left"` alias joins where a node table carrying the same id twice would multiply rows. Measured on synthetic LDBC-IS5-shaped graphs (one-row answer, polars-engine resident indexes), varying one dimension at a time: **at fixed E=2M, N=250k → 4M went 10.17 → 45.48 ms before and 7.60 → 17.17 ms after (2.65× at 4M, and the node-count slope is 3.7× flatter)**; the edge-count slope is unchanged, as expected for a node-side fix, with the constant ~6 ms lower. Parity: identical full frames (all columns, row order included) across 280 shape × graph combinations and 400 duplicate-node-id combinations, plus the 1003-case polars chain differential suite. Pinned by tests that assert the boundary rather than a wall clock: the node universe must not appear in the edge plan at all, and the node table must be read at most once per query. - **GFQL polars chain stops deduplicating semi-join key sides**: the native polars executor applied `.unique()` to every frame it fed into a `how="semi"` join. A semi-join emits a left row iff at least one matching right row exists, so duplicate keys can neither change which rows come back nor multiply them the way an inner join would — the deduplication was a full hash pass over the key column bought for no observable effect. On an unfiltered hop the key side **is** the node table, so this put **O(N) work inside a query whose answer is O(degree)**: the seeded single-hop plan built two such key frames per hop, each costing ~53 ms at 3.18M nodes — more than the rest of the query combined. The `.unique()` is now dropped everywhere the frame is provably a semi key side only (the `_semi` helper, the alias hop-window and next-edge endpoint gates, the two-hop fast path's endpoint gate, the `start_nodes` gate, the single-hop planner's id frames, and the index layer's `select_by_ids` polars branch — where the cuDF and pandas branches already used `isin` with no dedup, so the three engines now agree). It is deliberately **kept** on the alias frame that feeds a `how="left"` join, where duplicates genuinely would multiply rows, and the eager multi-hop loop is untouched (its frames also flow into concat/anti-join bookkeeping, a separate argument). Measured on a 3.18M-node / 14M-edge polars graph (LDBC SNB SF1-shaped), a seeded typed hop goes **127.4 → 57.1 ms (2.23×)** with identical row counts; at 1.75M edges **102.8 → 31.4 ms (3.28×)**, confirming the removed cost scales with node count, not edge count. Parity held across 380 differential comparisons covering duplicate node keys, null ids, dangling edges, duplicate `start_nodes`, and 11 traversal shapes; the gfql and chain suites show identical failure sets before and after. Pinned by tests that assert the boundary rather than the speed: duplicates reaching a semi key side must not change results or multiply rows, a dangling endpoint must still be excluded (the gate is load-bearing, not vacuous), and duplicate `start_nodes` must be inert. - **Seeded chain combine stops joining against the full frame when the intermediate is empty**: `_lean_prefilter_right` shrinks the big side of the combine's `how='left'` merge to the keys actually present on the left, but it declined to do so in the one case where shrinking is both maximally profitable and trivially correct — an **empty** left. A left merge keeps only the right rows that match, so a zero-row left yields a zero-row result whatever the right side holds; the merge was nonetheless materializing the whole graph-sized frame. It now hands back a zero-row slice of `right` (same columns and dtypes, so the merge still produces an identical schema). Measured: a single-node query whose 0-row intermediate was joined against 14M edges went **112.64 → 17.29 ms**. The shrink is used at exactly one call site, which is `how='left'`; a merge that retains unmatched right rows (`right`/`outer`) would NOT be safe to shrink this way, and the tests pin both directions — the empty-left case must return an empty result, and the non-empty cases must be byte-identical to the unshrunk merge. - **Native polars chain reuses the synthetic edge id as its stable row order**: when the executor had already added a synthetic edge-index column it also added a second, separate row-index column purely to restore input order at the end — two full `with_row_index` passes over the edge frame, and a redundant column carried through every intermediate. The existing synthetic id is already a contiguous, order-preserving row index, so it is now reused as the sort key and dropped once at the end. Only the pre-existing column is reused; when no synthetic id was added the separate order column is still created, so graphs that bring their own edge id are unaffected. Value-identical including row order (148.22 → 135.43 ms on a 3.18M-node / 14M-edge polars graph). - **GFQL indexed typed-edge traversals stop scanning the whole edge frame (#1658)**: the index path built the simple-equality `edge_match` mask over ALL `E` edges — one `(series == val)` over the full column — and then read that mask only at `rows[edge_keep[rows]]`, the handful of positions the CSR adjacency lookup had already returned. That put an **O(E) predicate scan inside an O(degree) traversal**, which is why an indexed seeded typed hop scaled with the graph while the underlying `g.hop()` stayed flat. The predicate is now evaluated on the gathered candidate rows instead: `_build_edge_row_filter` does the schema-level validation up front (the dtype-mismatch decline that keeps error parity with the scan is O(1) and unchanged) and `_EdgeMatchRowFilter.mask_for(rows)` applies `col == val` to just those rows, using each frame's native `==` exactly as before (so cuDF string columns stay on the cuDF layer). This is a cost tradeoff, not a free win, and it is bounded rather than assumed: candidate-row evaluation beats one whole-column compare only while the candidates stay a small fraction of the frame, and a fixed-point walk that reaches most of the graph inverts it — the out- and in-indices are filtered separately, so it can gather up to 2E elements by random access against the eager form's single sequential pass over E (measured before the guard: an undirected `to_fixed_point` typed walk gathered 1.94×E and ran 1.2–1.6× SLOWER than master). A cumulative-cost guard now switches to the whole-column mask once gathered rows reach E/8, so total predicate work is bounded at ~1.125×E in that regime while a seeded hop — which gathers ~degree — never approaches the threshold and never builds it. An evaluation failure mid-traversal abandons the indexed path entirely so the scan fallback stays parity-safe. Measured on a 3.18M-node / 14M-edge polars graph (LDBC SNB SF1-shaped), a seeded typed-edge query with alias markers goes **309.9 → 57.4 ms (5.4×)**, value-identical, with the full-column compare (previously 248 ms, 81% of the query) gone from the profile. Parity held across 650 differential comparisons — pandas + polars × 13 traversal shapes × 25 random seeds on a dense graph with null-carrying string and numeric columns — comparing columns, dtypes, row counts and full content, including the decline paths (membership `edge_match` still routes to the scan; a dtype mismatch still raises the same `GFQLSchemaError`). One honest limit on error parity: only the O(1) dtype gates are parity-exact. A *data-dependent* comparison failure (e.g. a list-valued cell in an object column) is now observed only if it lands in the gathered candidates, so the indexed path can succeed where the scan raises. That needs pathological cell values to reach, but it is a real narrowing of the previous guarantee. Pinned by two structural regression tests that assert the *shape* rather than a wall-clock number: the predicate must see only candidate rows, and growing the graph 8× at fixed degree must not grow the number of rows it examines. - **GFQL indexed fixed-hop bindings now dispatch BEFORE the canonical traversal (pandas / cuDF / native Polars)**: the resident-index fixed-hop path (`rows(binding_ops=...)`, the Cypher multi-alias `MATCH … RETURN` shape) was consulted only *inside* row materialization — after the engine had already run the full canonical traversal — so its compact path bag was computed on top of the graph-sized work it exists to avoid. Both the pandas/cuDF chain boundary and the native Polars chain now ask the same shared, structural gate first and, only when it can serve the WHOLE middle exactly, skip the canonical traversal and hand the compact state to the unchanged row materializer. Engagement stays operator/index/dtype/cost based — no query, schema, or hop-count recognition — and every unsupported, seeded, prefiltered, policy-bearing, shortest-path, or cost-gated shape still declines to canonical execution with identical results. On a standard LDBC SNB SF1 interactive-short profile this removed the redundant two-hop typed-mask and 16-merge buckets and cut the profiled call ~11.9× (1263 ms → 106 ms), exact 19-row oracle preserved. - **Indexed seed lookup now covers a unique node id PLUS extra scalar constraints**: `{node_id: v, other: w}` previously fell back to a full node-table scan even though the unique node-id index can gather at most one row; it now gathers that one row and applies the remaining constraints to it. Missing ids and non-matching extra constraints return the same empty result as before, and duplicate-id graphs (which cannot build the index) are unaffected. - **Seeded typed-hop property projection keeps its fast path through DISTINCT / ORDER BY / SKIP / LIMIT**: those trailing row ops are plain frame operations, so the fast path now delegates them to the canonical chain instead of declining the whole query. - **The pandas/cuDF chain fast path serves NAMED patterns**: `_try_chain_fast_path` rejected any op carrying an alias, so `g.gfql([n(name='x'), e_forward(name='r'), n(name='y')])` fell to the full two-pass BFS purely because the ops were named — naming is a PROJECTION concern, not a traversal one, and it should not decide which engine path runs. The alias flag columns `combine_steps` would have merged in are now reconstructed directly from the edges the fast path returns (`_tag_fast_path_aliases`): `combine_steps` tags a node with an alias iff it still participates in a surviving edge, and those edges are exactly the ones the fast path already produced, so `isin` over their endpoint columns is the same predicate computed without the join. A seed whose edges all fail the filter yields an empty edge frame and is tagged `False`, which is the dead-end case. Deliberate declines kept: **alias names that collide with a binding column the tagger would overwrite** (a node alias equal to the node-id binding wrong-served — the flag OVERWROTE the id column while the full path raises — and an edge alias equal to the hop's FROM-side binding returned a DIFFERENT node set per lane; both found by adversarial parity testing and now declined, with decline + raise/parity pins; TO-side collisions keep parity and stay served), **undirected + named** (an undirected edge makes a node reachable as either endpoint, so alias identity is not derivable from the endpoint columns), **duplicate alias names** (E201 is raised by `combine_steps`, so serving here would bypass the check and let alias reuse silently succeed), and **a resident index that would actually serve the shape** (checked with `_resident_seed_indexes`, i.e. index VALIDITY for these exact frames — a registry-presence check instead declines on every query and silently turns the whole optimization into a measured no-op). **Measured** on a 200-node / 800-edge pandas graph, where data-proportional work is ~0 so this is the per-query plan floor. Five alternating paired runs against `origin/master`, each the median of 200 reps after 20 warmups: the named 3-op traversal `25.2 ms (24.2–27.0) → 2.3 ms (2.1–2.7)`, **~11×**, and the same pattern followed by `rows(binding_ops=...)` `40.4 ms (38.1–43.6) → 17.4 ms (16.8–19.8)`, **~2.3×**. The `rows()` output is byte-identical between the two arms and the traversal output is value-identical (dtypes differ deliberately, see *Changed*). **Scope, stated plainly**: this is a NATIVE `g.gfql([...named...])` / `g.chain([...named...])` improvement only. It is pandas/cuDF-only, and an instrumented run of the graph-benchmark q1–q9 lane showed the chain fast path is **never consulted** for those Cypher queries — they are served earlier by the Cypher-layer fast paths in `gfql_fast_paths.py`, or hit a separate single-op gate — so **no benchmark cell moves** and no lane currently exercises this. A benchmark lane over the native named-chain surface is required follow-up before any perf claim is made on the board. ### Changed - **`resolve_engine('auto')` treats polars frames as a compute engine, not an input format**: AUTO on polars-frame graphs now resolves `Engine.POLARS` engine-wide — every surface that consults `resolve_engine` follows, including direct `g.hop()` with no engine argument, which now answers in polars frames instead of silently bridging to pandas. The legacy semantics (polars is an input format; coerce to pandas) survive under an explicit name, `resolve_input_engine`, used by the surfaces that genuinely compute in pandas/cuDF: layouts (ring, modularity, circle, FA2), hypergraph, `ComputeMixin` degrees/materialize/topological, DBSCAN clustering, and the remote GFQL/Python endpoints. Migrating any of those to native polars later is one call-site flip. `gfql_index_col_stats()` adopts the same AUTO gate as the rest of the index surface (it was un-preserving polars frames as the last step of `gfql_index_all`). - **GFQL index build/validation under `engine='auto'` preserves resident polars frames (rework of the retracted #1767; requires the #1743 AUTO routing, which this stacks on)**: `gfql_index_all()` / `create_index()` / `show_indexes()` with the default engine now resolve POLARS when both bound frames are eager polars, indexing the frames in place instead of coercing-and-REPLACING them with pandas copies (the legacy `resolve_engine(AUTO)` input-format policy). With the #1743 routing underneath, the two default spellings finally meet: `gfql_index_all()` builds a polars index over the untouched frames, and `g.gfql(...)` with no engine routes to the native polars engine and SERVES it (`index_trace()` pins path=index, engine=polars) — the exact combination that regressed 125-534x to the scan floor when the 2026-07 revision shipped without the routing, which is why that revision was retracted and why this entry must not land before the routing does. `show_indexes()` under AUTO reports the routed truth: the previously not-usable "polars index + AUTO query" combination now shows `usable=True` (the #1841 columns), while explicit mismatched-engine previews keep the #1838 decline wording. Boundaries unchanged: explicit `engine=` still coerces; pandas- and cuDF-frame graphs resolve as before; LazyFrame, mixed-frame, edges-only, and nodes-only graphs keep the legacy pandas build path (and stay self-consistent, since the build coerces the frames it indexes). - **`engine='auto'` (the default) now routes to the native polars engine when both bound frames are polars (#1743)**: AUTO on an all-polars-frame graph runs the native polars engine, falling back to the legacy AUTO path when the native engine declines a shape (`NotImplementedError`). Frames out follow the serving engine: polars for shapes the native engine serves, pandas for declined ones. Explicit `engine=` selections and policy-bearing calls are unchanged (the native executor does not emit the `postload`/`postchain` hooks, so policy-carrying queries stay on the generic path). - **`engine='auto'` on an all-cuDF-frame graph prefers the lazy polars engine's GPU target when cudf-polars is genuinely usable (#1743)**: when every bound frame is cuDF and a once-per-process probe confirms the RAPIDS cudf-polars stack actually works (polars imports, `cudf`/`cudf_polars` installed, and a real GPU collect succeeds — a box with the wheels installed but a broken driver/toolchain probes `False` and stays on the legacy path), AUTO runs the query on `engine='polars-gpu'` instead of the legacy CUDF path. cuDF frames in still mean cuDF frames out: inputs cross cuDF→Arrow→polars at coercion and results cross back polars→Arrow→cuDF at the result boundary (lossless nulls/dtypes, no pandas detour — `df_to_engine(polars_frame, Engine.CUDF)` now takes the Arrow interchange instead of the lossy pandas double-convert). Any `NotImplementedError` — an honest engine decline, a GPU-collect failure, or a cudf-out conversion failure — falls back to the legacy CUDF path with identical values. Explicit `engine=` selections always win, policy-bearing calls stay on the generic path (same hook-gap reason as the polars arm), mixed-frame graphs are untouched, and the probe is registered as an exempt process singleton in the GFQL cache registry. - **A served chain fast path now returns Cypher-conformant int/bool dtypes on named patterns too**: the pandas full path's rows-pivot upcasts non-id `int64 → float64` and `bool → object` through its merges. That is a pandas merge artifact, not a Cypher semantic — the openCypher TCK (`clauses/return/Return2.feature`, "Returning a node property value": `CREATE ({num: 1})` / `RETURN a.num` expects `1`, and the TCK value grammar writes an Integer as bare decimal digits and a Float with its decimals) and Neo4j (INTEGER and FLOAT are distinct property types; `valueType()` on an integer property reports `INTEGER NOT NULL`) both keep the integer, and this library's own **polars** and **cuDF** canonical paths already return `int64`/`bool` for the same query, so only the pandas merge upcasts. The chain fast path already preserved the conformant dtypes for UNNAMED patterns (documented in its docstring); serving named patterns extends that to the named surface rather than creating a new divergence, and the conformant dtype is deliberately kept rather than cast back to reproduce the defect. **User-visible** where a named pandas pattern is served by the fast path: an integer node property comes back `int64` instead of `float64` (`30` rather than `30.0`, including in JSON), and an alias flag column comes back `bool` instead of `object`. Values are unchanged. **Not aligned yet** (deliberately out of scope, follow-up): the pandas full path itself, and the Cypher-layer seeded projection, still emit the artifact — so pandas can still report `float64` for the same logical query depending on which internal path serves it. - **The widened residual translator gets its cost back: the lowering is memoized, and the fused lane stops emitting a NaN mask it can prove is dead**. Widening `_residual_polars_expr` to `row_pipeline.lower_single_alias_predicate` (previous entry) turned a regex match into a full parse-and-lower on a path where every board residual was *already* native, and it regressed the matched graph-benchmark q1-q9 lane at both scales — q7 by 1.24x (20k) and 1.16x (100k) with disjoint per-slot ranges, q5 by 1.13x/1.09x also disjoint, q6 slower within overlap; the three cells that moved are exactly the three carrying residuals (`engine='polars'`, RUNS=101 WARMUP=10, 15 position-balanced slots, arms differing only by the pygraphistry tree, base `ed3904515` vs PR `7e9bd662d`). **Two measured mechanisms, two scoped fixes.** (1) *Parse tax*: `lower_single_alias_predicate` re-parsed and re-lowered on every call, at 3.3 us/call before the widening and 162 us/call after it — the cost is not the parse so much as the schema-width `LazyFrame` probe `_expr_output_dtype` runs per operand, and the predicate strings are a tiny fixed set per query. It is now memoized behind a bounded LRU (512 entries, so a long-lived process cannot grow it without limit), at 2.3 us/call. The key is `(expr, alias, columns_nan_free, ((column, dtype-repr), ...))`: names AND dtypes AND their order, because the same predicate over the same column names lowers differently when a dtype changes, and `str(dtype)` rather than the dtype object because polars' `DataType.__eq__` equates a dtype class with its parameterized instances and would HIT across dtypes that lower differently. Parser AVAILABILITY is deliberately NOT in the key — it is process state, not an argument — so it is probed ahead of the cache (~0.2 us), and no entry can outlive a parser that has gone away. (2) *A costlier expression*, which is why the loss grew with scale: the general lowering wraps every float comparison in `& col.is_nan().not()` so NaN compares IEEE/pandas/Cypher-style rather than polars-style (NaN = largest), and that mask took `p.age >= 23` from 0.23 ms to 0.52 ms at 100k. On the connected-join fused lane the mask is provably dead **for a bare column read**: every frame there is a filtered/joined projection of the graph's own `_nodes`, which `_coerce_input_formats` already ran through `nan_clean._pl_nan_to_null` on the way into `gfql()`, and selection/filtering/joining cannot introduce a NaN a column did not hold — the same justification the pre-widening narrow translator gave, and the same one `predicates.filter_expr_by_dict_polars` already relies on. So the lane opts in via a new `lowering_context.COLUMNS_NAN_FREE` contextvar, threaded by an explicit `lower_single_alias_predicate(..., columns_nan_free=True)` that nothing else sets. **The default is guard-ON**, so the general row-table lowering — whose frames have NOT been through gfql ingest — is untouched and any future caller is safe without knowing this exists. **Suppression is scoped to COLUMN REFERENCES, never to computed operands**: `n.a/n.b` is NaN at 0.0/0.0 and `sqrt(n.x)` is NaN at x<0 even on a perfectly clean column, so in-query float math manufactures NaN no ingest can have removed and keeps the mask on both lanes; a float LITERAL keeps its own (constant-false) term too, since only column reads are in scope. That restores exactly the guard-free set of the pre-widening narrow matcher. **Result**: q7 returns to parity with base — TIE at 1.02x at both scales, from 1.24x/1.16x — and q5 (0.95x/0.99x) and q6 (0.98x/1.01x) return to TIE as well, with no other cell moved and every cell's VALUE identical across all three arms at both scales. Tests pin the scoping in both directions: the fused lane emits a guard-free expression, the general row-table lowering still emits the mask, a computed operand keeps it and is answered IEEE-style over a real in-query 0.0/0.0, a natively-built polars frame carrying a genuine NaN is shown normalized by ingest before the lane ever sees it, and the memo is shown to agree with the uncached lowering on every shape while a dtype change alone produces a different expression. - **The native polars residual translator covers the whole single-alias predicate vocabulary the connected-join WHERE renderer can emit, because it now delegates to the row evaluator's own lowering instead of re-deriving it**: `_residual_polars_expr` (`graphistry/compute/gfql_fast_paths.py`) recognized exactly two hand-written regex shapes — `((a.col) = 'lit')` and `(a.col literal)` for `= >= <= > <` — and returned `None` for everything else, and a single `None` drops the entire fused single-collect connected-join plan and re-evaluates that alias's residual through a `where_rows` chain. It now parses the residual with the row-expression parser, rewrites `alias.col` to the bare column its own frame actually carries, and lowers it with `lower_expr` through the new `row_pipeline.lower_single_alias_predicate`. **Parity is by construction, not by re-derivation.** The fallback being replaced is `where_rows_polars`, which is that same parser plus that same `lower_expr` under the same `_SCHEMA` dtypes followed by `table.filter(...)`; `alias.col ↔ col` is a bijection over the one frame involved; so the fast lane builds the expression the fallback would build and applies it the way the fallback would apply it. The accept set, the decline set and the answers are the evaluator's, and the dtype, temporal-literal, int-literal-division and float-NaN guards are inherited rather than restated, so the fast lane cannot drift from the evaluator when those guards change. Newly covered — each pinned by a differential against the forced chain fallback on the same frame AND by the expected rows, so a differential both sides get wrong cannot pass as parity: `!=` and `<>` (including the sharp edge, 3-valued NULL: a NULL operand yields NULL and `filter` DROPS the row, exactly as for `=`, where a pandas-shaped object-dtype `!=` would have kept it), `IS NULL`, `IS NOT NULL`, `IN [literals]`, `OR`, `NOT`, nested boolean combinations, `CASE WHEN`, arithmetic, and the row lowering's function whitelist (`substring`, `size`, …). Three former declines are retired as unnecessary rather than preserved: the **escaped-literal** decline (`'it\u0027s'`, `'C:\u005Cx'`) is gone because the literal is now unescaped by the evaluator's own parser instead of compared as raw regex-captured text, and is replaced by a non-vacuous differential over rows that really contain a quote and a backslash; the purely **layout** rejections (missing outer parens, reversed operand order, a function on the column side) are gone because nothing is matched by text shape any more; and `=`/`!=`/`IS NULL`/`IN` on a **Categorical** column translate now, since those are not `.str` kernels and the evaluator answers them (`toLower`/`toUpper` on Categorical still declines, because `.str` on Categorical raises in polars only). Every remaining decline is a shape the fallback declines too, so the row op's designed parity-or-error `NotImplementedError` still surfaces instead of a raw polars `ComputeError`: a property access on another alias, a bare identifier (including the whole-entity identity sentinel, which resolves only through the row table's `_NODE_ID`), an absent column, a numeric literal against a String column and the converse, a case function on a non-String column, and any node type outside the row lowering's own whitelist (map/subscript/slice/quantifier/comprehension). **`STARTS WITH` / `ENDS WITH` / `CONTAINS` / `=~` are deliberately NOT covered, and the reason is reachability, not difficulty**: `_pushdown_connected_join_where_filters` cannot render those to a row filter, so on the polars engine the whole comma-pattern query is rejected upstream with `GFQLValidationError` and no such residual ever reaches this translator — pinned by a test that spies the translator and asserts it is never called. Teaching it those shapes would be unreachable code; the real gap is in the connected-join WHERE renderer, where pandas answers those queries today and polars does not. **The justification is the vocabulary the Cypher front end already accepts, not a number** — and the number, once measured, was negative: the board's ten residual translations were all ALREADY native under the narrow matcher, so the widening converted no decline there and only added cost, regressing q7 by 1.24x/1.16x (20k/100k, non-overlapping per-slot ranges). Both mechanisms and the fix are the next entry; the widened COVERAGE is unaffected by it. - **`is_polars_df` is a type predicate, and the engine-frame vocabulary has a canonical home**: `graphistry/compute/typing.py` now exports `PolarsFrame` (the `pl.DataFrame | pl.LazyFrame` union), a `PolarsT` TypeVar and `PolarsSeriesT`, TYPE_CHECKING-only so polars stays an optional dependency and no runtime import or version floor is added; `polars/dtypes.py`, which had defined the first two, re-exports them so there is ONE definition. `DataFrameT` is deliberately left pinned to `pd.DataFrame` — widening it into a cross-engine union fans out across every checked module, and the fix for a polars-only helper is to name polars, not to blur the pandas alias. Against that vocabulary `Engine.is_polars_df` is now declared `(df: object) -> TypeGuard["PolarsFrame"]` instead of `(df: Any) -> bool`, so the polars branch of an engine-dispatching helper is actually CHECKED against the polars API rather than silently accepted. `TypeGuard`, not `TypeIs` (PEP 742): `TypeIs` also narrows the negative branch, but to do that soundly it requires the narrowed type to be consistent with the declared input, and a polars frame is not a subtype of the pandas type these call sites declare — that is the difference from `dtypes.is_lazy`, whose input is already a `PolarsFrame`. A companion `is_polars_series` (the same import-light module check) narrows the SERIES call sites to `pl.Series`, which the frame guard would have mistyped. Consequences: the six per-branch `# type: ignore` comments in `Engine.py`'s dispatch block were written against an older stub set and named codes (`attr-defined`, `no-any-return`) that no longer applied — they were dead, suppressing nothing, and are now scoped to the one thing that genuinely cannot typecheck, the polars return value flowing out of a `DataFrameT`/`SeriesT` signature; `_pl_nan_to_null` takes the `PolarsFrame` union instead of the constrained `PolarsT` TypeVar, which a union argument cannot bind (an `@overload` set would keep the per-flavour precision but is unusable here — on the polars-less type-lint lane every signature collapses to `Any -> Any`); and two rebindings that mixed a polars result into a pandas-typed local (`gfql/row/frame_ops.py`, `gfql_unified.py`) return or branch directly instead. Typing only: no runtime behaviour, no public API, no new dependency. Measured on a stub-visible mypy lane with polars importable, repo-wide errors go 163 -> 157 (11 -> 9 files); the lane CI actually runs today is unaffected and still reports no issues. - **The polars-engine index tests stop fabricating 20 failures on a cuDF-without-`cudf_polars` box** (tests only; zero runtime delta). `graphistry/tests/compute/gfql/index/test_index.py` built its engine list by appending `"polars-gpu"` when **`cudf`** imported — but `polars-gpu` is the `cudf_polars` GPU collect target and raises `ImportError: GFQL engine='polars-gpu' requires the RAPIDS cudf_polars stack` without it. So any environment with cuDF installed and `cudf_polars` absent (a common developer configuration) got 20 failures indistinguishable from product breakage, in a file that IS in the polars lane. CI never caught it because that lane installs neither package, so the parameter did not exist there — a gate can only fabricate failures where it is never exercised. The gate now tests `cudf_polars`. Measured: 20 failed / 116 passed before, 105 passed / 0 failed after, on a box with cuDF 25.10 and no `cudf_polars`. - **`rows(table=...)` and rewrite-param regression suites now run on all four engines** (tests only; zero runtime delta — no production line changed). The #1788 and #1790 suites were parametrized over pandas + polars only, so the `table` guards they exist to pin were never exercised on cuDF or polars-gpu at all. Both files were already in `bin/test-polars.sh` (#1805), so their polars params did run; what ran nowhere was `test_rows_table_named_middle.py` outside that one lane — a module-level `pytest.importorskip("polars")` skipped the whole file in `test-gfql-core`, which installs no polars. Both files are now parametrized over pandas / cuDF / polars / polars-gpu from a **fixed** engine list plus a classified availability check; dropping the module-level `importorskip` also makes 5 pandas cases live in `test-gfql-core`. The availability check is deliberately not `available_nonpandas_engines()`, which builds its list by importability so a missing engine vanishes from the report instead of showing as SKIPPED, and it is deliberately not a blanket `except Exception` either: a missing module skips, a recognisable GPU-stack error skips **with its text quoted**, and any other failure propagates — because a skipped GPU parameter reads as evidence of passing. Both traps were hit by trying rather than reasoned about: a probe that ran the shape under test turned a reverted production guard into 20 SKIPS instead of 20 failures, and a blanket probe silently dropped cuDF from an otherwise-green GPU run on a transient `cudaErrorMemoryAllocation`. GPU receipts on dgx GB10 / cuDF 26.02.01 / polars 1.35.2 / `docker run --gpus all`: **45 passed, 3 xfailed, 0 skipped**; mutation-checked by reverting the #1788 and #1790 `table` guards — **24 failures spread evenly over all four engines (6 each)**, so the added parameters are not decorative. Two gaps the new parameters surfaced are pinned as strict xfails rather than papered over: #1803 (the indexed bindings bypass excludes `Engine.POLARS_GPU`, so polars-gpu silently takes the scan path) and #1804 (the native polars bindings builder never receives `alias_prefilters` — previously an unnumbered xfail). - **Row execution context: `clear_row_exec_context` NULLs rather than restores, and that is now pinned rather than assumed** (tests + comments only; zero runtime delta — no production line changed). Review of #1793 asked whether the exit should RESTORE what the graph carried on entry instead of nulling, since `attach_row_exec_context` INHERITS on the way in (a `None` argument keeps what `g` already carries). Settled as NULL, for three reasons now each carried by a test: (1) `clear` is pure — it returns `g.bind()` and never writes through to the object it was handed, so an outer scope that set the field still has it afterwards and there is no caller state to save; that is what separates this from #1786, which WAS an in-place write onto the caller's own graph, and restore is the fix for a mutation. (2) The only channel restore would change is the RETURN value, and putting the seed back there IS the second half of #1786 — measured, a seed hand-restored onto a result changes the answer of the next query run on that result (7 → 2 → 1 rows). (3) No execution frame inherits a context it did not set: instrumenting `attach` over `graphistry/tests/compute` recorded 3907 calls and zero inheriting ones (53 entered on a graph already carrying a seed, but each was handed the identical `start_nodes` parameter, so the frame still owns the value), because the cross-segment `WITH` seed travels as the explicit `start_nodes` PARAMETER and never through the graph field. The new `test_exec_context_scoping.py` re-runs that ownership measurement as an assertion, so a future path that starts relying on inheritance reopens the decision loudly instead of silently losing an outer value. Mutation-checked in the deciding direction: implementing save/restore at all three attach sites fails 4 of the new tests on every runnable engine, while the existing #1793 suite passes unchanged — i.e. those tests could not distinguish the two designs and this file can. Engine-parametrized over pandas/cuDF/polars/polars-gpu from a fixed list plus a classified availability check (a missing module skips, a recognisable GPU-stack error skips with its text quoted, and any other failure propagates — a silently skipped GPU parameter reads as evidence of passing); GPU receipts on dgx GB10 / cuDF 26.02.01 / `docker run --gpus all`: 43 passed, 0 skipped. - **`is_lazy` is a type predicate, so the polars engine's eager/lazy split is checked instead of asserted**: `dtypes.is_lazy` decides WHICH member of the two-member `PolarsFrame` union a frame is, but it was declared `-> bool`, so that answer was discarded at the call boundary — an eager-only attribute (`.height`, `.columns`, `.schema`) reached after a lazy guard was unprovable, and the native polars chain closed the gap with `cast("pl.DataFrame", frame)`. A cast is not a type: it re-asserts, unchecked, the exact fact the predicate had just established, once per call site, and a wrong one surfaces as a production `AttributeError` rather than a CI error. `is_lazy` is now declared `-> TypeIs["pl.LazyFrame"]` (PEP 742), which narrows the *negative* branch as well as the positive — the eager side is the one that needed it, so `TypeGuard` would not have done — and the cast is gone. Four previously untyped chain-combine helpers (`_semi`, `_combine_edges`, `_apply_node_names`, and the `_known_empty` guard) now carry real signatures: `_semi`'s two frames share the `PolarsT` TypeVar because polars joins do not mix eagerness, and the two combine helpers take the `_LazyShim` collect-once duck-type they are actually called with. Typing `_apply_node_names` also retired a `getattr(next_step, "edges_empty", None)` — the shim declares that tri-state in `__slots__`, so a typo is now a checker error instead of a silent `None` that would have re-armed the very cardinality gate the guard disarms. Internal only: no runtime behaviour, no public API, and the `TypeIs` import is `TYPE_CHECKING`-only, so no new runtime dependency floor. - **GFQL execution context is declared rather than attached at runtime**: the private `_gfql_*` per-execution fields (index policy and registry, row-pipeline base graph, carried seed nodes, edge aliases, shortest-path backend, and the indexed-bindings handoff) are now declared on `Plottable` with defaults on `PlotterBase`, instead of being set with `setattr` and read back with `getattr(..., default)`. No public API or behaviour change; internal call sites are typed, and hand-rolled `Plottable` stand-ins must now construct the full context. ### Fixed - **`OPTIONAL MATCH` followed by a terminal `WITH` keeps its binding rows on both engines (#1896)**: `MATCH ... OPTIONAL MATCH ... WITH ... RETURN` was served by the row-column WITH pipeline, which cannot represent OPTIONAL MATCH binding rows — it collapsed multiplicity, dropped null-extended rows, and mis-projected the optional alias on a multi-alias carry. Two narrow AST rewrites now route these shapes onto the connected optional-match left-join lowering instead: a pure bare-alias carry stage (`WITH a, b [WHERE ...]`) is dropped and its `WHERE` handed back as a post-join binding-ROW filter (openCypher: matched rows keep their bindings, null-extended rows pass or fail on their own values), and a terminal projection/aggregate stage that `RETURN` passes through unchanged is folded into `RETURN` so the direct aggregate lowering serves it. Shapes outside that (rename/DISTINCT/ORDER/SKIP/LIMIT on the stage, multiple stages, UNWIND/CALL, references to uncarried aliases) still decline typed rather than answering wrongly. Separately, the unmatched-prefix-row null-fill no longer synthesizes ANONYMOUS rows by count arithmetic: unmatched rows are anti-joined so each null-extension keeps its own carried seed identity, and the fill declines typed when an output has no prefix-frame column behind it. Pins: an admit/decline matrix over both rewrites at the AST level, a per-branch matrix for the carried-output resolution, and pandas+polars row-value oracles for the carry/aggregate/rename/LIMIT/decline shapes. Four openCypher TCK scenarios (`match-where6-2`, `match7-29/30/31`) promote from the capability-debt manifest to served. - **A resident adjacency index no longer survives an `.edges()` rebind — silent wrong answers on both engines, present in 0.58.0 (#1913)**: after `g.edges(other_frame)` the index's identity guard correctly missed, but both chain executors then attached their synthetic per-edge id column and called `rebind_edges()` UNCONDITIONALLY. That call re-validated only the NEW frame's structural fingerprint (row count + bound columns + engine), never that the index was still live for the frame it was augmenting, so a CSR built over the OLD edges was re-marked valid for the NEW ones. Multi-hop and variable-length `chain`/`gfql`/Cypher on pandas and polars returned wrong subgraphs — including for `df.sort_values(...)`, an ordinary user action that preserves the entire edge set but permutes the CSR's row positions; `show_indexes()` meanwhile reported the index stale while those same queries were being served from it. **If you use `create_index`/`gfql_index_*` on 0.58.0 and rebind edge frames, results after the rebind may be wrong.** `rebind_edges` now requires the frame being migrated FROM and migrates only an index that is valid for it, so staleness is always miss-to-scan (correct, possibly slower) as the registry's contract promised; the executor's own shallow augmentation — the case the optimization exists for — still engages, verified by engagement pins, not just correctness pins. Consequently "rebind a fresh frame" works again as a recovery from in-place mutation; `gfql()`'s docstring now names rebinding and `drop_index` (`gfql_clear_caches()` is documented not to touch graph-keyed state, and never did — the two docstrings contradicted each other). Also widened the cache completeness lock to the rest of the GFQL execution path (`chain.py`, `hop.py`, `*_fast_paths.py`, `ComputeMixin.py`) and to process-global mutable state whatever its name: `_OFFENGINE_BRIDGE_WARNED` is now clearable, `_COST_GATE_FRAC_OVERRIDES` (deliberate tuning) and the registry ledger itself are exempt with written reasons. - **Endpoint closure is one rule on every surface (#1888, absorbing #1808)**: with a bound node table, a pattern edge matches only if BOTH endpoints resolve to node rows. Previously five surfaces answered a dangling-edge graph five ways — pandas/cuDF kept dangling-destination edges (the #1808 asymmetry: destinations were only gated when a destination filter existed), `hop()` synthesized phantom NaN node rows (upcasting attribute dtypes), polars unconstrained chains returned all edges verbatim (and attaching a policy changed the answer), and polars' projection and count disagreed with each other. Closure is now enforced at the three execution kernels (shared pandas/cuDF hop, polars hop, polars chain fast path — one semi-join per endpoint, matching the prune pandas chains already paid); synthesized node tables are vacuously closed and skip the pass; adjacency-index paths decline-to-scan on dangling touches (answer-consistent). Policy on/off is value-identical. Three strict-xfail pins flipped; six closure-invariant green pins added. - **The polars endpoint-closure gate resolves a NULL endpoint id against a NULL node row (#1888 review round 6)**: membership is the gate's whole implementation and the engines disagree about NULL — pandas/cuDF `isin` answers True for NULL-in-{..., NULL}, polars `is_in` answers NULL, and `filter` drops a NULL predicate. On a graph whose node table carries a NULL id row, `hop(engine='polars')` therefore dropped an edge with a NULL endpoint that pandas, cuDF and the pre-#1888 code all keep — the gate over-filtering a graph that is closed. The polars kernel now treats a NULL endpoint as resolvable exactly when the id universe holds a NULL. Pinned on all three engines, bound and synthesized node tables. - **The rest of the NULL-endpoint surface is pinned rather than assumed (#1888 review round 7; tests only, zero runtime delta)**: round 6 fixed one null-blind membership site (the polars hop gate's `is_in`); the same blindness lives in every polars semi-JOIN, which never matches NULL to NULL. Four strict xfails now name the measured wrong answers instead of leaving them in a docstring: the polars hop keeps the NULL-endpoint edge but its node-output semi-join drops that endpoint's node row (so the output is not endpoint-closed); the chain surface answers the NULL question differently from `hop()` on the same closed graph (2 edges vs 3, all three engines — and the polars arm of that is this PR's own chain gate, so the cell XPASSes at the merge base); polars `count(*)` over an undirected pattern answers 4 where pandas/cuDF answer 6; and pandas/cuDF gate a NULL endpoint out of a SYNTHESIZED (vacuously closed) node table where polars does not. All but the polars chain arm reproduce identically at `526976e91`. - **OPTIONAL MATCH returns openCypher null-extended results on the single-node-seed shapes that silently inner-joined (#1891)**: the seed gate excluded exactly the one-arm shapes whose two-arm twin already answered correctly, and its bypasses (aggregates incl. ungrouped `count(*)`, alias-only projections, zero-match arms) dropped unmatched seeds on BOTH engines — engine-parity-blind wrong answers. Those shapes now route through the connected left-join lowering; group-by keeps null keys so unmatched seeds keep zero-count/zero-sum/empty-collect rows; pure-carry `WITH` before OPTIONAL MATCH no longer NULLs carried seed properties; polars empty optional arms answer natively with typed-null schemas (no more data-dependent ANSWER→NIE flips or bare SchemaError); a `WITH`-carried scalar next to an aggregate no longer raises a raw KeyError when its name collides with an edge column (fast-path decline). Residual unsupported shapes decline with honest typed gates that describe the actual condition. 33 strict-xfail pins flipped (50/50 in `test_optional_match_semantics.py`). - **`hop()` filters mean the same thing at every hops value, and `to_fixed_point` equals the saturated bounded hop (#1892)**: seeded `hops==1` evaluated `source_node_match`/`source_node_query` against the SEED frame while every other hops value used the node table — id-only seed frames errored at hops=1 but answered at hops=2, and a stale same-named seed column silently flipped values (both engines; introduced with #917's shortcut, which blame shows carried no semantic intent). Filters now always read the node table; at a seeded single hop only seeds can be sources, so the domain is semi-joined to the seed ids first. Separately, undirected `to_fixed_point` + wavefront + node filters returned seeds the filtered traversal never re-encountered (topology-only keep heuristics from #952); the keep set now intersects the traversal's reached set, so on the FILTERED undirected wavefront `to_fixed_point` equals the saturated bounded arm and pandas matches polars. All 8 #1892 RED pins flipped to green (42/42 in `test_hop_semantics_pins.py`), and `test_hop_boundary_matrix.py` adds 395 hand-oracled boundary cells over seed cardinality, topology (cycle, self-loop, parallel edges, star, disconnected, isolated), hop windows, direction and filters; 134 of them fail at this change's base. That matrix also pins, as strict xfails against #1918, three boundaries this change does NOT reach: the UNFILTERED undirected wavefront, where the bounded arm re-enters a seed over its own departure edge and so does not equal `to_fixed_point`; polars applying no undirected-wavefront seed strip at all; and the pandas cycle helper collapsing parallel edges. - **The default engine no longer crashes same-path projections on polars-frame graphs (round-002; #1885's route)**: when the native polars engine declines a shape, AUTO's pandas fallback re-ran the pandas-idiom executors on the UNCOERCED polars frames — 7/7 same-path-WHERE + projection shapes crashed (`.assign` AttributeError, `len(LazyFrame)` in schema validation). The fallback now coerces frames to pandas first; eager and lazy inputs pinned to pandas parity. Round-002 also pinned as strict xfails: OPTIONAL MATCH + aggregate bypassing the seed gate into inner-join semantics (both engines agree on the non-Cypher answer), the WITH-carried-scalar-next-to-aggregate KeyError, sum-over-empty-match returning NULL where openCypher says 0, and the #1888 endpoint-closure divergences. - **`import graphistry` crashed under a lowercase `LOG_LEVEL`**: `setup_logger()` passed the raw `LOG_LEVEL` environment value straight to `logging.Logger.setLevel()`, which only accepts uppercase level names (`INFO`, `DEBUG`, ...) or ints. A common lowercase value such as `LOG_LEVEL=info` therefore raised `ValueError: Unknown level: 'info'` during package import, breaking every downstream `import graphistry` in that environment. The value is now upper-cased before use, and the `TRACE` alias check is case-insensitive, so `info`/`INFO`/`trace`/`TRACE` all work. Regression test added in `graphistry/tests/test_logging.py`. (#1886) - **`group_in_a_box_layout()` crashed on cuDF graphs**: the vectorized normalize converted per-partition stats to pandas before merging them into the engine frame, and cuDF's merge rejects a pandas right operand (`TypeError: right must be a Series or DataFrame`). Stats now stay in-engine; pandas path unchanged. (#1876) - **Polars NaN normalization could serve WRONG ROWS after in-place mutation**: the ingest path cached a per-frame "clean" verdict keyed by `id()`, so injecting NaN through polars' in-place APIs (`replace_column`, `extend`, ...) after a clean verdict left the NaN visible to WHERE comparisons instead of reading as missing — silent row changes through the public API, the same class as the two-hop memo above. The verdict set is removed under the release's freshness contract: the UNDECLARED ingest path probes the frame's current content every call (the probe IS the cheapest freshness check; frames without float columns short-circuit), and cross-call reuse belongs to the DECLARED index layer, whose frame-immutability assumption is now documented (#1881). Also from the same amplification round: the shortest-path cache's recyclable `id()` token became a strong-ref `is` guard, and a vacuous execution-spy test was rewritten against the real `chain_impl` seam with a positive control. - **The library honors its own immutability contract (audit + permanent pin)**: an audit of 242 in-place-write sites plus a 29-arm dynamic harness found GFQL proper clean on both engines, and three older public APIs mutating caller frames — `tree_layout()` and `label_components()` wrote layout/component columns into the user's bound nodes frame, and `transform_umap(merge_policy=True)` wrote `_batch` into the fitted graph's frames. All fixed with single batched `assign` calls (one copy per frame, not per column), plus the latent `align_shared_column_dtypes` parameter mutation. A parametrized no-mutation pin (`test_public_apis_do_not_mutate_inputs.py`) now guards the public surface on pandas and polars. - **Bound-frame immutability is now the STATED contract, and every cache honors its recovery recipe**: frames handed to GFQL are treated as immutable (what makes verdict/index/fact reuse sound — the position of any engine with indexes); mutating a bound frame in place is undefined behavior, recovered by rebinding fresh frames or `gfql_clear_caches()`. The amplification round found the polars NaN clean-verdict cache violating the RECIPE, not just the contract: it was unregistered — invisible to `gfql_clear_caches()` and the cache completeness lock — so an in-place NaN injection had no supported recovery and silently changed WHERE rows. The cache is retained (O(1) repeat-ingest under the contract) and now registered/flushable, with contract-recipe pins. Also: the shortest-path cache's recyclable `id()` token became a strong-ref `is` guard, and a vacuous execution-spy test was rewritten against the real `chain_impl` seam with a positive control. - **Known cross-engine divergences on degenerate inputs pinned as strict xfails** (`test_known_cross_engine_divergences.py`): #1808 (a dangling edge DESTINATION is matched by pandas/cuDF, dropped by polars — polars is the Cypher-correct side; the pandas endpoint gate is asymmetric) and #1739 (`HAS_