Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,14 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- **Seeded property-RETURN dtype divergence on cuDF**: the lean projection applied the pandas rows-pivot artifact (int → float64, bool → object) on every engine, but cuDF's canonical pivot preserves the source dtypes — so the fast path returned `float64`/`object` where cuDF's own canonical path returns `int64`/`bool`. The cast rule is now engine-aware; the dtype-class decline guard is unchanged.

### Documentation
- **GFQL pay-as-you-go resident indexing user guide**: New :doc:`Pay-As-You-Go Resident Indexing <gfql/indexing>` page — the lifecycle guide to resident indexes (`gfql_index_all()` / `gfql_index_edges()` / `create_index()` / `show_indexes()` / `drop_index()`): what the node-id + CSR in/out adjacency sidecars are, what engages them on 0.58.0 (seeded typed-hop fast paths incl. property RETURNs and property-seeded lookups per #1768/#1770, direct `g.hop()`; the general polars chain traversal honestly noted as not yet covered), the staleness/validity contract (identity + fingerprint; rebind invalidates; declines are safe — identical results either way), engine notes (polars needs `gfql_index_all(engine='polars')` until #1767), 0.58.0-tag measured numbers, and a runnable end-to-end example. Wired into the GFQL toctree + recommended paths alongside :doc:`Seeded Traversal Indexes <gfql/index_adjacency>`.
- **GFQL performance docs: 0.58.0 release-tag-verified numbers, siloed in one page**: `gfql/performance.rst` is now the canonical benchmark-numbers page (alongside `gfql/index_adjacency.rst` for the index benchmarks) — a benchmark rerun updates it alone. It carries the 0.58.0 tag sweep (DGX Spark GB10, warm medians N=30; four-engine numbers cross-engine parity-verified, competitor pairs validated against expected result rows): seeded typed-hop fast path across all four engines (e.g. pandas 29.9→2.46ms, 12.1×), native chain form, resident-index covered-shape lookups (with the `gfql_index_all(engine='polars')` caveat / PR #1767), flat seeded-hop scaling on pandas (0.159–0.164ms from 0.25M to 32M edges), the one-keyword `engine='polars'` LDBC SNB SF1 seed-lookup win (1,299.6→106.1ms, 12.3×), LDBC SNB interactive SF1 vs Neo4j 5.26 same-box pairs (GFQL 4 of 5; Neo4j wins recent-replies — reported as-is), OLAP multi-join vs embedded Kuzu (q8 200×, q9 14.2×) with the honest inverse (Kuzu wins single-table aggregates 2–4×, seeded property-projection lookups 2.4–64×), plus the prior Orkut/LiveJournal bulk sweep (moved from `engines.rst`, dated once) and its methodology. All other pages — `engines.rst`, `quick.rst`, `about.rst`, `overview.rst`, `index.rst` — now carry stable qualitative claims (e.g. "often an order of magnitude faster on query-heavy workloads") that link into `performance.rst` instead of inline figures, replacing the stale "up to ~38×" headlines and avoiding scattered per-claim version labels.
- **GFQL pay-as-you-go resident indexing user guide**: New :doc:`Pay-As-You-Go Resident Indexing <gfql/indexing>` page — the lifecycle guide to resident indexes (`gfql_index_all()` / `gfql_index_edges()` / `create_index()` / `show_indexes()` / `drop_index()`): what the node-id + CSR in/out adjacency sidecars are, what engages them on 0.58.0 (seeded typed-hop fast paths incl. property RETURNs and property-seeded lookups per #1768/#1770, direct `g.hop()`; the general polars chain traversal honestly noted as not yet covered), the staleness/validity contract (identity + fingerprint; rebind invalidates; declines are safe — identical results either way), engine notes (polars needs `gfql_index_all(engine='polars')` until #1767), the index cost model, and a runnable end-to-end example. Wired into the GFQL toctree + recommended paths alongside :doc:`Seeded Traversal Indexes <gfql/index_adjacency>`.
- **GFQL performance docs: benchmark numbers siloed in one page**: `gfql/performance.rst` is the canonical benchmark-numbers page (alongside `gfql/index_adjacency.rst` for the index benchmarks) — a benchmark rerun updates it alone. All other pages — `engines.rst`, `quick.rst`, `about.rst`, `overview.rst`, `index.rst` — carry stable qualitative claims that link into `performance.rst` instead of inline figures, avoiding scattered per-claim version labels. (The tag-sweep figures this entry originally listed have since been withdrawn — see the provenance entries below.)

- **Published benchmark numbers now come from a single machine-readable source of truth, and a stale or missing one FAILS the docs build.** Every performance figure in the docs used to be transcribed by hand from a run report. That is how a number becomes authoritative and then gets re-copied after the board underneath it has moved. Docs no longer restate a number; they reference it. `docs/source/_data/gfql_benchmarks.json` holds every publishable cell with the run that produced it (measurement date, host, whether the perf lock was held and the host was quiet, rep protocol, the frozen pygraphistry commit, the pyg-bench commit, runtime image, dataset, competitor + version, raw artifact path), and it is **generated** by `scripts/export_docs_numbers.py` in [graphistry/pyg-bench](https://github.com/graphistry/pyg-bench) from artifacts committed there — so regenerating the docs' numbers needs no GPU box and no benchmark re-run. A new Sphinx extension (`docs/source/_ext/gfql_bench.py`) resolves ``:bench:`key``` against that file and **breaks the build** on: a key the source of truth does not contain; a run older than `policy.max_age_days`; a cell that is not board-quotable published as a bare number (it must use `:bench-diag:`, which labels it diagnostic-only); and a page that references a cell without rendering that cell's `.. bench-provenance::` or `.. bench-disclosures::` — because a bare ratio stripped of its asterisk launders the caveat, which is worse than manual transcription. `bin/check_bench_numbers.py` runs the same gate without Sphinx and adds two checks a docs build cannot make: **commit drift** (how many commits touching `graphistry/compute` have landed since a published run was measured), and a **hand-typed-literal guard** over the managed pages, since a number typed in by hand is by construction not a reference to the source of truth. Negative tests in `docs/test_bench_numbers.py` pin all four rejections.

- **Two published OLAP ratios were REFUTED by a matched re-run and are removed.** `gfql/performance.rst` published "q8 runs in 5.0 ms vs 1,004 ms for embedded Kuzu (200×)"; a matched, row-validated re-run of the same q8 on the same host measures GFQL-polars 5.06 ms vs Kuzu 9.76 ms — **1.93×**, off by roughly a hundredfold. The same page published q9 at **14.2×**; measured, it is 66.61 ms vs 84.06 ms — **1.26×**. The 1,004 ms Kuzu figure traces to a historical board that pyg-bench's own results file records as "did not reproduce and cannot be reproduced".

- **Benchmark numbers that could not be traced to a surviving measurement have been REMOVED rather than restated.** A provenance audit of every published performance figure found most of them can be neither confirmed nor refuted: the reproducers wrote their results to `/tmp` (or only printed them), recorded no commit, host or timestamp, and their raw artifacts were never committed and no longer exist. Withdrawn on those grounds: the four-engine Orkut/LiveJournal bulk table and its CPU-crossover ratios (`gfql/performance.rst`, `gfql/engines.rst`), the 0.58.0 tag-sweep tables (`gfql/performance.rst`, `gfql/indexing.rst`), the seeded-index synthetic and vs-Kuzu/Neo4j tables and the prepared-Kuzu figures (`gfql/index_adjacency.rst`), the LDBC-vs-Neo4j table (every row that maps onto a query id the current lane emits differs materially from the current board on both sides, and `recent-replies` maps onto no current query id at all), the LadybugDB head-to-head (whose competitor column came from a hardcoded `LADYBUG = {...}` literal in `benchmarks/gfql/bench_ladybug_cypher.py` with no URL, version or citation — a literal, not a measurement), the filter→PageRank-vs-Neo4j results and their two committed SVG charts (the chart generator reads `plans/gfql-gpu-pagerank-benchmark/results/`, a path that has never existed in any commit, so the charts cannot be regenerated by anyone), and every downstream echo of those figures (`~38x`, `9-28x vs Kuzu/Neo4j`, `43X+`, `10-50x`, `100X+`, `10X+`). `gfql/benchmark_graphframes.rst` is **kept intact** — its raw results are committed at `docs/source/gfql/_static/graphframes/results.json` and every headline cell round-trips against it. Removing an unverifiable number is the correct outcome, not a regression — but it is a large removal, so it is called out here rather than buried.

## [0.58.0 - 2026-07-22]

Expand Down
266 changes: 266 additions & 0 deletions bin/check_bench_numbers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
#!/usr/bin/env python3

@lmeyerov lmeyerov Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't get why this big file is here when we also have docs/source/_ext/gfql_bench.py

more broadly, why is this different from the pyg-bench stuff? should all that be there, and all we do is pretty print their latest json? and if we have any boundary needs, ensure there are data contract tests that run on the pyg-bench side, and we re-verify on our side?

"""Gate published benchmark numbers on the single source-of-truth.

Runs the same data validation as the ``gfql_bench`` Sphinx extension, without
Sphinx and without re-running any benchmark, plus two checks a docs build cannot
make:

1. **Commit drift** — how many commits touching the query engine have landed
since a published run was measured. This is the check that catches "the board
moved and nobody re-measured": a docs build is perfectly happy to render a
number taken on a six-PRs-ago tree.
2. **Hand-typed literals** — the managed pages may not contain a bare latency or
speedup literal. A number typed by hand is by construction not a reference to
the source-of-truth, which is how a figure becomes authoritative and then gets
re-copied.

Exit status is non-zero on any finding, so it can gate CI and pre-commit.

Usage::

bin/check_bench_numbers.py # full gate
bin/check_bench_numbers.py --no-git # skip the commit-drift check
bin/check_bench_numbers.py --today 2027-01-01 # what will break, and when
"""

from __future__ import annotations

import argparse
import datetime
import os
import re
import subprocess
import sys
from typing import Dict, List, Optional, Sequence, Tuple

REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DOCS_SOURCE = os.path.join(REPO_ROOT, 'docs', 'source')
sys.path.insert(0, os.path.join(DOCS_SOURCE, '_ext'))

from gfql_bench_data import ( # noqa: E402
BenchData,
BenchDataError,
load_bench_data,
)

#: Paths whose churn invalidates a published query-latency number.
ENGINE_PATHS = (
'graphistry/compute',
'graphistry/Engine.py',
'graphistry/models/gfql',
)

_ROLE_RE = re.compile(r':(bench|bench-diag):`([^`]+)`')
_PROVENANCE_RE = re.compile(r'^\s*\.\.\s+bench-provenance::\s*(\S+)\s*$', re.MULTILINE)
_DISCLOSURES_RE = re.compile(r'^\s*\.\.\s+bench-disclosures::\s*$', re.MULTILINE)

#: A latency literal: "68 ms", "13.83s", "1.20s".
_LATENCY_RE = re.compile(r'(?<![\w.])\d[\d,]*(?:\.\d+)?\s*(?:ms|µs|us|s)(?![\w])')
#: A speedup literal: "38x", "9.4×", "~56×".
_SPEEDUP_RE = re.compile(r'(?<![\w.])\d[\d,]*(?:\.\d+)?\s*[x×](?![\w])')


class Finding:
def __init__(self, where: str, message: str) -> None:
self.where = where
self.message = message

def render(self) -> str:
return '{}: {}'.format(self.where, self.message)


def _read(path: str) -> str:
with open(path, 'r', encoding='utf-8') as handle:
return handle.read()


def _git(args: Sequence[str]) -> Optional[str]:
try:
completed = subprocess.run(
['git'] + list(args),
cwd=REPO_ROOT,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
except OSError:
return None
if completed.returncode != 0:
return None
return completed.stdout.decode('utf-8', 'replace')


def check_reference_integrity(data: BenchData) -> List[Finding]:
"""Every ``:bench:`` reference resolves, and its page carries the caveats."""
findings: List[Finding] = []
for rel in data.policy.managed_docs:
path = os.path.join(DOCS_SOURCE, rel)
if not os.path.isfile(path):
findings.append(Finding(rel, 'managed doc is listed in policy.managed_docs but does not exist'))
continue
text = _read(path)
keys = [match.group(2) for match in _ROLE_RE.finditer(text)]
declared_runs = set(_PROVENANCE_RE.findall(text))
has_disclosures = bool(_DISCLOSURES_RE.search(text))

needed_runs: List[str] = []
needs_disclosure = False
for key in keys:
if key not in data.cells:
findings.append(Finding(rel, 'references unknown benchmark key {!r}'.format(key)))
continue
cell = data.cells[key]
if cell.run_id not in needed_runs:
needed_runs.append(cell.run_id)
if cell.disclosures:
needs_disclosure = True
for run_id in needed_runs:
if run_id not in declared_runs:
findings.append(Finding(
rel, 'publishes run {!r} without a `.. bench-provenance:: {}` block'.format(run_id, run_id)))
if needs_disclosure and not has_disclosures:
findings.append(Finding(
rel, 'publishes a disclosure-bearing number but has no `.. bench-disclosures::` block'))
return findings


def check_freshness(data: BenchData, today: datetime.date) -> List[Finding]:
findings: List[Finding] = []
for run, age in data.stale_runs(today):
findings.append(Finding(
'runs.{}'.format(run.run_id),
'measured {} ({} days ago); policy.max_age_days is {}. Re-measure or drop the claim.'.format(
run.measured_at.isoformat(), age, data.policy.max_age_days)))
return findings


def check_commit_drift(data: BenchData) -> List[Finding]:
"""Fail when the query engine has moved materially since a run was measured."""
findings: List[Finding] = []
limit = data.policy.max_compute_commit_drift
for run_id in sorted(data.runs):
run = data.runs[run_id]
exists = _git(['cat-file', '-e', run.pygraphistry_commit + '^{commit}'])
if exists is None:
findings.append(Finding(
'runs.{}'.format(run_id),
'pygraphistry_commit {} is not in this repository, so the run cannot be placed in '
'history and its numbers cannot be validated'.format(run.pygraphistry_commit)))
continue
log = _git(['log', '--oneline', '{}..HEAD'.format(run.pygraphistry_commit), '--'] + list(ENGINE_PATHS))
if log is None:
findings.append(Finding(
'runs.{}'.format(run_id),
'could not compute commit drift from {}'.format(run.pygraphistry_commit)))
continue
drift = len([line for line in log.splitlines() if line.strip()])
if drift > limit:
findings.append(Finding(
'runs.{}'.format(run_id),
'{} commits touching {} have landed since {} was measured (policy max {}). '
'The published numbers describe a tree that no longer exists.'.format(
drift, '/'.join(ENGINE_PATHS), run.pygraphistry_commit, limit)))
return findings


def _strip_literal_directives(text: str) -> str:
"""Blank out regions where a literal is not a published claim.

Code blocks, shell recipes and inline literals hold reproduction commands and
API names, not board numbers.
"""
literal_start = re.compile(r'^\s*\.\.\s+(?:code-block|parsed-literal|literalinclude|math)::')
literal_paragraph = re.compile(r'(?<!\.)::\s*$')
out: List[str] = []
in_block = False
block_indent = 0
for line in text.split('\n'):
stripped = line.strip()
if in_block:
indent = len(line) - len(line.lstrip())
if stripped and indent <= block_indent:
in_block = False
else:
out.append('')
continue
if literal_start.match(line) or (literal_paragraph.search(line) and not stripped.startswith('..')):
in_block = True
block_indent = len(line) - len(line.lstrip())
out.append('')
continue
out.append(re.sub(r'``[^`]*``', '', line))
return '\n'.join(out)


def check_hand_typed_literals(data: BenchData) -> List[Finding]:
findings: List[Finding] = []
for rel in data.policy.managed_docs:
path = os.path.join(DOCS_SOURCE, rel)
if not os.path.isfile(path):
continue
allowed = data.policy.allowed_literals(rel)
text = _strip_literal_directives(_read(path))
for lineno, line in enumerate(text.split('\n'), start=1):
without_roles = _ROLE_RE.sub('', line)
for pattern in (_LATENCY_RE, _SPEEDUP_RE):
for match in pattern.finditer(without_roles):
literal = match.group(0).strip()
if literal in allowed:
continue
findings.append(Finding(
'{}:{}'.format(rel, lineno),
'hand-typed benchmark literal {!r}. Publish it as :bench:`<key>` from the '
'source-of-truth, or add it to policy.literal_allowlist if it is not a '
'measured claim.'.format(literal)))
return findings


def main(argv: Optional[Sequence[str]] = None) -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--data', default=None, help='path to gfql_benchmarks.json')
parser.add_argument('--today', default=None, help='YYYY-MM-DD; override the clock for freshness checks')
parser.add_argument('--no-git', action='store_true', help='skip the commit-drift check')
args = parser.parse_args(argv)

try:
data = load_bench_data(args.data)
except BenchDataError as exc:
sys.stderr.write('FAIL benchmark source-of-truth is invalid: {}\n'.format(exc))
return 2

if args.today:
year, month, day = args.today.split('-')
today = datetime.date(int(year), int(month), int(day))
else:
today = datetime.date.today()

checks: List[Tuple[str, List[Finding]]] = [
('reference integrity', check_reference_integrity(data)),
('freshness', check_freshness(data, today)),
('hand-typed literals', check_hand_typed_literals(data)),
]
if not args.no_git:
checks.append(('commit drift', check_commit_drift(data)))

total = 0
for name, findings in checks:
if findings:
total += len(findings)
sys.stderr.write('\nFAIL {} ({} finding(s)):\n'.format(name, len(findings)))
for finding in findings:
sys.stderr.write(' - {}\n'.format(finding.render()))
else:
sys.stdout.write('ok {}\n'.format(name))

if total:
sys.stderr.write(
'\n{} published benchmark number(s) cannot be defended from {}.\n'.format(
total, os.path.relpath(data.source_path, REPO_ROOT)))
return 1
sys.stdout.write('\nAll published benchmark numbers trace to {} run(s) with full provenance.\n'.format(
len(data.runs)))
return 0


if __name__ == '__main__':
sys.exit(main())
8 changes: 6 additions & 2 deletions docs/.rstcheck.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ ignore_roles =
py:attr,
py:exc,
py:obj,
py:data
py:data,
bench,
bench-diag

# Ignore Sphinx-specific directives
ignore_directives =
Expand All @@ -43,7 +45,9 @@ ignore_directives =
glossary,
productionlist,
include,
graphviz
graphviz,
bench-provenance,
bench-disclosures

# Ignore common informational messages
ignore_messages = (Hyperlink target "[^"]*" is not referenced\.$)
Expand Down
Loading
Loading