Skip to content

Commit 1ac2699

Browse files
lmeyerovclaude
andcommitted
docs(gfql): delete refuted and unverifiable benchmark numbers, and gate the rest
Two published OLAP ratios are not stale, they are wrong. `gfql/performance.rst` published q8 at "5.0 ms vs 1,004 ms for embedded Kuzu (200x)"; a matched, row-validated re-run measures 5.06 ms vs 9.76 ms = 1.93x. It published q9 at 14.2x; measured, 66.61 vs 84.06 = 1.26x. An audit of every other published performance figure found most of them can be neither confirmed nor refuted: the reproducers wrote to /tmp or only printed, recorded no commit/host/timestamp, and their artifacts were never committed. Two are worse than unverifiable - the LadybugDB competitor column was a hardcoded `LADYBUG = {...}` literal with no citation, and the filter->PageRank SVG charts are generated from a results directory that has never existed in any commit. All of it is removed rather than restated. The mechanism that stops it recurring ships with the deletion: a single generated source of truth (docs/source/_data/gfql_benchmarks.json), a Sphinx extension that resolves `:bench:` against it and fails the build on a missing, stale, non-quotable or undisclosed number, and a standalone gate that adds commit-drift and hand-typed-literal checks. Deletion and mechanism only - no editorial rewriting. The prose rewrite is a separate PR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015YsqAZQLbqjSDrYSFz2GoB
1 parent 233b64c commit 1ac2699

20 files changed

Lines changed: 2908 additions & 4567 deletions

CHANGELOG.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,14 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
3737
- **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.
3838

3939
### Documentation
40-
- **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>`.
41-
- **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.
40+
- **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>`.
41+
- **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.)
42+
43+
- **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.
44+
45+
- **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".
46+
47+
- **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.
4248

4349
## [0.58.0 - 2026-07-22]
4450

