Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
b9f85eb
feat: add LDBC SNB Interactive short-read benchmark (Lane 1)
pdlug Jul 5, 2026
837b763
fix: disk-backed containers and per-engine failure isolation for SF1
pdlug Jul 5, 2026
78951e7
fix(bench): index Post/Comment by id in Neo4j SNB loader
pdlug Jul 5, 2026
09d849c
feat(bench): add EC2 runner for the SNB Lane 1 benchmark
pdlug Jul 5, 2026
a16902a
fix(bench): build the EC2 SF1 cache path from /root, not the local ho…
pdlug Jul 5, 2026
db49faf
chore(bench): record EC2 smoke dry-run history entries
pdlug Jul 5, 2026
4d86201
fix(bench): strip the archive's own top-level dir when extracting SF1
pdlug Jul 5, 2026
28d8a01
fix(bench): set SSM's own executionTimeout; always terminate on collect
pdlug Jul 5, 2026
ee22370
fix(bench): derive the dead-man's switch from the actual timeouts used
pdlug Jul 6, 2026
76be629
fix(bench): bump the default EC2 benchmark timeout from 6h to 10h
pdlug Jul 6, 2026
6bba06a
fix(bench): switch LadybugDB loader from MATCH+CREATE to COPY FROM
pdlug Jul 6, 2026
576dd02
chore(bench): fix knip failures (unused file + unused exports)
pdlug Jul 6, 2026
f687d1a
fix(bench): stage ladybug CSVs with a pipe delimiter, not comma
pdlug Jul 6, 2026
e8a0e01
chore(bench): record partial real SF1 EC2 results (sqlite/postgres/ne…
pdlug Jul 6, 2026
34529c2
docs(bench): replace smoke-only SF1 caution with real SF1 results
pdlug Jul 7, 2026
725cc38
perf(bench): disable redundant auto-refresh-statistics during SF1 load
pdlug Jul 7, 2026
cba423d
perf(bench): raise loader BATCH_SIZE from 2,000 to 20,000
pdlug Jul 7, 2026
3cc851c
perf: parallelize IS2's per-message root walk
pdlug Jul 7, 2026
bb8cf41
perf: parallelize IS7's independent parent-author and replies fetches
pdlug Jul 7, 2026
bded9ce
Revert "perf: parallelize IS7's independent parent-author and replies…
pdlug Jul 7, 2026
d2fed8b
Revert "perf: parallelize IS2's per-message root walk"
pdlug Jul 7, 2026
517726f
docs: refresh SF1 report with post-load-fix EC2 numbers
pdlug Jul 7, 2026
ca866d0
docs: publish keySystemColumns guidance and the Postgres index-only-s…
pdlug Jul 7, 2026
aca4713
feat(bench): generalize the SNB harness to support an sf10 profile
pdlug Jul 7, 2026
b735048
fix(bench): Neo4j fairness — offline import, host-aware memory, plann…
pdlug Jul 7, 2026
9081499
fix(bench): tolerate AWS eventual-consistency lag in the EC2 launch poll
pdlug Jul 7, 2026
627eebb
chore(bench): record SF1 EC2 run #2 history entries (post Neo4j fairn…
pdlug Jul 7, 2026
c083fa0
fix(bench): stream-join LDBC relationship files instead of Map-based …
pdlug Jul 8, 2026
73090d1
chore(bench): record SF1 EC2 run #3 history entries
pdlug Jul 8, 2026
dc679cc
chore(bench): record real SF10 EC2 run history entries
pdlug Jul 8, 2026
2cef862
fix(bench): close the IS2 SF10 latency cliff (root cause + harness fa…
pdlug Jul 8, 2026
a0edf20
fix(bench): stop apt-daily/unattended-upgrades before bootstrapping EC2
pdlug Jul 9, 2026
a11b2ca
chore(bench): record EC2 smoke dry-run verifying the dpkg-lock fix
pdlug Jul 9, 2026
06edaa8
fix(bench): raise conntrack table ceiling on EC2 to survive SF10 load
pdlug Jul 10, 2026
356738d
chore(bench): record EC2 smoke dry-run verifying the conntrack fix
pdlug Jul 10, 2026
3235afd
feat(bench): add opt-in SSH diagnostic fallback for the EC2 runner
pdlug Jul 10, 2026
daf72fd
fix(bench): support a real public IP for the SSH diagnostic fallback
pdlug Jul 10, 2026
29a3034
chore(bench): record EC2 smoke dry-run verifying SSH actually connects
pdlug Jul 10, 2026
99d406b
chore(bench): record real SF10 EC2 run #5 history entries
pdlug Jul 11, 2026
f385eca
docs(bench): publish real SF10 EC2 results
pdlug Jul 11, 2026
bf99381
docs(bench): document SQLite SF10 load-time root cause
pdlug Jul 11, 2026
0bf7d7b
fix: expose PRAGMA wal_autocheckpoint tuning for bulk-insert-heavy SQ…
pdlug Jul 11, 2026
443c8d2
fix(bench): raise SQLite's wal_autocheckpoint during bulk load
pdlug Jul 11, 2026
3fa6a44
docs(bench): mark SQLite wal_autocheckpoint fix implemented, add re-r…
pdlug Jul 11, 2026
8fb0d22
fix(bench): stop exporting snbCacheDir — only used within resolve.ts
pdlug Jul 11, 2026
a58ae38
fix(bench): genuinely defer the SNB covering index to after bulk load
pdlug Jul 11, 2026
3f77184
fix(bench): provision gp3 IOPS/throughput on EC2 benchmark volumes
pdlug Jul 13, 2026
5ec4f0d
fix(bench): avoid SSM 24,000-char output truncation losing collect() …
pdlug Jul 13, 2026
7435553
chore(bench): record real SF10 EC2 run #8 history entries
pdlug Jul 13, 2026
f5c097a
docs(bench): update SF10 results with attempt 8's fix and final numbers
pdlug Jul 13, 2026
95e97cd
fix(bench): assert SNB covering index materialization instead of disc…
pdlug Jul 13, 2026
b664cba
fix(bench): correct IS1-IS7 to match official LDBC query semantics
pdlug Jul 13, 2026
1290a31
fix(bench): upgrade parity gate from row-count-only to value-level di…
pdlug Jul 13, 2026
f083dec
fix(bench): validate EC2 collect() success and default sf10 to a work…
pdlug Jul 13, 2026
9582ed8
docs(bench): invalidate every query-latency number pending a fresh run
pdlug Jul 13, 2026
c647988
fix(bench): make IS2's candidate fetch correctness-proof, not just le…
pdlug Jul 13, 2026
f0ca9a4
fix(bench): require complete reproducibility artifacts for a successf…
pdlug Jul 13, 2026
16da3bf
docs(bench): correct stale parity/loader wording and soften LDBC conf…
pdlug Jul 13, 2026
4960695
Merge remote-tracking branch 'origin/main' into feat/bench-snb-lane1
pdlug Jul 13, 2026
411710c
style(bench): fix prettier formatting in run-sf1-ec2.ts
pdlug Jul 13, 2026
75aad13
fix(bench): restore engine-side IS2 ordering/limiting via fixed-width…
pdlug Jul 13, 2026
1a8dc49
test(bench): add adversarial IS2 tie-break oracle, independent of cro…
pdlug Jul 13, 2026
05dbd6c
fix(bench): require the complete four-engine set for a successful EC2…
pdlug Jul 13, 2026
db8122f
docs(bench): document the IS2 re-fix, the tie-break oracle, and EC2 e…
pdlug Jul 13, 2026
f80dff1
fix(bench): make the IS2 tie-break oracle actually reproduce the orde…
pdlug Jul 13, 2026
2bc7f74
docs(bench): record the tie-break oracle's own fix in the results doc
pdlug Jul 13, 2026
5a83c60
docs(bench): record fresh SF1 + SF10 EC2 results with every fix in place
pdlug Jul 14, 2026
b2ed037
docs(bench): fix 3 publication issues — stale README timings, SF10 pr…
pdlug Jul 14, 2026
6d655a0
docs(bench): fix imprecise SF10 variance language, in doc and PR body
pdlug Jul 14, 2026
0ff66ba
docs(bench): fix the same imprecise variance claim in the SF1 section
pdlug Jul 14, 2026
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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ yarn-error.log

# Stryker mutation testing
.stryker-cache/
reports/
packages/typegraph/reports/
.gstack/

# Local design scratch files
Expand Down
72 changes: 60 additions & 12 deletions apps/docs/src/content/docs/performance/indexes.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,20 +76,16 @@ export const typegraphTables = createSqliteTables({}, {

**Key options:**

- `fields`: JSON property paths used for filtering/ordering (B-tree expression keys). Optional — an
index can instead be keyed entirely by `coveringFields`/`keySystemColumns` — but the declaration
must supply at least one of `fields`, `coveringFields`, or `keySystemColumns`.
- `fields`: JSON property paths used for filtering/ordering (B-tree expression keys). Optional if
`coveringFields` or `keySystemColumns` supply the key instead — an index must declare at least
one of the three.
- `coveringFields`: additional properties frequently selected with the same filters. These become
additional index keys to enable index-only reads when combined with smart select.
- `keySystemColumns`: system columns (e.g. `"id"`) to include in the index key, alongside
`fields`/`coveringFields`. A covering index can only serve a query's join index-only if the
index's key matches the join's actual predicate — a query that joins on a system column directly
(e.g. a compiled `n.id = e.from_id` for a reverse traversal) needs a matching key column that
`fields`/`coveringFields` can't provide, since those only ever accept schema properties. Rejects
edge-only system columns on a node index and any column `scope` already prefixes the key with;
not supported together with `method: "gin"`/`"trigram"`. Rejects `"id"` combined with `unique:
true` — every node's `id` is already unique per row, so a unique index keyed on `id` plus other
columns can never enforce a meaningful constraint across those other columns.
- `keySystemColumns`: system columns (e.g. `"id"`) to include in the key, after the `scope` prefix
and before `fields`. See [Keying on system columns](#keying-on-system-columns-keysystemcolumns).
Rejects `"id"` combined with `unique: true` — every node's `id` is already unique per row, so a
unique index keyed on `id` plus other columns can never enforce a meaningful constraint across
those other columns.
- `unique`: create a unique index.
- `scope`: prefixes index keys with TypeGraph system columns (default is `"graphAndKind"`).
- `where`: partial index predicate (portable DSL, compiled per dialect).
Expand Down Expand Up @@ -135,6 +131,31 @@ Index `scope` controls which TypeGraph system columns are prefixed ahead of your
- `"graph"`: prefixes with `graph_id` only (rare; useful for cross-kind queries within a graph).
- `"none"`: no system prefix (rare; usually only correct for global queries).

### Keying on system columns (`keySystemColumns`)

`fields` and `coveringFields` only ever reference your schema's own properties, and `scope` only
ever prefixes `graph_id`/`kind` — neither can put a system column like `id` into the index **key**.

Some query shapes need that. A reverse traversal — `.traverse("hasCreator", { direction: "in"
}).to("Post", "post")` — compiles a join on the target node's own `id` (`n.id = e.from_id`). If you
also filter, sort, or select a prop on that same node (say `creationDate`), a covering index needs
`id` in its key to match that join — `graph_id`/`kind` and `creationDate` alone aren't enough,
because the index can't be chosen for an `id`-equality join it doesn't cover.

```ts
const postRecent = defineNodeIndex(Post, {
keySystemColumns: ["id"],
coveringFields: ["creationDate"],
});
// -> CREATE INDEX ... ON typegraph_nodes (graph_id, kind, id, (props #>> ARRAY['creationDate']))
```

**Validation:**

- Node indexes only. Rejects edge-only system columns (`fromKind` / `fromId` / `toKind` / `toId`).
- Rejects a column already implied by `scope` (e.g. `graph_id` when `scope: "graphAndKind"`).
- Not supported with `method: "gin" | "trigram"` (same restriction as `coveringFields`).

## Edge Indexes

`defineEdgeIndex(edgeType, config)` works the same way as node indexes, with one extra option:
Expand Down Expand Up @@ -197,6 +218,33 @@ CREATE INDEX idx_person_email_name ON typegraph_nodes
WHERE deleted_at IS NULL;
```

:::caution[PostgreSQL: JSONB expression indexes don't get a true Index Only Scan]
A covering index like the one above lets PostgreSQL avoid a full **table scan**, but not
necessarily a **heap fetch** per matching row. PostgreSQL's `Index Only Scan` optimization — skip
the heap entirely when the index already has everything the query needs — does not extend to
JSONB extraction expressions (`props #>> ARRAY[...]`), only to real stored columns. Even a
correctly-shaped `coveringFields` index shows as a plain `Index Scan` in `EXPLAIN`, not
`Index Only Scan`, and PostgreSQL still visits the heap row for every match.

This matters most for high-fan-in ranked reads — e.g. "each of N friends' most recent post, top 10
overall" — where a query visits many candidate rows and discards most of them after sorting. If
`EXPLAIN (ANALYZE, BUFFERS)` shows most of a query's cost coming from a repeated `Index Scan` with
high `Buffers: shared hit` relative to the rows actually returned, this is likely why — confirmed
against a real workload at real scale, not a theoretical concern.

TypeGraph doesn't currently offer a built-in way to materialize a hot prop as a real column (this
is an active area of investigation). Until then, if this is a genuine hot-path bottleneck on
PostgreSQL, the workaround is maintaining your own
[stored generated column](https://www.postgresql.org/docs/current/ddl-generated-columns.html)
alongside `props` with a plain index over it — a real column does get `Index Only Scan`, confirmed
via `EXPLAIN (ANALYZE, BUFFERS)` showing `Heap Fetches: 0` (run `VACUUM ANALYZE`, not just
`ANALYZE`, after backfilling — the visibility map needs to be current before PostgreSQL will prove
it immediately).

We haven't specifically verified whether SQLite's JSON-extraction covering indexes have the same
limitation for this query shape.
:::

## Batched Index Lookup (`bulkFindByIndex`)

`store.nodes.<Kind>.bulkFindByIndex(indexName, items, options?)` takes many in-memory records and
Expand Down
31 changes: 29 additions & 2 deletions apps/docs/src/content/docs/performance/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,19 @@ await store.nodes.Person.bulkInsert(people);
await store.nodes.Person.bulkUpsertById(itemsWithIds);
```

### Batch sizing for large multi-call imports

For a dataset too large for a single `bulkInsert`/`bulkCreate` call (e.g., streaming rows from a
file in a loop), the *size* of each call matters, not just the total row count. Each call is its
own transaction, and — per the default
[`autoRefreshStatistics`](/backend-setup#refreshing-planner-statistics-after-bulk-loads) — can
trigger a planner-statistics refresh on its own. In a large-scale bulk-load benchmark, batches of
~2,000 rows per call were consistently ~25-30% slower per row than batches of ~20,000+: fewer,
larger calls amortize both the per-call transaction commit and the statistics refresh across more
rows. Prefer batch sizes in the tens of thousands when looping over many calls for a large import,
and consider `autoRefreshStatistics: false` plus one `store.refreshStatistics()` call after the
loop if per-call refreshes still dominate.

### Batch reads

`getByIds()` on node and edge collections uses a single `SELECT ... WHERE id IN (...)` instead of N
Expand Down Expand Up @@ -271,8 +284,11 @@ const results = await store
```

This optimization pairs well with [covering indexes](/performance/indexes#covering-indexes): if
your index contains both the filter keys and the selected keys, the database can satisfy the query
with an index-only scan.
your index contains both the filter keys and the selected keys, the database can serve the query
straight from the index instead of scanning the whole table — though on PostgreSQL specifically,
this stops short of a true `Index Only Scan` for JSONB-extracted fields; see the
[covering indexes](/performance/indexes#covering-indexes) section for the concrete limitation and
a workaround.

**When optimization applies:**

Expand Down Expand Up @@ -490,6 +506,17 @@ Backend-specific overrides:

</details>

### Real-world workload validation

Beyond the synthetic guardrail suite above, TypeGraph is also exercised against the
[LDBC Social Network Benchmark (SNB) Interactive](https://github.com/ldbc/ldbc_snb_interactive_v1)
workload — a standard, independently-defined graph benchmark, not a TypeGraph-specific one — at
SF1 scale (~10k persons, ~1M posts, ~2M comments). This surfaced and fixed two real scaling bugs in
the library: an unbounded `ANALYZE` cost on bulk SQLite loads, and an N+1 endpoint-existence check
in batched edge creation. It also directly produced the `keySystemColumns` guidance and the
PostgreSQL index-only-scan caveat in [Indexes](/performance/indexes#covering-indexes). The
benchmark source lives in `packages/benchmarks/src/real/` in the repository.

### Running benchmarks locally

```bash
Expand Down
3 changes: 3 additions & 0 deletions packages/benchmarks/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Dated real-workload benchmark run bundles (docs/design/benchmark-program-plan.md).
# The one-line-per-run reports/history.jsonl append log stays in git.
bench-results/
180 changes: 180 additions & 0 deletions packages/benchmarks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# TypeGraph benchmarks

Two benchmark programs live in this package:

- **`src/*-bench.ts` + `src/main.ts`** — the synthetic perf-sanity suite
(`pnpm bench`, `pnpm perf:check`) that runs in CI as a regression guardrail
on a small seeded graph. Keep using this for fast, deterministic
before/after comparisons during development.
- **`src/real/`** — the real-workload benchmark program described below:
industry-standard datasets and queries, run against TypeGraph and named
competitors, with a fairness harness (row-count + value-level digest
parity, noisy-sample detection, a competitor doctor, and a
`summary.json` per run). This supersedes `neo4j-compare/` (a 7.2k-node
microbenchmark) for anything beyond order-of-magnitude anchors — see
that directory's README for what it still remains useful for (a
same-machine two-command sanity check with no dataset to fetch).

The full program design lives in `docs/design/benchmark-program-plan.md`
(gitignored, local to the repo checkout that approved it).

## Real-workload benchmarks (`src/real/`)

### Lane 1 — LDBC SNB Interactive short reads (IS1-IS7)

The headline lane: LDBC SNB Interactive's seven short-read queries (point
reads, neighbor lists, reply-chain walks), adapted to this schema — the
closest standard workload to TypeGraph's operational profile. Every
query's output fields and result ordering match the official LDBC
reference implementation (verified against
`ldbc/ldbc_snb_interactive_v1_impls`) — but the schema itself
deliberately flattens two relationships the official schema models as
edges into plain properties instead: `Person.cityId` (official IS1
traverses `IS_LOCATED_IN` to a `City` node; this schema has no `City`
node at all) and `Forum.moderatorId` (official IS6 traverses
`HAS_MODERATOR`; see `src/real/schema/snb-graph.ts`'s module doc for why).
Both are simplifications applied identically across all four engines, not
a TypeGraph-specific shortcut — call this lane "LDBC SNB-derived," not a
claim of full official schema conformance.

**Engines** (docs/design/benchmark-program-plan.md's fair pairings):

| Pairing | Product under test | Competitor |
| --- | --- | --- |
| Embedded | `typegraph-sqlite` (better-sqlite3, in-process) | `ladybugdb` (`@ladybugdb/core`, Kuzu-family, in-process) |
| Server | `typegraph-postgres` (node-postgres, imperative docker container) | `neo4j` (pinned image, imperative docker container) |

Every query is implemented through the TypeGraph query builder for the
TypeGraph engines (never hand-written SQL — see
`src/real/engines/typegraph-queries.ts`'s module doc) and through
idiomatic Cypher for Neo4j/LadybugDB. `src/real/schema/snb-graph.ts`
documents one schema-design note worth knowing before reading the query
code: `replyOf`'s target is polymorphic (a Comment replies to either a
root Post or another Comment), which TypeGraph resolves with a
never-instantiated ontological `Message` supertype
(`subClassOf(Post, Message)` / `subClassOf(Comment, Message)`) so the
reply-chain walk can use `includeSubClasses` — Neo4j and LadybugDB don't
need this, since multi-label nodes / multi-pair relationship tables handle
polymorphic endpoints natively.

#### Running it

```bash
# Preflight: which engines are actually runnable on this machine right now
pnpm --filter @nicia-ai/typegraph-benchmarks bench:snb:doctor

# Tiny committed fixture (31 persons, 40 posts, 105 comments — including a
# dedicated 25-comment same-creationDate tie cluster, see "Parity gate"
# below) — fast, always runnable in CI regardless of Docker/optional-package
# availability
pnpm --filter @nicia-ai/typegraph-benchmarks bench:snb:smoke

# Same, but exits non-zero on a genuine parity mismatch (row count or
# value digest) between 2+ engines that ran (the CI-safe form: 0 or 1
# runnable engines still exits 0)
pnpm --filter @nicia-ai/typegraph-benchmarks bench:snb:smoke:check

# Adversarial correctness check, independent of cross-engine consensus: one
# fixture person's IS2 answer is known in advance (see "Parity gate" below),
# checked against each doctor-runnable engine's actual result directly
pnpm --filter @nicia-ai/typegraph-benchmarks bench:snb:verify-is2-tie-break

# Real LDBC SF1 (~9.9k persons, ~361k knows (directed), ~1M posts, ~2.05M
# comments). Takes under an hour on typical hardware — see
# reports/snb-lane1-results.md for current load times and why they differ
# this much across engines; that doc is the canonical, current source,
# not duplicated here since these numbers drift with every run.
pnpm --filter @nicia-ai/typegraph-benchmarks bench:snb:sf1

# Or run it on a dedicated ephemeral EC2 instance instead of local
# hardware (docs/ec2-benchmark-runner.md) — useful when local hardware is
# contended, or for a stable, reproducible hardware profile.
pnpm --filter @nicia-ai/typegraph-benchmarks bench:snb:sf1:ec2 -- \
--region=us-west-2 --subnet-id=<id> --security-group-id=<id> \
--iam-instance-profile=<name>
```

`bench:snb:sf1` looks for an extracted datagen directory at
`~/.cache/typegraph/fixtures/ldbc-snb/sf1` and prints the exact download
commands if it's missing:

```bash
mkdir -p ~/.cache/typegraph/fixtures/ldbc-snb/sf1 && cd ~/.cache/typegraph/fixtures/ldbc-snb/sf1
curl -L -O https://datasets.ldbcouncil.org/snb-interactive-v1/social_network-sf1-CsvBasic-LongDateFormatter.tar.zst
zstd -d --stdout social_network-sf1-CsvBasic-LongDateFormatter.tar.zst | tar -xf - --strip-components=1
```

The archive extracts into its own `social_network-...-LongDateFormatter/`
subdirectory, not flat — `--strip-components=1` is required, not
cosmetic; without it, `dynamic/person_0_0.csv` won't be where
`resolveDatasetRoot` looks for it. Or point at an existing extract with
`--data-dir <path>`. Real datasets are never committed — only the tiny
smoke fixture under `fixtures/` is.

Useful flags (all scripts): `--engines=typegraph-sqlite,neo4j` (default:
all four), `--requests-per-query=N`, `--warmup-requests=N`, `--seed=N`,
`--output=<dir>` (default `bench-results/current/snb-<profile>`,
gitignored).

#### What every run writes

- `bench-results/current/snb-<profile>/results.json` — per-query,
per-engine latency stats (p50/p95/p99/mean/CV, flagged noisy above 25%
CV) plus the parity verdict (row count and value digest).
- `bench-results/current/snb-<profile>/summary.json` — exact commands,
engine versions, dataset parameters, hardware, git commit.
- `bench-results/current/snb-<profile>/competitor-doctor.json` — which
engines were runnable and why not, for the ones that weren't.
- `reports/history.jsonl` — one append per engine per run, labels
`snb:IS1`..`snb:IS7`, alongside the synthetic suite's rows in the same
file.

#### Parity gate

Every engine executes the identical seeded request sequence (same sampled
person/message ids, same order), so per-request results are directly
comparable index-for-index. A query is "comparable" only when every engine
that ran agrees on **both** the result-set size (row count) **and** a
canonical value digest built from the query's actual LDBC-defined output
fields (message content, names, ids, ordering — not just how many rows
came back) — see `src/real/harness/parity.ts`'s `evaluateParity()` and
`src/real/engines/types.ts`'s `canonicalDigest()`. Row-count agreement
alone previously let a real semantic bug (all four engines measuring the
wrong IS2 workload) go undetected for as long as it did, which is why the
gate compares values now, not just counts. `--check` turns a genuine
mismatch (2+ engines disagreeing, on either signal) into a non-zero exit;
it never fires when fewer than 2 engines ran, so a no-Docker CI
environment (only the two embedded engines runnable) still exits 0.

Cross-engine agreement is still only consensus, not proof — this lane has
shipped bugs (a shared wrong workload, a shared lexicographic-vs-numeric
tie-break) that every engine reproduced identically, which parity alone
can never catch. `bench:snb:verify-is2-tie-break` checks against a known
answer instead: the smoke fixture's dedicated tie-cluster person (25
same-creationDate comments, `dataset/smoke-fixture-constants.ts`) has an
IS2 result computable in advance, since an identical creationDate leaves
ascending message id as the only tie-break — each doctor-runnable engine's
actual result is checked against that oracle directly, independent of
what any other engine returned.

The EC2 runner (`bench:snb:sf1:ec2:collect`) additionally requires the
full four-engine set to have actually produced results — see
`src/real/ec2/run-sf1-ec2.ts`'s `collect()` doc comment for why a paid,
multi-hour run can't tolerate the same partial-engine leniency a local/CI
invocation intentionally allows.

#### Competitor doctor

`src/real/harness/doctor.ts` checks Docker, `pg`/`better-sqlite3`,
`neo4j-driver`, `@ladybugdb/core`, and image cache status, and records
every missing piece as an explicit `failed`/`skipped` row — never a silent
skip. The harness only attempts engines the doctor reports runnable.

## Adding a new lane

Lanes 2-4 (LinkBench request mix, LSQB, adversarial shapes) are planned in
`docs/design/benchmark-program-plan.md` and land as separate PRs. The
shared harness in `src/real/harness/` (stats, parity, doctor, summary,
history, imperative Postgres container launcher) is written to be
lane-agnostic — a new lane adds its own dataset loader, schema, and engine
query implementations under `src/real/`, and reuses the harness as-is.
Loading
Loading