Skip to content

0.59.1

Choose a tag to compare

@lmeyerov lmeyerov released this 03 Oct 04:34
· 934 commits to master since this release
b18d808

PyPI: https://pypi.org/project/graphistry/0.59.1/ · Docs: https://pygraphistry.readthedocs.io/en/latest/gfql/performance.html

Highlights: GFQL performance release — the in-memory bulk-hop regression fix, the re-measured GraphBench / SNB Interactive boards against Kuzu, Memgraph, and Neo4j, the GraphFrames ladder to 1.8B edges, and the rewritten GFQL docs (engines, indexing, benchmarks). Full changelog below.

Added

  • GFQL searchAny / search_nodes / search_edges now search datetime columns when the term is numeric, matching what the viz inspector displays rather than repr: a timestamp renders as the inspector's moment format (Jan 5 2024, 3:04:05 am UTC), so "2024" finds rows in that year. Datetimes previously declined. The zone is a temporal_tz parameter (default UTC) because the inspector renders in the viewer's zone, which a server cannot know — and the zone is not cosmetic, since it decides which day a timestamp lands on and therefore which rows match. Verified byte-identical to real moment-timezone on 3000 timestamps across 12 zones, including both DST directions, half- and quarter-hour offsets, and zones whose abbreviation changes seasonally.
    • Searching a datetime column means rendering it, which a numeric-term search now pays for on every datetime column present: on a 300k-row frame one datetime column takes a searchAny from 77ms to 629ms, about three times what a float column added when #2089 gated floats in the same way. A term that is not numeric does not gate datetime columns in and pays nothing, which is pinned by spying on the renderer rather than by timing.
    • pandas and polars render every zone and answer identical rows; polars matches pandas on 2968/3000 fuzz cases, the residue being Africa/Casablanca, where polars' bundled timezone database and Python's disagree about its future DST. cuDF matches at UTC (0/5000 mismatches on a GB10) and declines any other zone rather than mislabel it: cudf 26.02.01's strftime ignores tz_convert, answering UTC for %Z whatever the zone and reading the underlying UTC instant for %p, so an Asia/Kolkata column would render the right hour beside the wrong meridiem.
  • GFQL searchAny / search_nodes / search_edges now search float columns when the term is numeric, matching what the viz inspector displays rather than repr: fractional values render to a fixed number of decimals (so 0.1 + 0.2 is found by "0.3" and never by "0.30000000000000004"), whole values render without a trailing .0, and NaN plus the Int32 sentinel 2147483647 display nothing and never match. Precision is a float_precision parameter (default 4). This removes the previous cuDF float decline and the polars float exclusion (#1695).
    • Only pandas can reproduce the inspector's half-away-from-zero rounding; polars and cuDF round half-to-even, so a value whose float_precision+1-th decimal is exactly 5 renders one unit lower there. Measured on 26,008 realistic column values: pandas 100%, cuDF 99.996%, polars 99.931% agreement with the UI. Pinned in test_known_cross_engine_divergences.py.

Changed

  • GFQL polars: prune_to_endpoints on a single-hop edge is a typed decline (NotImplementedError, use engine='pandas' or 'cudf'); the plain single-hop branch and the full polars traversal used to admit it and silently return both endpoints where pandas keeps the arrival side. Variable-length hops keep their native pruning (#2053).
  • GFQL: the general chain path keeps integer and boolean node attribute dtypes on a closed graph: the endpoint-closure backfill now appends only endpoints missing from the node frame instead of concatenating every endpoint id and deduplicating afterwards, which widened the attribute columns to float on every route that fell through the hot paths (#2058, chain seam; the row-pipeline pivot dtypes are a separate item).
  • GFQL: the chain specializations move into graphistry/compute/chain_specializations/{admission,hotpaths}.py (pandas/cuDF single-node lane, seeded typed single hop, seeded typed RETURN-destination) and graphistry/compute/gfql/lazy/engine/polars/chain_specializations/{admission,hotpaths}.py (polars plain single-hop branches, seeded lane, RETURN-destination), each lane next to the admission predicate the dispatcher calls (native_fast_path_admits, polars_plain_single_hop_admits, polars_seeded_lane_admits); chain.py and the polars chain only dispatch, chain_fast_paths.py keeps the shared seed/index helpers. No route admits or declines anything it did not before. Tests mirror the new paths and filter one shared shape corpus per route with the route's own gate; GFQL_ROUTES_OFF=<route,...> (test conftest) makes named hot paths decline so every existing test replays through the other routes, and bin/test-routes-off.sh reports the per-route divergences.
  • GFQL: the wavefront seed-rediscovery rule moved out of hop.py into graphistry/compute/gfql/seed_rediscovery.py (pandas/cuDF) and graphistry/compute/gfql/lazy/engine/polars/seed_rediscovery.py (polars); undirected_rediscovered_seed_ids (an internal helper) is gone.

Performance

  • GFQL searchAny over a datetime column answers a live, as-you-type search on 30M rows in 412ms instead of 43.7s — 106x, measured on a GB10 with every arm checked equal to the rendered answer. A numeric search term cannot straddle two fields of the inspector's date format, because the separators between them are a space, a comma and a colon and a term may contain none of those. So the rendered text is never needed: the search becomes membership tests over narrow integer arrays for day, year, hour, minute, second and zone, and a term selects a handful of the at most sixty values a field can take. The components are built once per column and zone and held in a 512MB LRU keyed by a digest of the timestamps' own buffer — not by identity, which serves a stale answer once a frame is mutated in place, and order-sensitively, since a sorted column is a different index. Applies to pandas; polars still renders (399ms at 1M, 4.3s at 10M) and cuDF is unchanged.
  • GFQL: seeded fixed-hop chains on the indexed bindings path do less per-hop frame work — an array-side join estimate, the endpoint filter evaluated before the wide node gather, the endpoint semi-join skipped when it is the identity, and (polars) a searchsorted path expand join — and a bare rows() immediately followed by select attaches only the properties the select reads (rows(attach_prop_columns=...)) on pandas, cuDF, and polars. Same rows, order, and dtypes; LDBC SNB IC8 recent-replies on polars drops from about 24 ms to 11 ms locally (#2084).
  • GFQL: an undirected hop() on pandas and cuDF hash-deduplicated the doubled edge frame (both orientations, 2E rows) on (from, to, edge id) before traversing, once per hop call and three times per chain; every consumer already dedups on the edge id, so the frame is now the plain concat the polars hop uses. Measured locally and not published: the block itself went from 7.1 s to 1.4 s on an 8M-edge frame on pandas and 50 ms to 17 ms on cuDF; the benchmark numbers are re-measured in pyg-bench.
  • GFQL latency: this release closes a set of point-lookup regressions found while re-measuring the LDBC SNB Interactive shapes, and adds the gates that stop them coming back. Regressions fixed: every Cypher string query on a wide pandas node table re-scanned the object columns to key the compile cache (#2029); an undirected multi-hop paid a per-edge Python loop in the seed-rediscovery rule (#2023) and a whole-frame dedup of the doubled edge frame on pandas and cuDF; the LDBC IS1 form (a seeded hop returning properties of both aliases) and the seeded single-node lookup ran the full chain with no index consulted; native op-list chains scanned the node table for a seed given on a non-binding property even with a node-property index resident, declined named patterns that the chain fast path could serve, and on polars never consulted the resident seed indexes at all (#2027, #2033; now served through the resident node-id, adjacency and node-property indexes on pandas, cuDF and polars, with the engagement visible in gfql_explain). Gates added: test_gfql_latency_contract.py (fast path served plus a bounded ratio to plain frame ops for the basic shapes on pandas, polars and cuDF), test_hop_scaling_pin.py (undirected 2-hop cost bounded relative to two plain joins), and, in the private benchmark repository, a point-latency sentinel on the real SF0.1 fixture with absolute sub-10 ms thresholds that runs on every release candidate before the documentation numbers are re-vendored, plus an exporter gate that fails any unexplained slowdown or new loss in the published cells. The sentinel also times native op-list twins of the same shapes with untimed receipts of the route that served them and value parity with the full path, so a fast path that stops engaging is reported rather than hidden behind a still-correct answer.

Fixed

  • circle_layout() entry guards (#1968): an edge-only graph no longer crashes with TypeError: object of type 'NoneType' has no len() — nodes are materialized before they are counted, matching every other layout on the surface. Counting after materialization also fixes the stale count behind it: a graph carrying a node table with no node binding had its edge-derived replacement laid out at the old table's length, which surfaced as AssertionError: NaN values detected in x positions. The no-argument form on a graph without x/y positions now raises a ValueError naming the documented precondition (pass bounding_box= or have positions) instead of a bare KeyError: 'x'. A partition-keyed bounding_box frame with duplicate partition_key values now raises a ValueError naming the duplicated keys instead of a pandas IntCastingNaNError from the row-multiplying merge — on cuDF the same frame surfaced as ValueError: CuPy currently does not support masked arrays, and both engines now give the same typed message — and one missing partition_key/cx/cy/w/h says which columns are missing instead of raising KeyError from inside the merge. A partition-keyed frame passed without partition_by — the frame is then iterated as if it were the 4-tuple — says so instead of ValueError: too many values to unpack (expected 4). A null value in a partition_by column raises a ValueError naming that column, where the old message — Unexpected NaNs in node indices — sent the reader to node ids; groupby(...).cumcount() returns NaN for a null group key, so the node indices were only the messenger. Based on #2008 by @SAY-5.
    • The engine comparison that chose between the two angle formulas, engine_concrete in [EngineAbstract.CUDF, 'cudf', 'Engine.CUDF'], was always False — engine_concrete is an Engine, a different enum that is not a str-enum — so the arm was selected only by a hasattr(node_idx_relative, 'to_pandas') duck-type that happens to track cuDF. The arms differed in one thing: the CPU arm's .fillna(0.0) turned a NaN angle into angle 0 where the GPU raised. There is now one expression for both engines, and a NaN angle is unreachable — the node indices and the per-partition sizes are each already checked and a partition is never empty — so the existing Unexpected NaNs in node angles check is the single backstop. cuDF and pandas are pinned to identical positions on a GB10.
  • Engine coercion can still name the engines when it degrades: ensure_engine_match returns the original graph rather than crash a user's workflow, and its handler reports which engines it saw — but those names are resolved inside the same try, so a failure early enough left them unset. The handler papered over this with 'nodes_engine' in locals(); the names are now simply bound before the try, and the degradation contract is pinned by tests for a failure both before and after they are resolved.
  • adjacency_membership_keys no longer depends on its direction argument being one of three strings to have bound anything: oi and ii were each assigned only under their own direction check, so an unrecognized direction reached assert oi is not None with nothing bound and raised UnboundLocalError instead of the assertion. Both are initialized up front; the existing asserts now say what they were written to say.
  • from_igraph determines its node ID column without a bookkeeping flag: the branch that un-swizzles integer vertex IDs tracked whether it had found a matching column in a separate found boolean, so the column name was bound inside the loop and read outside it. Binding node_id_col = None up front and testing that instead removes the flag and the possibly-unbound read. Behaviour is unchanged; round-trips through igraph produce byte-identical edge frames for named vertices, integer vids and a bare attribute-less graph.
  • Two feature-pipeline functions raised UnboundLocalError on reachable inputs: transform() bound X only inside its nodes/edges branches, while y and T beside it were pre-initialized, so any other kind — and kind is a plain str that FastEncoder does not validate — crashed at the first X.empty; and get_numeric_transformers() returns ndf_ unconditionally but assigned it only when ndf is not None, so passing None crashed, where the sibling y_ = y had the same shape and worked. Both now bind on every route, matching the idiom already used beside them.
  • Three layout paths read a local that an earlier branch may never have bound, so the failure mode was UnboundLocalError rather than the intended behaviour. GraphBase.__init__ read the component s in its connectivity check even when no edge ever assigned it (reachable when vertices arrive with a component already set); it now initializes to None, which the existing check already treats as unconnected. tangents() appended the leaked loop variable after the loop to repeat the final tangent; it now appends by value. mercator_layout bound cupy only inside its import guard and gated on a separate use_cupy flag; the flag is folded into cp is not None. No behaviour changes: tangents was verified identical on every input, and the mercator cupy branch was verified equal to the numpy branch to 1e-9 on a GB10 (cudf 26.02.01, cupy 13.6.0) including equator and ±89° cases.
  • PlotterBase's swallow-and-continue paths are exercised rather than assumed: the five maybe_* optional-import helpers (cudf, dask_cudf, dask.dataframe, pyspark, polars) now have tests pinning that each returns None when its library is absent and the module when present, which is the contract engine dispatch is built on; and two except: guards that must never be able to fail an upload — a failure inside infer_labels, and an edges table whose len() raises — are pinned with tests that fail if the guard is removed. No behaviour change: a bare 1 used as a no-op statement becomes pass, and two no-ops that merely followed a logger.debug(...) are deleted. Retires 9 findings from the pyright ratchet.
  • graphistry.outliers is importable again without matplotlib, which is not a dependency, so this was the default install. The module guards its optional imports with try/except and binds each name to None on failure, but two things defeated that: import matplotlib.font_manager binds matplotlib, and that name was missing from the fallback list (outliers.py:162 would have raised NameError); and get_outliers' signature annotates Union[np.ndarray, pd.DataFrame], evaluated at def time, so with np = None the module raised AttributeError: 'NoneType' object has no attribute 'ndarray' before reaching any function. Annotations are now lazy (from __future__ import annotations) and the fallback list is complete, so the module imports and degrades as it was written to. Nothing inside graphistry/ imports it, so this only affected callers of graphistry.outliers directly.
  • An optional-import guard no longer leaves a name unbound on the path that skips it: if polars: import polars as pl in compute/gfql_unified.py makes pl a function-local, so the pandas path left it unbound rather than falling back to any module-level binding. Both arms are guarded by the same flag, so no behaviour changes; the binding is made explicit instead.
  • GFQL Polars: id-set membership (is_in) is spelled per installed polars version by one helper (membership.is_in_ids): the imploded RHS on polars >= 1.28, the bare Series RHS below it. Fixes ComputeError: shapes don't match ... 'is_in' on polars 1.21 (RAPIDS 25.02) for hop endpoint resolution, the seeded chain lanes, and EXISTS/OPTIONAL row ops, and removes the remaining bare-Series deprecation warnings on polars >= 1.28 (#2082, #1938).
  • GFQL: record whole-entity projection kind and presence independently of identity columns, so adapters distinguish unlabeled entities, property returns, and absent entities; preserve presence through OPTIONAL null-fill and reentry (tck-gfql#200).
  • GFQL: explicit polars-gpu row aggregation collects the aggregate plan on GPU; unsupported aggregation plans raise a structured operation decline (#2075).
  • GFQL: reject malformed aggregation sources during validation while preserving literal column names and valid expressions (#2076).
  • GFQL Polars: property returns from multiple aliases gather existing columns directly around the endpoint joins, avoiding expression-plan overhead.
  • GFQL Polars: single-node property returns gather columns directly, removing expression-plan collection from this fast path.
  • GFQL: indexed scalar node and directed single-hop queries followed by rows(source=...) and optional select(...) now produce row tables directly on pandas, cuDF, and Polars. Joined property projections preserve repeated edge matches without constructing full traversal results.
  • GFQL: indexed scalar equality filters no longer retain null-valued rows when the comparison produces a nullable boolean mask.
  • GFQL: a native chain whose edge alias is named like the source, destination or edge-ID binding column silently overwrote that binding on pandas and cuDF (the seed's edges vanished, or the edge ids became True) and raised a raw polars SchemaError; it is now the same typed decline (E108) the node-ID collision already gets, on every engine, before execution — the rule the Cypher route already applied (#2050).
  • GFQL polars: a colliding alias no longer leaves the internal __gfql_shadow_restore__<alias>__ column on op-list results; the chain keeps the shadowed values under that name only while a compiled Cypher pipeline runs (its row pipeline reads them back), and on gfql([...]) / chain([...]) the marker simply shadows the column as on pandas and cuDF. Nothing is stripped from results: user-defined columns and pipelines composed from successive gfql calls are untouched.
  • GFQL: a multi-hop step (hops=2, to_fixed_point) whose edge alias shares its name with the column its own filter uses raised incompatible-column-type on pandas and cuDF; the backward re-execution now reads the graph's edge columns for that step, so the eager engines serve it like the single-hop form and return the same rows as polars (#2049).
  • GFQL: one alias-shadowing contract on every engine for op-list chains: an alias named like a column of the frame it marks becomes the boolean marker under that name (pandas, cuDF and now polars, which used to keep the user values and leak a _right join suffix). The shadowed values ride under the internal restore column the Cypher row pipeline already resolves, so Cypher keeps reading the user value through the variable (MATCH (a)-[type:K]->(b) RETURN type.type) on both engines; the polars residual decline for that shape is gone.
  • GFQL polars: an unnamed, untyped single-hop chain kept duplicate node rows (a node table carrying the same id twice) where pandas, cuDF and every other polars shape collapse them; the plain single-hop branch now returns one row per node id (#2051).
  • GFQL polars-gpu: the fused single-hop grouped-aggregate lane no longer takes the value_counts formulation on the GPU target; its unnest node has no cudf-polars implementation, so every fused count(*) shape declined to the generic route on engine='polars-gpu'. The group_by formulation is GPU-executable and answers the same rows (#2064).
  • GFQL schema validation tracks columns removed by row projections, group_by, and drop_cols, while retaining extended projections and prefix grouping keys.
  • GFQL: a group_by aggregation source naming an existing column whose name contains a dot or a minus (analysis.cohorts.Activist, x.Pro-Vax) was rejected as a missing column (E301 analysis, E301 Vax): schema validation parsed every source as a row expression and split it on the operator, so uploaded column names both executors would serve never reached them. A source that is literally a column of the active row table is now required as that column — the precedence pandas and polars already use (the pandas row pipeline checks the column before parsing, and the polars aggregate accepts only columns) — and a source that is not a column is still parsed as an expression.
  • cuDF 26.2 compatibility: cudf.from_pandas replaces the removed cudf.DataFrame.from_pandas at the five cuDF-only product sites (ai_utils, umap_utils, feature_utils) and in the test fixtures (#2043); the three cuDF chain differential cases that disagree with the full path on cuDF 26.2 are marked expected-failure on that line with the tracking issue, so the GPU lane reports them instead of crashing before them.
  • GFQL pandas/cuDF: several single-alias IN (and other pushed-down) predicates across a hop no longer raise Unalignable boolean Series (#2020): the predicate pushdown filtered an alias frame by label after an earlier pushdown had already narrowed it, while the mask it evaluated carried a fresh positional index. Rows are now kept by position, which is the contract of a mask computed on the same rows; results equal the polars engine and the scalar = form.
  • GFQL polars: a native chain whose edge alias shares its name with the column that step filters on is served instead of raising incompatible-column-type (#2039): the backward pass and the pruned re-execution now run each step on the graph's original edge columns, and a node step whose alias names its own filtered column filters the graph's node values rather than the marker of an earlier pass, so a stamped alias marker is never re-filtered as that column; pandas and polars return the same rows. A cross-engine collision matrix pins the single-hop shapes and records the multi-hop (#2049) and binding-column (#2050) forms as expected failures. The Cypher rows-route projection of such an alias on polars still declines with a typed error (pinned) and stays tracked.
  • GFQL: two basic Cypher shapes fell off the seeded fast paths on every engine. A seeded typed hop whose RETURN projects properties of more than the destination alias (MATCH (m {id})-[r:T]->(p) RETURN m.id, r.since, p.name) went through the row pipeline's connected-bindings frame builder; the seeded fast path now projects any number of properties from any alias of the hop (seed node, edge, destination node) from the rows it already holds, one row per matched edge, and still declines anything beyond one hop or an absent property to the full path. A seeded single-node pattern (MATCH (p {id}) RETURN p / RETURN p.a, p.b) ran the full two-pass chain over the node table with no index consulted; a new seeded_node_lookup fast path resolves the seed through the resident node-id index, a resident node-property index, or one scalar scan and projects the matched rows directly. Seed resolution is shared with the seeded typed hop, so a seed predicate on an indexed property that is not the node binding now uses the property index there too. On the SNB SF0.1 fixture the IS1 text went from 19 ms to 4 ms and the node-only lookup from 14 ms to 1 ms on pandas; both shapes report fast_path served in gfql_explain on pandas, polars and cuDF.
  • GFQL: native Polars single-node lookups resolve seeds through resident node-property indexes, and a directed scalar one-hop chain whose resident node-id and adjacency indexes cover it (typed edges, destination filters, named patterns) is served by the seeded reduction used by Cypher, with full-path table order and alias flags preserved and the engagement visible in gfql_explain (#2033).
  • GFQL: the pandas and cuDF native op-list lanes (single-node lookup, seeded typed hop) record their resident-index engagement in gfql_explain (seams native_seed_lookup / native_seeded_hop), matching the polars lane; a lane that stops engaging is now visible instead of silently correct.
  • GFQL: native op-list chains (g.gfql([n(...), e_forward(...), n(...)])) resolve a seed predicate through the resident node-id or node-property index and serve named patterns on the chain fast path; before, a named single-node op always ran the full two-pass chain, a named seeded hop declined the fast path whenever the traversal indexes were resident (deferring to an indexed kernel that had already declined), and a seed on a property other than the node binding was a full node-table scan (#2027). The alias flag columns now sit where the full path places them.
  • GFQL: every Cypher string query re-read the node table's dtypes to build its compile-cache key, and that read scans the values of every object column (the string-content gate for predicate pushdown). On a wide pandas node table this cost more than the query it keyed: an SNB SF0.1 seeded lookup on 327k nodes × 27 object columns spent 105 ms of 106 ms there. The read is now memoized per node frame (identity plus a length/columns fingerprint, the resident indexes' contract) and cleared by gfql_clear_caches(); the same lookup now takes 2 ms (#2029).
  • GFQL: undirected multi-hop traversals with a seeded wavefront were ~30x slower than before 0.58 because the wavefront seed-rediscovery rule (a seed is returned only when an edge-disjoint walk re-encounters it) ran as a per-edge Python loop on every undirected hop step. The rule now runs as joins and group-bys inside the caller's own frame engine (pandas, cuDF, polars), with the same results (#2023).

Documentation

  • GFQL docs navigation is split into a short start path and two hubs: gfql/index now lists four Start Here pages, a Performance and Benchmarks hub (gfql/perf/index), a Language Reference hub (gfql/reference/index), and the developer specs. No page moved, so existing URLs are unchanged; the previously unlisted gfql/loading_graph_data page joins the reference hub.
  • The GFQL benchmark pages state the result first and consolidate caveats: gfql/benchmark_filter_pagerank (now titled as a case study and listed under Start Here) and gfql/benchmark_graphframes open with what the page compares and the measured outcome (GraphFrames: GFQL's best engine faster in all eight cells, CPU alone in six), keep every number unchanged, and move measurement rules into one "Method and limits" section per page. The GraphFrames page gains per-task bar charts rendered from gfql/_static/graphframes/results.json by the same generator and sync test as the Neo4j charts. The bench-provenance directive now accepts several run ids and renders one Measurement block with caveats folded in; run provenance strings name the RAPIDS base image (pyg-bench #220).
  • The speedup case study renders GFQL arms re-measured at the 0.59.0 release commit: the vendored artifact now carries the 2026-09-04 filter/PageRank GFQL cells (Twitter CPU 2.62 s, GPU 0.23 s; GPlus CPU 34.11 s, which selects exactly the locked Neo4j lane's node set). The GPlus GPU arm selects a different node set than the CPU arm (Jaccard 0.91), so its time renders as a diagnostic and no GPlus GPU-vs-CPU ratio is published; the Twitter ratio is 11.3x. The pandas pipeline slowdown versus the July measurement is attributed by a same-harness A/B (#2021) and the cuGraph/igraph tail difference is tracked in #2022.
  • GFQL performance page renders the relaned four-engine q1–q9 board and the aligned SNB matrix: the vendored pyg-bench artifact now carries the 2026-08-13 board (GFQL bound cold per timed run) with Kuzu, Memgraph, and Neo4j columns and the SNB-derived SF0.1/SF1 four-engine tables, where the databases win every point-lookup cell. gfql/performance drops its release-pinned heading and every hand-typed legacy figure (typed-hop fast path, index timings, the old Neo4j five-query table, the bulk engine sweep, LadybugDB) in favor of published cells, one Measurement block, and win counts derived from cells by a new :bench-tally: role. The bench extension now enforces policy.max_compute_commit_drift against the checkout (git history permitting) with per-run waivers carried in the artifact, so a board measured long before the current compute code fails the build instead of aging silently. gfql/overview states where GFQL wins and where the databases win, with links.
  • GFQL docs wording and structure pass: Start Here is now the 10-minute guide, the overview, and the speedup case study; the quick reference and Cypher syntax guide head the Language Reference hub. gfql/indexing and gfql/index_adjacency carry plain-language titles. The engines page folds its opening note boxes into prose and its "coming from" table names the concrete change and where the measurement lives (Memgraph added, PuppyGraph dropped). gfql/about examples 3–7 gain tested Cypher twins, example 4 now returns rows on its own sample graph, and the sample-graph block runs in the doc-example lane so later examples are checked against it. Marketing filler ("leverage", "seamless", "honest") is removed across the GFQL pages.
  • GFQL overview states the positioning up front: gfql/overview says GFQL is the only open-source in-process Cypher on dataframes and frames it against the columnar engines readers already know. An empty hidden toctree that rendered the page as a folder is gone.

Infrastructure

  • The test extra now pins tzdata, so named-timezone tests do not depend on the host shipping a complete system tzdata. Slim images and minimal distros ship a partial /usr/share/zoneinfo — one with America/ and Europe/ but no US/ makes test_align_mixed_tz_converts_only_mixed_datetime_pairs fail with ZoneInfoNotFoundError: 'US/Eastern', which reads like a pandas behaviour change and is not one. Toggling only tzdata in an otherwise identical environment moves the five temporal test modules from 1 failed / 1402 passed to 1403 passed.
  • CI: the gfql change filter now includes the chain engine (compute/chain*.py, hop.py, gfql_fast_paths.py, filter_by_dict.py, ast.py, predicates/, and the chain/hop test files), so tck-gfql, the Cypher-frontend gates and the gfql benchmark lane run on a change to the chain engine; they were skipped on #2055.
  • CI tooling moves into bin/ci/: bin/ had grown to 34 flat files. The 13 CI-only scripts and baselines (the four ci_*_guard.py with their baselines, ci_docs_only_check.py, ci_gpu_gate_audit.py, ci-hf-cache-key.py, changed_line_coverage.py, coverage_audit.py) now live under bin/ci/, leaving 21 developer-facing entry points at the top level. Pure relocation: no behaviour, flags or output changed, and git mv keeps history. The test-*.sh runners deliberately stay put — they are referenced 32 times across 15 files including conftest.py and committed coverage baselines, which is fixture churn for a cosmetic gain.
  • CI: pyright now runs as a ratcheted gate (python-pyright, py3.12), catching conditionally-unbound locals, unresolved names and no-effect statements that ruff and mypy miss. bin/pyright.sh pins pyright 1.1.414; bin/ci/ci_pyright_guard.py holds findings to per-file counts in bin/ci/ci_pyright_baseline.json, so existing debt is grandfathered and new or moved code is not. Only five rules gate — those decided by a file's own control flow, names and syntax; rules that read third-party stubs vary by up to 5.5x across environments and are reported but never gated. Unparseable files and a collapse in analysis scope also fail the gate, so widening a pyrightconfig.json exclude cannot quietly disable it. See DEVELOP.md "Pyright ratchet". Builds on #1123 by @JithinBathula; closes #1075.
  • CI: test-docs runs on docs-only pull requests (#2018): the job needed python-lint-types, which a docs-only change skips, and GitHub skips a job whose prerequisite was skipped. The gate now accepts skipped prerequisites and refuses only failed or cancelled ones, so documentation changes are built and tested before merge.
  • Benchmark-number age rule moved to the docs lane, quotable runs only: docs/test_bench_numbers.py now runs in the docs build rather than every Python test lane, and a run no board can quote no longer fails CI by aging; a page that cites one of its cells is still age-checked at the point of use.

Tests

  • GFQL: test_gfql_latency_contract.py pins the low-latency contract for basic Cypher shapes on a wide 300k-node, 30-object-column table across pandas, polars and cuDF: a seeded typed hop with projections or a whole-entity return must be served by a fast path and cost at most 12x the plain frame ops for the same lookup (an id mask plus one join), measured interleaved; the node-only seeded lookup with projections is a recorded gap (strict xfail) so closing it flips the pin.