bin/check_bench_numbers.py

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
#!/usr/bin/env python3
2+
"""Gate published benchmark numbers on the single source-of-truth.
3+
4+
Runs the same data validation as the ``gfql_bench`` Sphinx extension, without
5+
Sphinx and without re-running any benchmark, plus two checks a docs build cannot
6+
make:
7+
8+
1. **Commit drift** — how many commits touching the query engine have landed
9+
since a published run was measured. This is the check that catches "the board
10+
moved and nobody re-measured": a docs build is perfectly happy to render a
11+
number taken on a six-PRs-ago tree.
12+
2. **Hand-typed literals** — the managed pages may not contain a bare latency or
13+
speedup literal. A number typed by hand is by construction not a reference to
14+
the source-of-truth, which is how a figure becomes authoritative and then gets
15+
re-copied.
16+
17+
Exit status is non-zero on any finding, so it can gate CI and pre-commit.
18+
19+
Usage::
20+
21+
bin/check_bench_numbers.py # full gate
22+
bin/check_bench_numbers.py --no-git # skip the commit-drift check
23+
bin/check_bench_numbers.py --today 2027-01-01 # what will break, and when
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import argparse
29+
import datetime
30+
import os
31+
import re
32+
import subprocess
33+
import sys
34+
from typing import Dict, List, Optional, Sequence, Tuple
35+
36+
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
37+
DOCS_SOURCE = os.path.join(REPO_ROOT, 'docs', 'source')
38+
sys.path.insert(0, os.path.join(DOCS_SOURCE, '_ext'))
39+
40+
from gfql_bench_data import ( # noqa: E402
41+
BenchData,
42+
BenchDataError,
43+
load_bench_data,
44+
)
45+
46+
#: Paths whose churn invalidates a published query-latency number.
47+
ENGINE_PATHS = (
48+
'graphistry/compute',
49+
'graphistry/Engine.py',
50+
'graphistry/models/gfql',
51+
)
52+
53+
_ROLE_RE = re.compile(r':(bench|bench-diag):`([^`]+)`')
54+
_PROVENANCE_RE = re.compile(r'^\s*\.\.\s+bench-provenance::\s*(\S+)\s*$', re.MULTILINE)
55+
_DISCLOSURES_RE = re.compile(r'^\s*\.\.\s+bench-disclosures::\s*$', re.MULTILINE)
56+
57+
#: A latency literal: "68 ms", "13.83s", "1.20s".
58+
_LATENCY_RE = re.compile(r'(?<![\w.])\d[\d,]*(?:\.\d+)?\s*(?:ms|µs|us|s)(?![\w])')
59+
#: A speedup literal: "38x", "9.4×", "~56×".
60+
_SPEEDUP_RE = re.compile(r'(?<![\w.])\d[\d,]*(?:\.\d+)?\s*[x×](?![\w])')
61+
62+
63+
class Finding:
64+
def __init__(self, where: str, message: str) -> None:
65+
self.where = where
66+
self.message = message
67+
68+
def render(self) -> str:
69+
return '{}: {}'.format(self.where, self.message)
70+
71+
72+
def _read(path: str) -> str:
73+
with open(path, 'r', encoding='utf-8') as handle:
74+
return handle.read()
75+
76+
77+
def _git(args: Sequence[str]) -> Optional[str]:
78+
try:
79+
completed = subprocess.run(
80+
['git'] + list(args),
81+
cwd=REPO_ROOT,
82+
stdout=subprocess.PIPE,
83+
stderr=subprocess.PIPE,
84+
check=False,
85+
)
86+
except OSError:
87+
return None
88+
if completed.returncode != 0:
89+
return None
90+
return completed.stdout.decode('utf-8', 'replace')
91+
92+
93+
def check_reference_integrity(data: BenchData) -> List[Finding]:
94+
"""Every ``:bench:`` reference resolves, and its page carries the caveats."""
95+
findings: List[Finding] = []
96+
for rel in data.policy.managed_docs:
97+
path = os.path.join(DOCS_SOURCE, rel)
98+
if not os.path.isfile(path):
99+
findings.append(Finding(rel, 'managed doc is listed in policy.managed_docs but does not exist'))
100+
continue
101+
text = _read(path)
102+
keys = [match.group(2) for match in _ROLE_RE.finditer(text)]
103+
declared_runs = set(_PROVENANCE_RE.findall(text))
104+
has_disclosures = bool(_DISCLOSURES_RE.search(text))
105+
106+
needed_runs: List[str] = []
107+
needs_disclosure = False
108+
for key in keys:
109+
if key not in data.cells:
110+
findings.append(Finding(rel, 'references unknown benchmark key {!r}'.format(key)))
111+
continue
112+
cell = data.cells[key]
113+
if cell.run_id not in needed_runs:
114+
needed_runs.append(cell.run_id)
115+
if cell.disclosures:
116+
needs_disclosure = True
117+
for run_id in needed_runs:
118+
if run_id not in declared_runs:
119+
findings.append(Finding(
120+
rel, 'publishes run {!r} without a `.. bench-provenance:: {}` block'.format(run_id, run_id)))
121+
if needs_disclosure and not has_disclosures:
122+
findings.append(Finding(
123+
rel, 'publishes a disclosure-bearing number but has no `.. bench-disclosures::` block'))
124+
return findings
125+
126+
127+
def check_freshness(data: BenchData, today: datetime.date) -> List[Finding]:
128+
findings: List[Finding] = []
129+
for run, age in data.stale_runs(today):
130+
findings.append(Finding(
131+
'runs.{}'.format(run.run_id),
132+
'measured {} ({} days ago); policy.max_age_days is {}. Re-measure or drop the claim.'.format(
133+
run.measured_at.isoformat(), age, data.policy.max_age_days)))
134+
return findings
135+
136+
137+
def check_commit_drift(data: BenchData) -> List[Finding]:
138+
"""Fail when the query engine has moved materially since a run was measured."""
139+
findings: List[Finding] = []
140+
limit = data.policy.max_compute_commit_drift
141+
for run_id in sorted(data.runs):
142+
run = data.runs[run_id]
143+
exists = _git(['cat-file', '-e', run.pygraphistry_commit + '^{commit}'])
144+
if exists is None:
145+
findings.append(Finding(
146+
'runs.{}'.format(run_id),
147+
'pygraphistry_commit {} is not in this repository, so the run cannot be placed in '
148+
'history and its numbers cannot be validated'.format(run.pygraphistry_commit)))
149+
continue
150+
log = _git(['log', '--oneline', '{}..HEAD'.format(run.pygraphistry_commit), '--'] + list(ENGINE_PATHS))
151+
if log is None:
152+
findings.append(Finding(
153+
'runs.{}'.format(run_id),
154+
'could not compute commit drift from {}'.format(run.pygraphistry_commit)))
155+
continue
156+
drift = len([line for line in log.splitlines() if line.strip()])
157+
if drift > limit:
158+
findings.append(Finding(
159+
'runs.{}'.format(run_id),
160+
'{} commits touching {} have landed since {} was measured (policy max {}). '
161+
'The published numbers describe a tree that no longer exists.'.format(
162+
drift, '/'.join(ENGINE_PATHS), run.pygraphistry_commit, limit)))
163+
return findings
164+
165+
166+
def _strip_literal_directives(text: str) -> str:
167+
"""Blank out regions where a literal is not a published claim.
168+
169+
Code blocks, shell recipes and inline literals hold reproduction commands and
170+
API names, not board numbers.
171+
"""
172+
literal_start = re.compile(r'^\s*\.\.\s+(?:code-block|parsed-literal|literalinclude|math)::')
173+
literal_paragraph = re.compile(r'(?<!\.)::\s*$')
174+
out: List[str] = []
175+
in_block = False
176+
block_indent = 0
177+
for line in text.split('\n'):
178+
stripped = line.strip()
179+
if in_block:
180+
indent = len(line) - len(line.lstrip())
181+
if stripped and indent <= block_indent:
182+
in_block = False
183+
else:
184+
out.append('')
185+
continue
186+
if literal_start.match(line) or (literal_paragraph.search(line) and not stripped.startswith('..')):
187+
in_block = True
188+
block_indent = len(line) - len(line.lstrip())
189+
out.append('')
190+
continue
191+
out.append(re.sub(r'``[^`]*``', '', line))
192+
return '\n'.join(out)
193+
194+
195+
def check_hand_typed_literals(data: BenchData) -> List[Finding]:
196+
findings: List[Finding] = []
197+
for rel in data.policy.managed_docs:
198+
path = os.path.join(DOCS_SOURCE, rel)
199+
if not os.path.isfile(path):
200+
continue
201+
allowed = data.policy.allowed_literals(rel)
202+
text = _strip_literal_directives(_read(path))
203+
for lineno, line in enumerate(text.split('\n'), start=1):
204+
without_roles = _ROLE_RE.sub('', line)
205+
for pattern in (_LATENCY_RE, _SPEEDUP_RE):
206+
for match in pattern.finditer(without_roles):
207+
literal = match.group(0).strip()
208+
if literal in allowed:
209+
continue
210+
findings.append(Finding(
211+
'{}:{}'.format(rel, lineno),
212+
'hand-typed benchmark literal {!r}. Publish it as :bench:`<key>` from the '
213+
'source-of-truth, or add it to policy.literal_allowlist if it is not a '
214+
'measured claim.'.format(literal)))
215+
return findings
216+
217+
218+
def main(argv: Optional[Sequence[str]] = None) -> int:
219+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
220+
parser.add_argument('--data', default=None, help='path to gfql_benchmarks.json')
221+
parser.add_argument('--today', default=None, help='YYYY-MM-DD; override the clock for freshness checks')
222+
parser.add_argument('--no-git', action='store_true', help='skip the commit-drift check')
223+
args = parser.parse_args(argv)
224+
225+
try:
226+
data = load_bench_data(args.data)
227+
except BenchDataError as exc:
228+
sys.stderr.write('FAIL benchmark source-of-truth is invalid: {}\n'.format(exc))
229+
return 2
230+
231+
if args.today:
232+
year, month, day = args.today.split('-')
233+
today = datetime.date(int(year), int(month), int(day))
234+
else:
235+
today = datetime.date.today()
236+
237+
checks: List[Tuple[str, List[Finding]]] = [
238+
('reference integrity', check_reference_integrity(data)),
239+
('freshness', check_freshness(data, today)),
240+
('hand-typed literals', check_hand_typed_literals(data)),
241+
]
242+
if not args.no_git:
243+
checks.append(('commit drift', check_commit_drift(data)))
244+
245+
total = 0
246+
for name, findings in checks:
247+
if findings:
248+
total += len(findings)
249+
sys.stderr.write('\nFAIL {} ({} finding(s)):\n'.format(name, len(findings)))
250+
for finding in findings:
251+
sys.stderr.write(' - {}\n'.format(finding.render()))
252+
else:
253+
sys.stdout.write('ok {}\n'.format(name))
254+
255+
if total:
256+
sys.stderr.write(
257+
'\n{} published benchmark number(s) cannot be defended from {}.\n'.format(
258+
total, os.path.relpath(data.source_path, REPO_ROOT)))
259+
return 1
260+
sys.stdout.write('\nAll published benchmark numbers trace to {} run(s) with full provenance.\n'.format(
261+
len(data.runs)))
262+
return 0
263+
264+
265+
if __name__ == '__main__':
266+
sys.exit(main())

docs/.rstcheck.cfg

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ ignore_roles =
2020
py:attr,
2121
py:exc,
2222
py:obj,
23-
py:data
23+
py:data,
24+
bench,
25+
bench-diag
2426

2527
# Ignore Sphinx-specific directives
2628
ignore_directives =
@@ -43,7 +45,9 @@ ignore_directives =
4345
glossary,
4446
productionlist,
4547
include,
46-
graphviz
48+
graphviz,
49+
bench-provenance,
50+
bench-disclosures
4751

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

0 commit comments

Comments
 (0)