Skip to content

feat: add LDBC SNB Interactive short-read benchmark (Lane 1)#225

Merged
pdlug merged 70 commits into
mainfrom
feat/bench-snb-lane1
Jul 14, 2026
Merged

feat: add LDBC SNB Interactive short-read benchmark (Lane 1)#225
pdlug merged 70 commits into
mainfrom
feat/bench-snb-lane1

Conversation

@pdlug

@pdlug pdlug commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

First lane of the real-workload benchmark program: the LDBC SNB Interactive short-read queries (IS1-IS7), run against four engines under a shared fairness harness, at both SF1 and SF10 scale.

  • New packages/benchmarks/src/real/: schema (schema/snb-graph.ts), dataset streaming reader (dataset/ldbc-csv.ts) + a tiny committed smoke fixture, four engine drivers (engines/typegraph-sqlite.ts, typegraph-postgres.ts, neo4j.ts, ladybug.ts), and a lane-agnostic harness (harness/: stats with CV-based noisy-sample flagging, value-level parity gate, competitor doctor, summary.json writer, history.jsonl appender).
  • TypeGraph engines drive IS1-IS7 entirely through the query builder — no hand-written SQL (engines/typegraph-queries.ts's module doc explains the .prepare()/.execute() fairness rationale and the Message ontological-supertype workaround for the polymorphic replyOf edge).
  • Neo4j and LadybugDB launch imperatively (docker run + tmpfs / in-process embedded) and implement the same 7 queries in native Cypher, using their own native handling of polymorphic node/edge types (multi-label nodes, multi-pair REL TABLEs) instead of TypeGraph's ontology workaround.
  • New packages/benchmarks/src/real/ec2/: an EC2 runner (docs/ec2-benchmark-runner.md) so SF1/SF10 can run on a dedicated, ephemeral cloud instance instead of local hardware. SSM Run Command is the default, no-key-pair control channel; generic enough for anyone who clones the repo to point at their own AWS account. An opt-in --ssh-public-key-path diagnostic fallback exists for when SSM itself is what's broken. Hardened over five rounds of review — see below.
  • CI-safe: bench:snb:smoke:check runs the tiny fixture end-to-end; a competitor-doctor preflight reports missing Docker/packages as explicit failed rows (never silent), and the run stays green with 0 or 1 runnable engines — it only fails on a genuine parity mismatch (row count or value digest) between 2+ engines that actually ran.
  • Incidental fix: the root .gitignore's unscoped reports/ rule (meant for Stryker mutation-testing output) had been silently excluding packages/benchmarks/reports/history.jsonl from every commit since it was introduced — scoped to packages/typegraph/reports/ so both the existing synthetic perf suite's history and this lane's history are actually committed going forward.

One packages/typegraph change: walAutocheckpointPages, a new createLocalSqliteBackend pragma option (see "SF10" below for why). This branch and main independently arrived at the same fix; main's version (merged separately as #251, with an upper-bound validation this branch's original commit didn't have) is now what this branch carries after reconciling with main — see "main reconciliation" below. Everything else is benchmarks-only; three other library fixes were needed along the way but each landed separately in its own PR (#226, #227).

Review history: 5 rounds, all fixed and re-verified with a fresh EC2 run

Five rounds of review found issues serious enough that no query-latency number from before this PR's fixes could be trusted. All findings below are fixed; a fresh SF1 + SF10 EC2 run on the fully-fixed commit (2bc7f74f) has since replaced every invalidated number — see Results below.

Round 1 (6 findings)

  1. IS2 implemented the wrong workload, in all three engine drivers. Checked against the official LDBC reference Cypher (ldbc/ldbc_snb_interactive_v1_impls): IS2 is "recent messages of a person" — the given person's own messages, tie-broken messageId ASC. This PR's drivers traversed to that person's friends and measured messages they authored, tie-broken DESC — a materially different, heavier workload. Every engine shared the identical mistake, which is exactly why row-count parity never caught it. Fixed in typegraph-queries.ts, neo4j.ts, and ladybug.ts.
  2. materializeIndexes()'s best-effort result was silently discarded in both the SQLite and Postgres drivers. Fixed: assertMessageIndexMaterialized() (schema/snb-graph.ts) checks the result and throws if the index wasn't created or already materialized.
  3. collect() (run-sf1-ec2.ts) extracted the benchmark's exit code but never checked it. Fixed: throws (after writing whatever partial artifacts exist) on non-Success SSM status, a missing exit-code marker, or a nonzero exit code.
  4. --profile=sf10 always defaulted to c7i.4xlarge, the same 32GB instance type that OOM'd on four separate SF10 attempts. Fixed: profile-aware default (r7i.4xlarge for sf10); --instance-type still overrides explicitly.
  5. Neo4j's snb_post_id/snb_comment_id constraints had no current query-time consumer (every by-id match goes through the shared :Message label). Fixed: removed.
  6. Release-facing copy didn't match the branch (stale "no typegraph changes," "fastest across the board" overclaim, a nonexistent script reference, a stale "no direct measurement" line). Fixed throughout the results doc, the EC2 guide, and this PR body.

Round 2 (5 more findings — the smoke run in round 1 proved execution and row-count parity, not LDBC semantic parity)

  1. IS2 was still incomplete after round 1's fix. The official query also returns message content and root-post-author names; TypeGraph omitted content, Neo4j and LadybugDB omitted both content and author names. IDs were also tie-broken with plain localeCompare ("message:10" sorting before "message:2") despite official ids being numeric. Fixed: content + names added to all three engines' IS2; a shared compareIdsAscending() helper (engines/types.ts) makes every id tie-break numeric-aware.
  2. IS3/IS5/IS6/IS7 weren't equivalent across engines either, and row-count-only parity could never have caught any of it: TypeGraph's IS3 had no defined ordering at all; Neo4j's and LadybugDB's IS6 omitted forum id/title; TypeGraph's and LadybugDB's IS7 omitted reply content and author names. Fixed: every query across all three engines now returns exactly the official LDBC output fields, correctly ordered.
  3. The parity gate itself only ever compared row counts. Upgraded to a value-level canonical digest per row (SnbQueryResult.digest, canonicalDigest() in engines/types.ts, harness/parity.ts) — engines can no longer agree on count while disagreeing on field values, omitted fields, or order. Verified on the smoke fixture: this immediately caught one real remaining bug (TypeGraph's IS3 digest used the field name personId, Neo4j/LadybugDB used id — same values, incomparable digests) before landing at all 7 queries passing true value-level parity across all four engines.
  4. A successful SSM command with no results could still report success (resultsText === "{}" didn't feed the failure predicate). Fixed: !hasParseableResults added to collect()'s failure check.
  5. Failed runs could contaminate canonical historyreports/history.jsonl was appended before validating the run succeeded. Fixed: raw history lines are always preserved locally (run-scoped, not shared) for post-mortem; the canonical file is only appended after every success condition passes. A related, smaller finding — four smoke-test history rows this session accidentally generated recorded a git SHA that didn't contain the uncommitted fixes producing them — was caught and reverted, not committed.
  6. (P2) Neo4j's fairness label still described the just-removed Post/Comment constraints, and the results doc claimed Neo4j's load time was unaffected by round 1's fixes when constraint creation actually runs inside its timed load(). Both fixed.

Round 3 (2 more P1s — round 2's own IS2 fix wasn't actually correctness-proof — plus 3 P2s)

  1. Round 2's IS2_CANDIDATE_LIMIT buffer (a fixed-size native LIMIT re-sorted numerically in JS) wasn't provably correct. Confirmed with a concrete counter-example: a same-creationDate tie cluster larger than the buffer (e.g. 30 messages sharing one timestamp, ids 1-30) causes the native lexicographic LIMIT to exclude genuinely-top-10 candidates (numeric ids 3-9) before the JS resort ever sees them — even a correct numeric resort of a wrong candidate set produces a wrong answer. Fixed by removing the native LIMIT entirely across all three engines: IS2 now fetches all of a person's own messages (unlimited), sorts numerically, and slices to 10 purely in JS — correct regardless of tie-cluster size.
  2. collect()'s success predicate still didn't require a parseable summary.json or non-empty history.jsonl lines — only results.json was checked. Fixed: hasParseableSummary/hasHistoryLines added alongside the existing checks; a run is only "successful" when all three reproducibility artifacts are present.
  3. (P2) Stale "row-count parity" wording throughout cli.ts, request-plan.ts, snb-short-reads.ts's console output, the README, and the results doc — corrected to describe both row-count and value-digest parity, matching round 2's actual gate upgrade.
  4. (P2) README's SF1 load-time numbers were pre-fix stale (75-80 min) — updated to the current numbers (SQLite ~40 min, Postgres ~12 min).
  5. (P2) Neo4j's loader was described as "batched UNWIND ... IN TRANSACTIONS" in both the README and results doc when it's actually the offline neo4j-admin database import — corrected in both places. Also softened the lane's "official LDBC" framing: the README now explicitly names the two relationships this schema deliberately flattens into properties (Person.cityId, Forum.moderatorId) instead of modeling as edges, and calls the lane "LDBC SNB-derived" rather than a full schema-conformance claim — the query fields and ordering are still verified against the official reference; the schema-shape simplification is a separate, honestly-disclosed thing.

Round 4 (2 more P1s — round 3's own IS2 fix traded correctness for a new fairness problem)

  1. Round 3's fix (removing IS2's native LIMIT entirely) was correctness-proof but changed the measured workload. The official query applies its own ORDER BY messageCreationDate DESC, messageId ASC LIMIT 10 engine-side, before the root-post-author walk. Fetching every message a person ever authored instead — every engine, unconditionally — transferred full message content over the network for engines that pay a real round trip (Postgres, Neo4j), disproportionately penalizing them versus the embedded engines, and diverged from official semantics. The repo's own index documentation notes some IS2 candidate pools run to thousands of rows, so this wasn't a theoretical concern. Root-caused properly instead of patched again: dataset/ldbc-csv.ts now zero-pads every id's numeric portion to a fixed width, so a plain lexicographic ORDER BY id ASC (SQL or Cypher alike) already agrees with numeric order, for any tie-cluster size — this is what actually broke in round 2/3, not the presence of a LIMIT per se. With that fixed, native ORDER BY ... LIMIT 10 is restored everywhere: TypeGraph/LadybugDB fetch each of Post/Comment's own top 10 and merge (any message in the true top 10 of the union must be in its own type's top 10 too, so this is provably equal to the true top 10, for any candidate-pool or tie-cluster size, transferring at most 20 rows regardless of how many messages the person actually authored); Neo4j's unified :Message label needs only one query.
    • Also added: bench:snb:verify-is2-tie-break, an adversarial correctness oracle independent of cross-engine consensus. Every engine agreeing has already gone wrong twice in this lane (the friend-workload bug, the lexicographic-tie-break bug) — consensus alone doesn't prove correctness. The smoke fixture now includes a dedicated person who authors 25 same-creationDate comments; with no other tie-breaking signal, that person's correct IS2 answer (the cluster's 10 smallest message ids) is knowable in advance, and the new script checks each doctor-runnable engine's actual result against that known answer directly. All 4 engines pass.
  2. The paid EC2 path could still report success with fewer than the full four-engine set. Doctor-unavailable engines are filtered out of the run rather than recorded as a failure — correct behavior for a local/CI invocation (a no-Docker environment should still exit 0 on the two embedded engines), wrong for a run whose entire point is a complete four-engine comparison: a container failing to start on the instance would silently produce a smaller-than-requested but still "successful" run. Fixed: collect() now parses results.json.engines and requires all four canonical engine names to be present before declaring success, and additionally fetches + preserves competitor-doctor.json locally (success or failure) so an incomplete run is diagnosable without re-connecting to the instance.

Round 5 (1 more P1 — round 4's own new oracle test didn't actually test anything)

  1. The tie-break oracle's 25 tie-cluster ids were one contiguous 3-digit range (120..144), so unpadded lexicographic order and numeric order coincide by construction — same-length numeral strings always compare identically both ways. The check would pass whether or not dataset/ldbc-csv.ts's zero-padding fix was actually applied, catching nothing; it was consensus theater dressed up as an oracle. Fixed: split into two blocks of different digit widths (3-digit 120..129, 4-digit 1000..1014) — unpadded lexicographic order now ranks every 4-digit id ahead of every 3-digit one ("1000" < "120" character-by-character), so an unpadded engine returns the wrong answer (1000..1009) instead of the true one (120..129). Verified directly, not just reasoned about: temporarily reverted the padding fix, confirmed all 4 engines fail with exactly that predicted wrong answer, then restored it and confirmed all 4 pass again.

Results

Full writeup: packages/benchmarks/reports/snb-lane1-results.md. Both scales below are fresh runs on commit 2bc7f74f (every fix from all 5 rounds in place): 0 engine failures, 7/7 queries at full value-level digest parity (not row-count alone) at both scales. Single run each — not yet a publishable comparison per the program plan's multi-run bar (tracked in the results doc's Next steps).

SF1 (9,892 persons, 1,003,605 posts, 2,052,169 comments) — 7/7 comparable=yes, 0 failures

Engine Load time
ladybugdb 41.6 s
neo4j 71.4 s (1.2 min)
typegraph-postgres 669.3 s (11.2 min)
typegraph-sqlite 587.1 s (9.8 min)

SQLite and Neo4j both load dramatically faster than the last SF1 run reported in this PR (SQLite ~4.1x, Neo4j ~4.3x) — not a new optimization, but the SF10 investigation's covering-index-deferral and constraint-removal fixes finally showing up in an SF1 number for the first time (that run predates both). Postgres and LadybugDB, whose load paths weren't touched by either fix, measured 3.5% and 22.7% faster than the previous run, respectively — cross-commit observations that can't establish run-to-run variance or causality on their own. See the results doc for the full query-latency table (IS2 still dominates every engine's latency, as structurally expected — a top-10 selection plus a per-message root-post-author walk).

SF10 (65,645 persons, 7,435,696 posts, 21,865,475 comments) — 7/7 comparable=yes, 0 failures

Getting one clean, fast SF10 run originally took eight EC2 attempts across two root-causing efforts — a memory-exhaustion failure disguised as a networking problem (attempts 1-5), then an EBS gp3 volume's default IOPS ceiling that made a correctly-implemented, locally-validated SQLite checkpoint-tuning fix show zero real-world improvement (attempts 6-8). Full narrative in the results doc. This run is on commit 2bc7f74f (all 5 review rounds); attempt 8 above was commit a58ae38e (load-time fixes only, predating those rounds) — a consistent second load-time data point, not the same commit:

Engine Load time
ladybugdb 354.0 s (5.9 min, +10.7% vs. attempt 8)
neo4j 404.2 s (6.7 min, +8.8%)
typegraph-postgres 7,127.5 s (1.98 h, +7.4%)
typegraph-sqlite 10,929.4 s (3.04 h, -2.1%, i.e. faster)

The second run differed by -2.1% to +10.7% across engines. These provide a consistent second load-time point, but two cross-commit observations do not establish run-to-run variance or causality — the two runs are on different commits, so a small, real, commit-attributable effect can't be ruled out from two samples alone. With a fresh SF1 run now available under the identical (2bc7f74f) fixes, the results doc also derives a fair SF1-to-SF10 growth-rate comparison for the first time: Postgres grows almost exactly linearly with the ~10.65x comment-count increase; SQLite grows noticeably super-linearly (~18.6x), consistent with the documented wal_autocheckpoint tuning's own caveat that it "holds through roughly 50,000-100,000 pages, then regresses again" — worth a deeper look if pursued further, not investigated in this PR.

pdlug added a commit that referenced this pull request Jul 5, 2026
Fixes a scaling bug discovered while running the LDBC SNB benchmark
(#225): `refreshStatistics()` on the SQLite backend ran a bare, unscoped
`ANALYZE` (no table argument), which does two things wrong:

1. **Unscoped**: it re-analyzes every table in the database file, not
just TypeGraph's own tables. The Postgres backend already scopes its
`ANALYZE` to TypeGraph-managed tables (`coreAnalyzeStatements` in
`postgres.ts`) — SQLite's never did.
2. **Unbounded**: it does a full table/index scan per call. Postgres's
`ANALYZE` always examines a bounded, fixed-size sample of rows (governed
by `default_statistics_target`) regardless of table size, so it never
showed this problem. SQLite's `ANALYZE` scans the whole table unless
bounded by `PRAGMA analysis_limit` (a feature that exists for exactly
this reason, per SQLite's own docs).

`bulkCreate`/`bulkInsert` on nodes and edges auto-trigger this refresh
once a single call's row count crosses
`AUTO_REFRESH_STATISTICS_ROW_THRESHOLD` (1000 rows, added in #212).
#212's own PR description says the design deliberately did not cover
"loops of small batches that never individually reach the threshold" —
but never considered loops of *large* batches (each already over the
threshold), which is exactly what any real streaming bulk loader does
for a multi-million-row dataset (and what this repo's own
`backend-setup.md` docs recommend: `for (const batch of batches) { await
store.nodes.Document.bulkCreate(batch); }`). Each such batch
independently re-triggers the refresh, and with unbounded per-call cost
growing with total table size, total load time integrates to **O(n²)**.

Discovered via a real LDBC SNB SF1 load (packages/benchmarks, #225): the
comments stage (~2M rows) ran for **over 4.5 hours without finishing**,
versus 46 minutes for the ~1M-row posts stage just before it — a ratio
far beyond what row-count alone explains. Isolated the cause with
controlled reproductions (60k-200k row loads), confirmed it disappears
with a single node kind and no edges, persists without any ontology
involved, and correlates with total graph size rather than any one
table's size.

## Fix

`refreshStatistics()` on the SQLite backend now:
- Sets `PRAGMA analysis_limit = 1000` (SQLite's own suggested value for
large databases) before running `ANALYZE`, bounding per-call cost
regardless of table size.
- Scopes `ANALYZE` to TypeGraph's own tables (`typegraph_nodes`,
`typegraph_edges`, `typegraph_node_uniques`, the fulltext table, plus
the `recorded_*` tables when present), matching the Postgres backend.

## Verification

- New test file `tests/backends/sqlite/refresh-statistics-scope.test.ts`
(4 tests): pins the `analysis_limit` value, asserts an unrelated table
sharing the same connection is never touched, asserts no throw when
`recorded_*` tables are absent, and bounds batch-to-batch growth in a
repeated-large-bulkInsert loop. Confirmed these tests **fail against the
pre-fix code** (reverted the fix, re-ran — 2 of 4 failed as expected)
before restoring the fix.
- Existing `tests/auto-refresh-statistics.test.ts` (#212's suite): all 8
tests still pass unchanged — no change to the trigger semantics, only to
what `refreshStatistics()` itself does on SQLite.
- Full `packages/typegraph` suite: 229 files / 4525 tests passed (898
skipped, Postgres-dependent — see below), 0 failures.
- Property-based suite: 345 passed / 1 skipped, 0 failures.
- Postgres suite (`pnpm test:postgres`, unaffected by this SQLite-only
change but run per this repo's convention for backend changes): 67 files
/ 1816 tests passed, 0 failures.
- Manual repro: 100k-row load (with the `Message` ontology) completes in
~8.4s with only ~2.1x growth from first batch to last (consistent with
normal B-tree logarithmic growth); 200k-row load (no ontology) completes
in ~22.9s with ~2.7x growth. Both previously would have shown the
~5x-per-58k-row trend that led to the multi-hour SF1 hang.
@pdlug

pdlug commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

SF10 IS2 latency cliff: root cause found and fixed

The first real SF10 EC2 run (10x SF1 data volume) surfaced a severe regression: IS2 (friends' recent messages) median latency jumped 689x on the SQLite backend (74ms → 51s) and 28x on Postgres (247ms → 7.0s), while Neo4j (7.3x) and LadybugDB (2.6x) scaled near-linearly over the same data growth. Everything else (IS1, IS3-IS7) stayed competitive.

Root cause (independently investigated and cross-checked against this codebase): the covering index this branch already added for the Post/Comment creationDate join (postByCreationDateIndex/commentByCreationDateIndex, keySystemColumns: ["id"]) carried id but not deleted_at/valid_from/valid_to — the three system columns every compiled query's soft-delete/temporal-validity predicate checks. Not truly covering, so every candidate message paid a heap-row fetch. Free at SF1 (table fits in page cache); catastrophic at SF10 (30-50GB table exceeding available cache, so every fetch becomes a random disk read). Measured: 30,897 → 734 page-cache misses per IS2 call (42x) once all three columns are added.

Fixed in two places:

  • fix: cover valid_from on default edge indexes; add SQLite cache-size pragmas #245 (core library, off main): the same gap existed in TypeGraph's own default edge traversal indexes (from_idx/to_idx — missing valid_from, used by every edge query in every app, not just this benchmark). Also adds host-aware cacheSizeKib/mmapSizeBytes pragma options to createLocalSqliteBackend (SQLite's own 2MiB default cache being the other half of the problem).
  • This branch (commit 92670c5): extends keySystemColumns to all three columns, consolidates the previously-duplicated Post/Comment index declarations (byte-identical, non-partial — pure redundant write cost), sizes SQLite's cache_size host-aware in the engine driver, and runs VACUUM ANALYZE after Postgres bulk load (without it, Postgres can't serve an index-only scan at all regardless of index shape).

Full investigation write-up (ruled-out hypotheses, EXPLAIN QUERY PLAN evidence, the isolated-vs-combined-scale local repros): see the linked artifact in session notes.

Not yet done: a real SF10 re-run to confirm the fix closes the gap at true scale — local repros verified the index-shape change and plan flip, but held off on the ~10-13h EC2 cost pending approval. Tracked as the next step before this branch's results can be trusted for the public comparison draft.

@pdlug

pdlug commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Synced the PR #245 review fixes onto this branch's copy of the same commit (`a42151c`) — `from_idx`/`to_idx` now also cover the join's target-id column (a second real gap found in review, beyond the `valid_from` fix), plus the `cacheSizeKib` validation and changeset guidance fixes. Full SQLite suite: 4,539 passed, 0 failed.

pdlug added 26 commits July 10, 2026 12:52
Adds packages/benchmarks/src/real/, the first lane of the real-workload
benchmark program in docs/design/benchmark-program-plan.md: IS1-IS7
implemented through the TypeGraph query builder (no hand SQL) against
TypeGraph/SQLite and TypeGraph/PostgreSQL, and via idiomatic Cypher
against Neo4j and LadybugDB, on the official LDBC SNB Interactive
dataset. Includes the shared fairness harness (row-count parity gate,
noisy-sample detection, competitor doctor, summary.json writer), a
committed tiny smoke fixture, and a CI-safe smoke test that degrades
gracefully when Docker/optional packages are unavailable.

Also fixes a long-standing root .gitignore bug: a bare `reports/` rule
meant for Stryker mutation-testing output was unscoped and had silently
excluded packages/benchmarks/reports/history.jsonl from every commit
since it was introduced.

Verified end-to-end on all four engines against the smoke fixture with
100% row-count parity across all seven queries. An attempted real SF1
run surfaced a bulk-load scaling issue in TypeGraph (documented in
packages/benchmarks/reports/snb-lane1-results.md as a finding, not
fixed here per the plan's benchmarks-only scope).
The TypeGraph/PostgreSQL container's 4GB tmpfs ran out of space loading
real LDBC SF1 data ("no space left on device") on an 8GB Docker VM —
the exact lesson the sibling braiddb project's own SNB driver
documents (real SF1 plus indexes overflows a RAM-backed data dir).
PGDATA now lives on the container's disk-backed writable layer instead
of a sized tmpfs. Neo4j's /data mount had the same latent risk (a fixed
6g tmpfs never exercised at SF1 scale before now) — switched to a named
docker volume, matching how braiddb's own Neo4j driver avoids tmpfs for
anything past its tiny synthetic profile. /logs and /tmp stay tmpfs on
both since their contents don't scale with dataset size.

Separately: the harness had no per-engine failure isolation, so the
Postgres crash above took the whole process down with it — losing
typegraph-sqlite's already-completed 87-minute SF1 run's structured
results.json/summary.json (the console log still had the printed
numbers, but the JSON artifacts were never written). Extracted the
per-engine load+measure logic into runEngine() and wrapped each
engine's turn in a try/catch: one engine's crash is now recorded as an
explicit failed row (never a silent loss) in results.json, and the run
continues to the remaining engines and still writes every artifact.
--check now also exits non-zero on an engine failure, not just a
row-count mismatch, since a doctor-runnable engine crashing mid-run is
a real regression distinct from "not runnable" (which must stay green).
Neo4j schema indexes are scoped to one label each and never inherited
across the other labels a multi-label node carries. The containerOf/
replyOf edge-wiring steps MATCH by id filtered on the concrete :Post/
:Comment label (not :Message), so without a label-specific index those
MATCHes fell back to a full label scan per row — at SF1 scale (~1M
Post nodes) a single 5,000-row containerOf batch turned into billions
of comparisons and multi-hour hangs.

Verified via EXPLAIN against a throwaway container: both edge-wiring
queries now plan as NodeUniqueIndexSeek(Locking) instead of
NodeByLabelScan. Smoke suite still passes with 100% row-count parity
across all four engines.
Runs the existing, unmodified snb-short-reads.ts on a dedicated,
ephemeral EC2 instance instead of local hardware, via SSM Run Command
(no SSH, no key pairs — matches the nicia-sandbox host security
group, which has no inbound SSH at all). launch provisions the box,
waits for bootstrap, and fires the benchmark command in the
background; collect polls until it finishes, writes results locally,
appends to reports/history.jsonl, and terminates the instance.

Motivated by local runs being slowed by Time Machine backup
contention and Docker Desktop's 8GB/8-CPU VM sharing resources with
several other projects' running containers.
…medir

resolveDatasetRoot's SF1_CACHE_DIR is built from os.homedir(), which
resolves on whichever machine evaluates it. The EC2 bootstrap script
imported that same joined constant, so it baked the *local* (macOS)
homedir into the remote Ubuntu box's dataset download path instead of
/root — caught in the smoke dry run's decoded user-data before it
could break a real SF1 attempt (smoke skips the dataset step, so it
never surfaced there). Export the path segments instead of the joined
path, and join them against /root on the bootstrap-script side.
Real end-to-end verification of the new EC2 runner (launch/collect,
SSM Run Command, result parsing) against the committed smoke fixture,
run on a c7i.4xlarge in nicia-sandbox. All 4 engines, 100% row-count
parity.
The official LDBC SF1 archive extracts into its own
social_network-...-LongDateFormatter/ subdirectory, not flat into the
current directory. sf1DownloadInstructions()'s documented curl+tar
steps (and the EC2 bootstrap script, which copied them) both expected
dynamic/person_0_0.csv directly under the cache dir, so a fresh
auto-download always failed dataset resolution afterward. This path
was never exercised locally (dev always used --data-dir against a
pre-extracted copy from the sibling braiddb project's cache), so
nothing caught it until the first fully-automated EC2 bootstrap tried
a real download. tar --strip-components=1 lands dynamic/, static/,
etc. directly in the cache dir, matching what isSnbDatagenDirectory
already expects. Verified against the real cached archive before
pushing.
AWS-RunShellScript's document parameter executionTimeout defaults to
3600s (1h) and is entirely separate from send-command's top-level
--timeout-seconds (a delivery timeout, not an execution timeout).
Without setting it explicitly, the benchmark's SSM command was killed
after exactly one hour regardless of --benchmark-timeout-seconds,
mid-run, with no error from the benchmark itself. Caught because the
real SF1 run was still healthy (InProgress, log advancing) right up
until the silent 1-hour cutoff.

Also wrap collect()'s result-parsing/writing in try/finally so
instance termination always runs (unless --keep), even when parsing
fails or the command didn't reach Success — previously an early
throw left the instance running and unbilled-for-nothing until the
bootstrap script's 6h dead-man's switch caught it, discovered when
exactly this happened after the executionTimeout cutoff.
The bootstrap script's self-shutdown safety net was a hardcoded flat
360 minutes (6h) — coincidentally the exact same duration as the
benchmark's own default SSM executionTimeout. A real SF1 run that
legitimately took close to 6 hours got killed by its own safety net
with ladybugdb (the last engine) still finishing, losing the whole
run one step from completion (confirmed via
StateReason=Client.InstanceInitiatedShutdown).

The dead-man's switch exists to catch "collect was never invoked",
not to race a benchmark that's still running. Its duration is now
--bootstrap-timeout-seconds + --benchmark-timeout-seconds + a 1h
buffer, so it can only fire after both of those windows have already
closed.
A real SF1 run's sqlite (74.5min) + postgres (78.4min) + neo4j
(4.5min) load phases alone already used ~2.6h, and the fourth launch
hit the 6h executionTimeout with only ladybugdb's load left
unfinished (sqlite/postgres/neo4j all completed cleanly, all 21
queries done). 6h was an initial guess with no real data behind it;
10h leaves comfortable margin now that we have real per-engine
timings. The dead-man's switch scales with this automatically (it's
derived from bootstrap + benchmark timeouts, not hardcoded).
LadybugDB (Kuzu-family) stores relationships in a columnar CSR
(Compressed Sparse Row) adjacency structure, built once for bulk
COPY FROM but rebuilt on every incremental MATCH+CREATE edge write.
The previous loader batched writes via UNWIND+CREATE (matching every
other engine driver's own pattern), which scaled roughly cubically:
a controlled repro showed edge insertion going from 46s at 50k edges
to 388s at 100k edges (~8.4x time for 2x data). At SF1 scale
(~2.4M edges) this made ladybugdb's load impractical — a real SF1
EC2 run got the other three engines done in ~2.6h combined, then
stalled on ladybugdb for 4+ hours without finishing.

Rewrote loadSnbDataset() to stream each entity/edge kind to a staging
CSV file, then issue one COPY <table> FROM per file, in dependency
order (nodes before edges referencing them) via the same
stageComplete() sequencing the streaming reader already guarantees.
Multi-pair relationship tables (HasCreator, ReplyOf) disambiguate via
COPY's from=/to= options, verified against the installed engine
version directly (Kuzu's own docs disagreed with themselves on the
header option name). parallel=false avoids a known limitation with
quoted newlines in the default parallel CSV reader.

Verified: smoke suite still passes with 100% row-count parity across
all 4 engines. A same-scale repro against the new COPY-based path
shows edge loading dropping from 388,056ms to 57ms at 100k edges
(~6,800x) and scaling linearly through 800k edges (a scale never
reached by the old path in practical time).
Add a bench:snb:generate-smoke-fixture script so knip recognizes
generate-smoke-fixture.ts as a real entry point instead of a script
only discoverable via a comment. Drop the `export` keyword from
SF1_CACHE_DIR, collectHardwareInfo, HardwareInfo, DoctorCheck,
LaneHistoryEntry, SnbEngineOptions, and snb-graph.ts's individual
node/edge definitions (Person/Forum/Post/Comment/Message/knows/
hasCreator/containerOf/replyOf) plus its SnbGraph type — all genuinely
module-private (only ever consumed within their own file; other files
import the composed snbGraph/SnbStore/EngineVersion/etc. instead).
Also drops types.ts's SnbRowSink re-export, which nothing actually
imported (engine drivers import it directly from dataset/ldbc-csv.ts).

Verified: knip clean, typecheck clean, smoke suite still 100%
row-count parity across all 4 engines.
The COPY FROM rewrite (previous commit) fixed the scaling problem but
introduced a correctness bug at real SF1 scale: a real SF1 EC2 run
failed loading ladybugdb with "expected 4 values per row, but got
more" on a forum title containing an embedded comma
("Group for Charles_V,_Holy_Roman_Emperor in Copenhagen") — despite
being correctly RFC4180-quoted.

Reproduced locally against the real dataset: the exact same line
parses fine in total isolation (a 2-line test file), but fails
against the real 90,492-row file at the identical line — the failing
line's byte offset (~1,082,649) sits suspiciously close to a 1MB
buffer boundary. This points to a bug in Ladybug's CSV parser mis-
handling a quoted field that straddles an internal read-buffer
boundary in large files, not a bug in this loader's own escaping.

Comma is a poor delimiter choice for LDBC's natural-language content
regardless — titles/post/comment text contain commas constantly,
forcing quoting (and this bug's exposure) on a large fraction of
rows. Switching to `|` (one of Kuzu/Ladybug's supported delimiters)
means real content almost never needs quoting at all, since a literal
pipe essentially never appears in prose.

Verified against the real cached SF1 dataset end-to-end (not just the
one failing table): the full dataset now loads via the actual
ladybugdb engine driver in 20.4s, with all 7 IS queries running
cleanly afterward.
…o4j)

From the run that caught the ladybugdb CSV delimiter bug: sqlite,
postgres, and neo4j all completed successfully with full IS1-7
results before ladybugdb failed. Real numbers worth keeping rather
than discarding.
reports/snb-lane1-results.md now leads with a real, single-run SF1
result across all four engines (100% row-count parity, 0 failures)
instead of the "does not complete in a practical time" finding —
documents the three scaling bugs found and fixed to get there (SQLite
ANALYZE, Neo4j missing Post/Comment indexes, LadybugDB CSR-vs-
incremental-writes), and files a new follow-up: TypeGraph's
SQLite/Postgres backends load ~65-100x slower than Neo4j/LadybugDB at
this scale, likely because the latter two use an engine-native bulk
path bulkInsert doesn't have an equivalent of.

README.md's bench:snb:sf1 section: replaces the outdated "does not
complete" caution with real load-time expectations per engine, adds
the EC2 runner as an alternative to local hardware, and fixes the
manual download instructions' own missing --strip-components=1 (the
official archive extracts into its own subdirectory, not flat — the
same bug the EC2 bootstrap script had until an earlier commit here).

Also commits the 4 new real SF1 history.jsonl entries (one per
engine) from the run these numbers come from.
Profiling a synthetic 1M-row bulk load showed store.refreshStatistics()
firing on every single bulkInsert() call, not just occasionally: the
auto-trigger checks each individual call's row count against the
threshold (1000), and our loader's batches (2000 rows) always exceed
it, so every batch re-triggers a full statistics refresh across all
core tables. Disabling entirely a flat ~2.7x speedup for node inserts
in isolation (22-75ms/batch growing with table size -> flat ~17-23ms)
on a synthetic 1M-row repro, since both engine drivers already call
store.refreshStatistics() explicitly once after the whole load
finishes — the auto-trigger was pure redundant work for this caller.

Not a library-behavior change: autoRefreshStatistics is an existing,
documented store option; this only sets it for how this specific
benchmark already uses the store.
Each bulkInsert() call gets its own transaction (runInWriteTransaction),
while the bind-parameter-limit chunking that keeps individual INSERT
statements within the driver's bound happens *inside* that call,
independent of this outer size. A smaller outer batch just means more
transactions/round-trips for the same total rows, not smaller/safer
statements.

Profiled on a synthetic 500k-row repro (post-#227): 2,000 (the old
value) was consistently the slowest across two separate runs; anything
>=10,000 was 25-30% faster, with the exact optimum noisy run-to-run on
this shared dev machine (10k/20k/50k traded places for fastest between
runs). 20,000 is a well-supported middle ground, not a precisely tuned
peak — comfortably below both SQLite's (~3,640) and Postgres's
(~7,281) internal per-statement chunk sizes' multiples, so this still
issues multiple appropriately-sized statements per call rather than
one enormous one.

Verified: smoke suite still 100% row-count parity across all 4 engines.
Each message's root-post walk in the LDBC IS2 benchmark query was
independent of the others but ran sequentially in a for loop. Switching
to Promise.all overlaps round-trip latency across up to 10 messages
instead of paying it serially, with no query-shape change.

Verified functionally (row-count parity, smoke + full SF1) and via a
same-machine controlled A/B on SQLite (~1.8x faster, not a regression
as a cross-machine comparison against the EC2 baseline table falsely
suggested).
Same pattern as IS2: the parent-author lookup and the replies fetch
each depend only on message.id, not on each other, so they now run
concurrently via Promise.all instead of paying two round trips
serially. The knows-check that genuinely depends on both results stays
sequential after them.

Verified via row-count parity (100% comparable) at smoke and full real
SF1 scale.
Fresh full SF1 EC2 run reflecting PR #227 (edge-creation N+1 endpoint
check) and the loader batch-size tuning: sqlite load time down ~1.85x,
postgres down ~7x. Document that fix alongside the existing three.

Also documents the concurrent-root-walk experiment for IS2/IS7: tried,
measured on this same dedicated box, and reverted — neutral for SQLite
(which serializes concurrent execute() calls anyway) and a mild
regression for Postgres (this benchmark's Postgres runs over localhost
Docker, so there's little real round-trip latency to overlap).
…can caveat

Documents keySystemColumns on the public performance/indexes page, with
the reverse-traversal covering-index example that motivated it.
Corrects the existing "covering indexes enable index-only scans" claim
with the real limitation found this cycle: PostgreSQL doesn't serve
Index Only Scan for JSONB expression indexes, only real stored columns
(EXPLAIN ANALYZE, BUFFERS confirmed) — with a workaround via a
self-maintained generated column until TypeGraph has a built-in one.

Also adds batch-sizing guidance for large multi-call bulk imports
(larger per-call batches measurably reduce transaction-commit and
auto-refresh-statistics overhead), and a short note that these findings
came from validating TypeGraph against the LDBC SNB Interactive
benchmark, without publishing comparative numbers against other
engines (not yet at publishable-comparison confidence per the internal
benchmark report).
Generalizes dataset/resolve.ts from hardcoded SF1-only constants
(SF1_ARCHIVE/SF1_CACHE_DIR/SF1_DOWNLOAD_URL) to a per-profile spec map
(SNB_DATASET_SPECS), so adding a new scale factor is a data change, not
a new code path. Threads sf10 through the CLI (--profile parsing,
warmup/sample defaults), the EC2 bootstrap script (dataset download
step now works for any real profile), and the EC2 runner (profile
validation, a new bench:snb:sf10 script, and a profile-aware
benchmark-timeout default — sf10 defaults to 36h given it's ~10x SF1's
row counts with no direct measurement yet of how each engine's load
time actually scales at that volume).

Needed to get a second scale-factor data point for a defensible
public performance comparison (single-scale, single-run numbers aren't
enough to claim a trend holds).
…er pin

Replaces the online batched UNWIND ... IN TRANSACTIONS Cypher load with
Neo4j's own offline neo4j-admin database import full, run as a one-off
docker run against the same named volume before the server container
starts. Neo4j's own documentation recommends this over online writes
past ~10M records into an empty database — SF1 scale is well past
that, so the previous load-time number understated what a real
deployment would show. Stages the LDBC CSVs into neo4j-admin's own
:ID/:START_ID/:END_ID header shape first, typing every property
:string explicitly so imported values stay byte-identical to what the
retired Cypher CREATE path produced. Verified: row counts match
exactly at real SF1 scale (9,892 persons, 1,003,605 posts, 2,052,169
comments), load time drops ~6.7-6.9x in same-machine A/B testing
(confirmed twice independently).

Also replaces the flat 2g/2g heap/page-cache constants with sizing
derived from neo4j-admin's own `server memory-recommendation --docker`
against the memory actually visible to the Docker daemon (not
os.totalmem() — Docker Desktop's VM can expose far less RAM than the
host), with a documented heuristic fallback. The flat 2g cap
artificially starved Neo4j on hosts with far more available memory
while the other three engines used it uncapped or unconstrained.

Both issues, plus two more below, were found by an adversarial
fairness review run ahead of publishing a benchmark comparison —
self-graded competitor code is exactly how these comparisons go wrong.

Separately, a dedicated query-plan audit (PROFILE against every
IS1-IS7 query at real scale) found: (1) IS2's friend-frontier step,
IS3, and IS7's knows-check all compiled to a full NodeIndexScan
instead of NodeUniqueIndexSeek under Neo4j's default Cypher 25 planner
for the pattern `MATCH (:Person {id: $id})-[:KNOWS]->(friend:Person)
...` — a real planner limitation for this exact shape, not a missing
index. Pinning `CYPHER 5` (the legacy planner) on those three queries
restores the seek, confirmed via PROFILE showing NodeUniqueIndexSeek.
(2) The Message(creationDate) index was never used by any IS1-IS7
query plan — pure write-time cost with no read-side payoff. Dropped.
waitUntil's check() call had no exception handling — describeInstanceState
right after run-instances reliably hit AWS's own eventual-consistency
window (InvalidInstanceID.NotFound for an instance id the API itself
just returned), and that exception propagated immediately instead of
being retried like a normal "not ready yet" poll result. Reproduced
twice in a row this session, each orphaning a running-but-unconfigured
EC2 instance that had to be found and terminated manually.

A thrown error from check() is now treated as "not ready yet" and
retried until the deadline, with the last error surfaced in the
timeout message so a genuine, persistent failure (bad credentials,
wrong region) is still diagnosable rather than silently retrying to a
generic timeout.
pdlug added 28 commits July 11, 2026 10:22
Uses the new walAutocheckpointPages pragma (previous commit) to close
the SF10 load-time gap root-caused in this session: SQLite's ~9-hour
SF10 load, ~4.85x slower than Postgres running the exact same
bulkInsert path in-process, traced to SQLite's untuned ~4MiB default
checkpoint threshold getting more expensive as the database file grows
over a large load. 100,000 pages is the empirically-validated value (a
local repro swept 1K-1M pages; throughput plateaus, then regresses
again past ~100K).

Also runs an explicit wal_checkpoint(TRUNCATE) right after the load
finishes, before any query is measured: without it, whatever's still
sitting in the WAL when the load's row count doesn't land exactly on a
checkpoint boundary would make every subsequent query pay a WAL-scan
cost the other three engines don't — a fairness gap the raised
checkpoint threshold would otherwise introduce.

Verified via `bench:snb:smoke --engines=typegraph-sqlite --check`
(real end-to-end load + IS1-7 query run). Full-dataset load-time
improvement not yet re-measured against real SF10/EC2 — pending a
decision on whether that expensive re-run is worth it now versus
batched with other benchmark changes.
Fixes a knip test:unused failure. Nothing outside this file imports
it; the EC2 bootstrap script deliberately re-derives the same path
itself (via the still-exported cacheRelativeSegments) rather than
calling this, since it needs "/root" on the remote box, not this
process's own homedir().
The fairness label on every SQL engine driver claims "indexes
materialized... after bulk load, matching the documented production
path" — false for the one SNB-specific index (the IS2 covering index
fixed earlier this branch). It was passed to createSqliteTables/
createPostgresTables's own indexes option, which bakes it straight
into the initial CREATE TABLE/migration DDL: it existed from row 1,
and every Post/Comment/Person/Forum insert paid its maintenance cost
live for the entire load, not just after.

defineGraph() already has an indexes option, read by
store.materializeIndexes() (already called by both engine drivers
after loadSnbDataset() finishes) — it was simply never wired up here.
Moved snbIndexes from createSqliteTables/createPostgresTables to
defineGraph({ indexes }), so materializeIndexes() now does real work
instead of operating on an empty declaration list.

Verified directly: the index is absent from sqlite_master through
createStoreWithSchema's bootstrap DDL and only appears after an
explicit materializeIndexes() call. Also verified end-to-end via
bench:snb:smoke --engines=typegraph-sqlite --check.

This only affects the one benchmark-specific covering index — the
base indexes createSqliteTables/createPostgresTables always create
(kind/temporal/deleted-at indexes, edge traversal indexes) are core
TypeGraph library behavior, not something this benchmark controls,
and are out of scope here.
An unprovisioned gp3 volume silently gets the account's baseline
(3,000 IOPS / 125 MB/s) regardless of size, since gp3 (unlike gp2)
decouples IOPS/throughput from volume size entirely. A root-cause
investigation confirmed this baseline genuinely bottlenecks SQLite's
bulk-load checkpoint I/O once the database reaches a few GB: checkpoint
flushes revert from large sequential writes to small ~4KB random writes
that pin against the IOPS ceiling regardless of wal_autocheckpoint
tuning — exactly the condition SF10's real load hits, which is why the
wal_autocheckpoint fix (443c8d2) showed no real-world improvement on
an actual SF10 EC2 run despite being locally validated.

Validated on real EC2/EBS infrastructure via a cheap diagnostic
instance before spending another multi-hour SF10 run: explicitly
provisioning 10,000 IOPS / 400 MB/s clears the ceiling (iostat showed
sustained 3,000-11,000+ write IOPS instead of a hard pin at exactly
3,000) and confirmed the checkpoint tuning is genuinely complementary
once unblocked (23% faster than SQLite's default checkpoint interval
on the same large, provisioned-IOPS volume). Combined with the
already-merged checkpoint and deferred-index fixes, an SF10 run's
SQLite load time drops from 8.96h to 3.10h (2.89x).
…artifacts

collect() previously embedded results.json, summary.json, the new
history.jsonl lines, and a console-log tail all in the same
long-running SSM command's own StandardOutputContent, which AWS hard-
caps at 24,000 characters. That worked until all four engines
succeeded on the same run for the first time (SF10 attempt 8):
the combined size finally exceeded the cap, silently truncating
mid-JSON and dropping the last engine's history entry — results.json
and summary.json survived only because they're written earlier in the
script's output.

The backgrounded benchmark command now reports only its exit code
inline (negligible size) and writes a small sentinel file recording
history.jsonl's line count at start, so the correct tail offset
survives past the run's end. collect() fetches results.json,
summary.json, and the new history lines via three separate, later SSM
commands instead — each gets its own full 24,000-character budget, so
no single artifact's growth can crowd out another's. The verbose
console-log tail is now fetched only on a non-zero exit code (the one
case it's actually useful for), at 200 lines instead of 60, since it's
no longer competing for space with data collect() actually parses.
typegraph-sqlite/postgres/neo4j entries from the successful attempt 8
run (combined checkpoint tuning + deferred index + IOPS provisioning
+ correct instance type). ladybugdb's entry was lost to the SSM
truncation bug fixed in 5ec4f0d — not reconstructed here to avoid
injecting imprecise, hand-rounded numbers into a precision-sensitive
trend log.
The results doc still described attempt 5's unoptimized 8.96h SQLite
load time as the standout finding, and its own root-cause section said
the wal_autocheckpoint fix was "investigated, not yet implemented" —
both wrong as of this session, and internally inconsistent with the
doc's own Next steps checklist, which already marked the fix as
implemented.

Documents the full eight-attempt arc: attempts 1-5 (memory exhaustion
disguised as networking, already documented), attempt 6 (a second bug,
the covering index wasn't genuinely deferred), attempt 7 (the
checkpoint + index fixes combined showed zero real-world improvement),
and the round-two root cause (an unprovisioned gp3 volume's default
IOPS ceiling), validated cheaply on a diagnostic instance before
attempt 8 confirmed the fix: SQLite load time 8.96h -> 3.10h (2.89x),
total wall clock 11h10m -> ~5h22m. Also updates the SF10 query-latency
table with attempt 8's real numbers and adds an explanation of why IS2
is ~100-1000x slower than every other query on every engine (an
intentional, un-batched, ~21-round-trip design, not an engine
difference).
…arding the result

store.materializeIndexes() is deliberately best-effort — it reports
failures as a per-index status rather than throwing — so both the
SQLite and Postgres drivers awaiting the call and discarding its
result would silently proceed even if the SNB covering index this
benchmark's fairness label depends on was never created.

assertMessageIndexMaterialized() (schema/snb-graph.ts) checks the
result for the specific index and throws if it wasn't created or
already materialized; both drivers now call it immediately after
materializeIndexes().
A review checked this benchmark's queries against the official LDBC
reference implementation (ldbc/ldbc_snb_interactive_v1_impls) and
found every engine driver diverged from spec in ways row-count-only
parity could never catch:

- IS2 measured the wrong workload in all three engines: traversing to
  the given person's *friends* and measuring messages *they* authored,
  instead of official IS2's own definition ("recent messages of a
  person" — the given person's own messages). Every engine shared the
  identical mistake.
- IS2/IS3/IS6/IS7 silently omitted official output fields on one or
  more engines: message content (TypeGraph's IS2, TypeGraph's and
  LadybugDB's IS7), author/moderator names (Neo4j's and LadybugDB's
  IS2, LadybugDB's IS7), forum id/title (Neo4j's and LadybugDB's IS6).
  IS7's replyAuthorKnowsOriginalMessageAuthor boolean was computed as
  a bulk existence check and never actually attached per-row.
  TypeGraph's IS3 had no defined ordering at all.
- Id tie-breaks (IS2/IS3/IS7) were lexicographic on this benchmark's
  kind-prefixed ids ("message:10" sorting before "message:2") instead
  of the numeric ordering official ids (plain BIGINTs) require.

Every query across all three engines now returns exactly the official
LDBC output fields. A shared compareIdsAscending() helper
(engines/types.ts) makes every id tie-break numeric-aware via
Intl-collation's `numeric: true` option, applied as a final JS-side
resort immediately before building each query's result — regardless
of what a query's own native ORDER BY/SQL did on a tie — so the row
order captured for parity comparison can't drift between engines.
IS2's per-kind candidate fetch is over-fetched (limit 20, not 10) as a
defensive buffer against that same tie-break imprecision excluding a
genuine top-10 candidate before the final correct sort trims to 10.

canonicalDigest() (engines/types.ts) is the other half of this fix —
see the following commit for the parity-gate upgrade that consumes it.
Neo4j's fairness label is also corrected to describe the current
schema (no Post/Comment constraints, matching the earlier commit that
removed them) instead of stale metadata.
…gest comparison

Row-count agreement is exactly how the previous commit's IS2 workload
bug and field-omission bugs went undetected for as long as they did —
every engine shared the identical wrong IS2 semantics and still agreed
on row count, and a missing field doesn't change how many rows a query
returns.

evaluateParity() (harness/parity.ts) now compares each sampled
request's canonicalDigest() output across engines, in addition to the
existing row-count check — EngineRowCounts renamed to
EngineQueryOutcomes to carry both. measureQuery() (snb-short-reads.ts)
collects digests alongside row counts from each SnbQueryResult.

Verified on the smoke fixture: this immediately caught one more real
bug before landing — TypeGraph's IS3 digest used field name
`personId`, Neo4j's and LadybugDB's used `id` (identical underlying
values, incomparable digests) — fixed as part of the previous commit.
All 7 queries now pass true value-level parity across all four
engines, not just row-count parity.
…ing instance type

Two independent EC2-runner correctness fixes:

- collect()'s failure predicate only checked SSM status and exit code,
  so a successful SSM command whose results.json somehow came back
  empty (resultsText === "{}") still reported success. Added
  !hasParseableResults to the failure check. Also fixed: canonical
  reports/history.jsonl was appended before validating the run
  succeeded, so a failed run's partial per-engine rows could get mixed
  into the trend log looking like an ordinary result — raw history
  lines are now always preserved locally (run-scoped, not shared) for
  post-mortem, and the canonical file is only appended after every
  success condition passes.
- --profile=sf10 always defaulted to c7i.4xlarge regardless of
  profile — the same 32GB instance type that OOM'd on four separate
  SF10 attempts before r7i.4xlarge (128GB) was found to be required.
  DEFAULT_INSTANCE_TYPE_BY_PROFILE now defaults sf10 to r7i.4xlarge;
  --instance-type still overrides it explicitly.
Two rounds of review found real correctness bugs in every IS1-IS7
query implementation (see the preceding commits) — none of the
published SF1/SF10 query-latency numbers in this doc reflect the
correct queries anymore, and several also predate the value-level
parity gate that would have caught them sooner.

- Top-of-file notice broadened from "IS2 is invalidated" to "every
  query-latency number is invalidated" — IS3/IS6/IS7 also had
  field-omission bugs on one or more engines, not just IS2.
- Neo4j's load time is now also flagged invalidated: the Post/Comment
  constraint removal changes work done inside its timed load(), not
  just its query-time fairness — the doc previously claimed load times
  were unaffected, which was true for SQLite/Postgres/LadybugDB but
  not Neo4j.
- The SF1 and SF10 query-latency tables (and the paragraphs of
  analysis that used to follow them) are deleted rather than kept as
  "mostly still right," since Neo4j's IS6 and TypeGraph's/LadybugDB's
  IS7 now do measurably more real work than before.
- Smoke-scale section notes the fixture was re-verified end-to-end:
  all 7 queries now pass value-level digest parity, not just
  row-count parity.
- Next steps checklist records every round-2 finding and fix.
- ec2-benchmark-runner.md's --instance-type default doc updated for
  the profile-aware default; a nonexistent bench:snb:sf10:ec2 script
  reference and a stale "no direct measurement yet" line corrected
  (the latter already partly fixed in code comments; this closes the
  matching doc gap).
…ss likely to fail

A third review found the previous round's fix — over-fetch 20
candidates per kind via a native LIMIT, then numerically re-sort in
JS — was still not provably correct. The native LIMIT applies before
this file's numeric-aware final sort, ordered by the native query
layer's own plain lexicographic id tie-break. A same-creationDate tie
cluster larger than the buffer (e.g. 30 messages sharing one
timestamp, all with a fixed 20-row buffer) can rank a genuinely-top-10
message (by numeric id) past whatever cutoff the lexicographic order
chose ahead of it — verified concretely: ids message:1..30 all tied on
creationDate, LIMIT 20 (lex order) fetches 1,10-19,2,20-27, and even
the correct numeric resort of *that* candidate set produces
1,2,10-17 instead of the true 1-10. No fixed buffer size is provably
safe against this, only fetching every candidate and limiting after
the correct sort is.

Removed the native LIMIT (and the now-unused IS2_CANDIDATE_LIMIT
constant) from all three engines' per-kind IS2 fetch; the existing
numeric-aware final sort + slice(0, 10) now operates over the
complete candidate set. A person's own authored-message count is
bounded by realistic LDBC activity levels, so this stays a cheap
point-adjacent fetch rather than a table scan.
…ul EC2 collect()

collect()'s success predicate only checked results.json; a run whose
summary.json came back as the "not found" sentinel ({}) still had it
written (as an empty, invalid summary) without being flagged, and
missing history.jsonl lines were silently accepted either way. A
"successful" run could therefore lose the reproducibility metadata
(engine versions, hardware, git sha) the results doc cites, or its
canonical trend-log entry, while still reporting success and
terminating the instance.

hasParseableSummary/hasHistoryLines now participate in the failure
predicate alongside hasParseableResults — a genuinely complete run
writes all three unconditionally (summary.json always, at least one
history line per engine that completed), so missing any one is a
real failure, not an acceptable partial result.
…ormance claim

A third review found several places still described the parity gate
as row-count-only after it was upgraded to value-level digest
comparison, plus two unrelated stale claims:

- Runtime console output ("=== Row-count parity ==="), CLI option
  doc, and code comments (cli.ts, request-plan.ts, snb-short-reads.ts,
  run-sf1-ec2.ts) updated to describe both signals.
- README's parity section, "what every run writes" description, and
  fairness-harness summary updated the same way.
- README's SF1 load-time numbers (75-80 min TypeGraph loads) predated
  the N+1 endpoint-lookup fix (PR #227) that cut them to ~40/~12
  minutes — updated to the current numbers, with a note that the
  results doc is the canonical source if they drift again.
- Results doc and README both described Neo4j's current loader as the
  retired batched `UNWIND ... IN TRANSACTIONS` approach; it's been the
  offline `neo4j-admin database import` for a while — corrected in
  both places.
- README now states plainly that this lane adapts LDBC SNB rather than
  claiming full schema conformance: the schema flattens two
  official-schema edges (City via IS_LOCATED_IN, Forum's moderator via
  HAS_MODERATOR) into plain properties, identically across all four
  engines. Query output fields and ordering are verified against the
  official reference implementation; the schema-level simplification
  is a separate, deliberate, and already-documented choice
  (schema/snb-graph.ts's module doc) that the README should name
  rather than let "official" imply exact conformance.
# Conflicts:
#	packages/typegraph/src/backend/sqlite/local.ts
#	packages/typegraph/tests/backends/sqlite/local-pragma-defaults.test.ts
Caught by CI's Lint & Type Check job on the merge commit, not by local
verification beforehand.
… padded ids

Removing IS2's native LIMIT entirely (the previous fix for the
lexicographic-tie-break bug) was correctness-proof but wrong in a new way:
every engine now fetched every message a person ever authored, transferring
full content over the network for engines that pay a real round trip
(Postgres, Neo4j), and diverging from the official query's own
`ORDER BY ... LIMIT 10` applied before the root-post-author walk.

Root-caused properly instead: dataset/ldbc-csv.ts zero-pads every id's
numeric portion to a fixed width, so a plain lexicographic `ORDER BY id ASC`
(SQL or Cypher alike) already agrees with numeric order, for any
tie-cluster size. This lets native ORDER BY/LIMIT 10 be restored everywhere
— TypeGraph/LadybugDB fetch each of Post/Comment's own top 10 and merge
(provably equal to the true top 10 of the union), Neo4j's unified :Message
label needs only one query.
…ss-engine consensus

Cross-engine agreement is consensus, not proof — this lane has already
shipped two bugs (the friend-workload bug, the lexicographic-tie-break bug)
that every engine reproduced identically, which parity alone could never
catch.

The smoke fixture now includes a dedicated person who authors 25
same-creationDate comments (dataset/smoke-fixture-constants.ts, additive —
doesn't perturb any existing row's random generation). With an identical
creationDate, ascending message id is the only remaining tie-break, so that
person's correct IS2 answer (the cluster's 10 smallest message ids) is
known in advance. The new bench:snb:verify-is2-tie-break script checks each
doctor-runnable engine's actual result against that known answer directly,
not against the other engines. All 4 engines pass.
… collect()

A doctor-runnable-engine filter is correct for a local/CI run (a no-Docker
environment should still exit 0 on the two embedded engines) but wrong for
a paid, multi-hour EC2 run: a container failing to start on the instance
would get silently filtered out rather than recorded as a failure, and
nothing checked how many engines actually produced results — an incomplete
1-3 engine run could satisfy every existing check and terminate the
instance as a reported success.

collect() now parses results.json's engines list and requires all four
canonical names (harness/doctor.ts's SNB_ENGINE_NAMES) to be present, and
additionally fetches + preserves competitor-doctor.json locally (success or
failure) so an incomplete run is diagnosable without re-connecting to the
instance.
…ngine-completeness gate

README's Lane 1 section, the EC2 runner doc, and the results doc all
described the round-3 "fetch everything, sort in JS" IS2 fix as final —
update all three to describe the actual fix (fixed-width padded ids +
restored native ORDER BY/LIMIT), the new adversarial oracle test, and
collect()'s new complete-engine-set requirement. Smoke fixture row counts
(31 persons, 105 comments) updated to match the new tie-cluster fixture
data.
…ring bug

The tie-cluster fixture used one contiguous id range (120..144) — all
3-digit numbers, so unpadded lexicographic order and numeric order coincide
by construction (same-length numeral strings always compare identically
both ways). The oracle would pass whether or not dataset/ldbc-csv.ts's
zero-padding fix was actually applied, catching nothing.

Replaced with two disjoint blocks of different digit widths: 120..129
(3-digit) and 1000..1014 (4-digit). Unpadded lexicographic order ranks
every 4-digit id ahead of every 3-digit one ("1000" < "120"
character-by-character), so an unpadded engine now incorrectly returns
1000..1009 instead of the true answer, 120..129 — verified directly by
temporarily reverting the padding fix and confirming all 4 engines fail
with exactly that wrong answer, then restoring it and confirming they pass
again.
Round 5's finding — the oracle's original contiguous-id-range fixture
didn't actually reproduce the ordering bug it claimed to guard against —
and its verified fix (cross-digit-width blocks, confirmed by temporarily
reverting the padding fix and observing all 4 engines fail as predicted).
Real EC2 runs on commit 2bc7f74 (all five review rounds' fixes applied):
0 engine failures, 7/7 queries at full value-level digest parity, both
scales. Replaces every invalidated number this doc previously flagged
with real query-latency data, and reconfirms SF10's load-time fixes
independently (a second data point closely matching attempt 8's, plus a
now-possible fair SF1-to-SF10 growth-rate comparison under identical
fixes). history.jsonl gets the 8 new entries from both runs.
…ovenance overclaim, Neo4j fairness gap

- README's SF1 load-time example numbers (SQLite ~40min, Neo4j ~5min)
  were already stale against the fresh run (9.8min, 1.2min) the moment
  they were written down twice. Removed the duplicated volatile numbers
  entirely; the doc now only points at snb-lane1-results.md, the single
  source that actually gets updated each run.
- snb-lane1-results.md (and the PR body) claimed both SF10 runs were "on
  the fully-fixed commit" — only the fresh run (2bc7f74) was; attempt 8
  was commit a58ae38, load-time fixes only, predating the five rounds of
  query-correctness review. Also replaced "normal run-to-run variance"
  with the actual 2.1-10.7% per-engine spread and reframed as "a
  consistent second data point," not proof of reproducibility from two
  samples.
- neo4j.ts's machine-readable fairness string said the offline
  neo4j-admin import path is used "for both smoke and SF1" — it's used
  for every profile including SF10, confirmed by this session's own SF10
  run's console log.
"Ordinary EC2/EBS run-to-run noise" asserted a causal characterization
two cross-commit observations can't support — the two SF10 runs are on
different commits, so a small, real, commit-attributable effect can't be
ruled out from two samples. "2.1-10.7% spread in both directions" also
conflated a signed range with magnitude (the actual range is -2.1% to
+10.7%, not two-sided 2.1-10.7%). Replaced both instances (top status
paragraph, SF10 section intro, the load-time callout, and the Next steps
checklist entry) with the signed range and "a consistent second data
point, not proof of run-to-run variance or causality."
Missed sibling of the SF10 fix: "consistent with ordinary run-to-run
variance, not a regression or improvement" for Postgres/LadybugDB's SF1
load times made the same unsupported causal claim from a single
cross-commit comparison — and the actual magnitudes (-3.5% Postgres,
-22.7% LadybugDB) aren't obviously "ordinary" anyway. States the exact
percentages and notes cross-commit observations can't establish
causality, matching the SF10 section's fix. PR body's parallel sentence
updated the same way.
@pdlug pdlug merged commit dc61e22 into main Jul 14, 2026
16 checks passed
@pdlug pdlug deleted the feat/bench-snb-lane1 branch July 14, 2026 04:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant