#!/usr/bin/env bash
# The single entry point of the sift benchmark harness. `bench/bench --help` documents everything;
# bench/README.md has the short version. The scripts in bench/steps/ are internal helpers it calls.
set -euo pipefail

HERE=$(cd "$(dirname "$0")" && pwd)
STEPS=$HERE/steps
REPO=$(dirname "$HERE")
. "$HERE/presets.env"   # the sizes of a pass (smoke, quick, standard, full)

usage() {
  cat <<'EOF'
Usage: bench/bench COMMAND [options]

One suite for every engine and every dataset: sift (and its variants), ParadeDB pg_search, pg_textsearch, rum, pgx-bm25, gin,
Tantivy, Elasticsearch on two public datasets, every engine on a throwaway PostgreSQL 18 cluster of its own (a lane; Elasticsearch and Tantivy
run as a server / library next to it, driven over HTTP by xbench). The engines are built one after the other and then measured one at a time, each with every CPU of the host to itself, interleaved in rounds so that drifts of the host load hit all of them alike. The engines are the lines of bench/engines.tsv, each with an adapter bench/engines/<id>.sh.

Commands
  setup      check the prerequisites, download and convert the datasets, build or unpack the engines (what is up to date is skipped)
  run        measure: articles (full-text search on Simple English Wikipedia) and/or places (multi-field
             autocomplete on GeoNames); writes raw tables per dataset and tier into the result directory and
             prints the tables (the report) at the end
  report     turn result directories into results.tsv (chart input), builds.tsv and tables.md
  charts     draw the SVG charts of bench/out/img from the committed bench/results/*.tsv (stdlib python, deterministic)
  refresh    measure the README numbers again at this checkout and rewrite their blocks (`final --docs --stages "search store"`, resumable, under the host lock;
             `--calm` for a timer, `--pr BASE` opens the pull request): `refresh [--stages LIST] [--calm [S]] [--pr BASE] [--dry-run]`
  readme     print the Benchmarks block of README.md and rewrite the generated blocks of README.md and bench/out/BENCHMARKS.md from the
             slim tier rows of bench/results/*.tsv (`--tier T` another tier; no measurement)
  all        setup, run and report in one go (the clean-checkout reproduction). With --docs it also runs the cold-cache tables (always
             for the ab and full tiers) and rewrites bench/results, bench/out/img, the benchmark blocks of README.md and the slim section of
             bench/out/BENCHMARKS.md (report --docs, readme): the one command that regenerates every published number from the slim tier
  cold       like run, but every query starts with the index and heap evicted from the caches (Postgres engines)
  regress    the regression check: sift against a build of an older git ref (sift_ref, `--ref`, default v1.1.0) and sift
             `positions = off` against sift_ref, per class: new and reference values,
             ratio, and a REGRESSION when the new value is worse by more than 5% and 2 combined standard errors (index size,
             build time too); exit status 1 on a regression. `regress --run` builds both and measures sift and sift_ref on the
             articles and places (a reduced matrix of classes, see --engines to add sift_nopos and --classes to widen
             it) and compares; `regress [DIR|results.tsv ...]` compares results that exist (default bench/out/latest)
  check      correctness: `check oracle [ENGINE] [N]` compares match sets and count(*) with a tsvector oracle,
             `check rank [ENGINE] [N]` compares the top-10 with pgx-bm25's BM25 (any engine, external ones included); without
             ENGINE every engine of --engines (default: all) is checked, exit status 1 if one fails;
             `check --dataset places [oracle|rank] [N]` runs every engine of --engines on N (default 30) places queries
             per class against the SQL oracle of sql/places_oracle.sql (match sets; BM25 top-20 of top, boost, filter)
  suite      the standard multi-engine suite: `suite [ENGINE...]` is `run --dataset articles` over every engine (default) with
             the classes term and or phrase prefix fuzzy count mixed
  workload   `workload ENGINE [CLASS...]` is `run --dataset articles --engines ENGINE --classes CLASS...` with one 30 s round
             per class (default and or phrase mixed count filter join update)
  workers    sift CREATE INDEX time per parallel worker count: `workers [N...]`
  shred      throughput of the sift_col jsonb shredder (docs/COL-SHRED.md) in documents per second against jsonb_to_recordset, and of its SIMD
             kernels per level, on a throwaway cluster of scripts/pgtest.sh (SHRED_DOCS, SHRED_ROUNDS, SIFT_TMP, SIFT_PORT)
  remote     run the harness on a remote host: `remote sync|setup|sift|quick|run|sh|pull|clean ...` (RHOST)
  machine    print the machine and engine description (CPU, RAM, kernel, PostgreSQL, versions, load)
  du         what the work directory and the store hold, by component and in total
  analytics  the analytics benchmark of sift_col: `analytics setup|run|report|clean|clickbench|clickbench-setup`
  store      the orders benchmark, for everyone: ten questions about the orders of an e-commerce dataset on PostgreSQL, PostgreSQL with pg_sift,
             Citus (columnar, one node), Elasticsearch and ClickHouse in docker containers with equal limits (the same memory cap, the same CPU quota and the same thread budget for every engine,
             timed at 1 and 3 CPUs): `store [all|setup|run|cold|updates|report|merge|clean]`
  resume     `resume [ID]`: continue the unfinished run (the latest, or ID) from what is done: results of the same key are kept, the rest is measured; exit status 75 means a run was interrupted
  compare    `compare A.json B.json`: per cell the ratio B / A of the store results with its 95% interval and a Mann-Whitney test (what changed between two runs or releases)
  kit        the laptop kit: PostgreSQL with pg_sift in one docker container (packaging/docker), 100,000 orders of the store data, the ten questions with
             and without the aggregate pushdown, every answer compared; docker is the only requirement: `kit [--rows N] [--pg MAJOR] [--runs N] [--cpus N] [--image I] [--keep]`
  sets       the set-filter benchmark: a fact table filtered by 1k / 2k / 5k entity ids, 10k and 100k as headroom (array
             parameter, literal list, normal plan, IN (SELECT ...) of a membership table, ClickHouse); `sets [--docs] [--ref TAG] [--reps N] [--no-clickhouse] [--keep]`
  scaling    the aggregates of the store orders by number of partitions (1 to 4096) and parallel workers, pg_sift against the normal plan; `scaling [--docs] [--ref TAG] [--partitions N,N] [--workers N,N] [--rows N]`
  duckdb     pg_duckdb against pg_sift and PostgreSQL on the orders of the orders benchmark, in two containers with equal limits, warm and cold, the heap and a Parquet file
             as the pg_duckdb data: `duckdb [--docs] [--rows N] [--runs N] [--cpus N] [--questions LIST] [--keep]` (steps/duckdb.sh)
  generic    the generic suites (TPC-H-like, ClickBench-like, time series, the headline cases on orders) on pg_duckdb and ClickHouse, one container at a time, the answers checked against PostgreSQL's before any time: `generic [--rows N] [--suites tpch,hits,tseries] [--engines pg_duckdb,clickhouse] [--runs N] [--check-only] [--docs]` (steps/generic.py)
  cases      the benchmark cases (bench/cases.tsv, bench/cases/*.sql): each timed on pg_sift, on PostgreSQL with sift.enable_agg = off and on pg_duckdb, the plan
             asserted from EXPLAIN and every answer compared: `cases [--cases ID,ID] [--engines pg_duckdb] [--rows N] [--scales 1,10,100] [--rounds R] [--runs N] [--cpus N] [--any-plan] [--docs] [--no-wait]`,
             `cases --list` (steps/cases.sh); `cases --check [--strict] [--host-plan]` checks that every claim of the release has a case, current rows, peer rows and a block
  records     the measurement records (steps/records/README.md): `records check FILE...` refuses a set that mixes runs, table states or settings, `records report [--tsv] FILE...` writes its tables,
             `records claims FILE...` checks the CHANGELOG figures against them, `records migrate FILE...` writes a file of an older schema again in the current one
  extras     the measurements behind the 3.1 rows the store and search passes do not make: `extras ITEM... [--docs]`, ITEM: rollup tablewrites writepath bm25size
             leaftypes fields buildcost gpu all (steps/extras.sh)
  release    the one authoritative run after a release, every table of the docs, one command: `release [--dry-run] [--rehearsal] [--stages LIST] [--budget MIN]` (steps/release.sh;
             bench/out/BENCHMARKS.md, "Time budget of the release pass")
  final      the release pass in stages with a total budget (`final [--size P] [--docs] [--stages LIST] [--budget MIN] [--dry-run]`)
  release-quick  the quick release profile, one command, a budget of 60 min on one slot: the cases marked `headline` at 300,000 rows and at 100,000 (a slope), the cost of the default `track_changes` at 100,000,
             and the store and updates stages from the cache of competitor cells; prints planned against measured minutes and fails over budget (`release-quick [--dry-run] [--budget MIN] [--stages LIST]`; 1M and 10M stay named runs)
  clean      destroy the clusters and remove scratch; `--cache` also the dumps and the cache of lanes, `--build` the engine builds,
             `--all` everything including the datasets and downloads

analytics (steps/analytics.sh, steps/analytics.py): a deterministic orders corpus of the usual e-commerce ORM shape (1.2M orders, jsonb
attributes, metrics, items and notes) on a throwaway cluster of its own (SIFT_TMP, SIFT_PORT), the seven set reports (store sets K10, K400, K4000)
and the eight whole-table scans (`whole-*`) as plain SQL, measured as the CPU time and instructions of one backend with the baseline plan against the sift_col
plan (an index of the access method sift_col, --col-index; without one the baseline is measured alone), optionally against flat tables
(--flat) and a throwaway ClickHouse (--clickhouse). `analytics run` writes analytics.json (bench/results layout) and prints the table;
`analytics clickbench` runs the 43 queries of ClickBench on --rows rows of hits (10,000,000).

store (steps/store.sh, steps/store.py, sql/store/): a seeded `orders` table (1M and 10M rows, --sizes), the same CSV loaded into a container each of
PostgreSQL (heap, btree, GIN), PostgreSQL with pg_sift (`sift_col` + `sift`, and `sift_table`), Citus (the `columnar` table access method, one node), Elasticsearch and ClickHouse (images pinned in versions.env,
--cpus and --memory per container, the thread budget of every engine set to the CPU quota), ten queries (lookup by id, orders per country, revenue per month, top categories, an average, distinct customers
per month, a filtered count, and three after a full-text search), the result of each checked against PostgreSQL's before it is timed, median of --runs
(7) warm runs and --cold-runs (3) runs after a restart, load time and size per system. `store all` runs both sizes and writes store.json; with --docs also
bench/results/store.json and the generated blocks of README.md and bench/out/BENCHMARKS.md; `store report` redraws the blocks from bench/results/store.json.
Needs docker, psql, python3; nothing else is installed and every container and volume is removed at the end.

check, workers and the engines' databases of a run start the tier's cluster, load the corpus if needed and leave it running
(`check`, `workers`); `clean` stops it.

Options (not every option applies to every command)
  --tier T          slim | ab | full (default slim; quick is the old name of slim)
                      slim   50,000 articles, GeoNames cities15000 (34,165 places), 2-3 rounds of cells of at most 1 s that stop at a
                             relative standard error of 5%: the default, the source of the numbers of README.md; minutes of
                             measurements after the one-time setup (bench/README.md has the measured time)
                      ab     150,000 articles, cities15000, 2-3 rounds: the intermediate tier of bench/out/BENCHMARKS.md (about 4 minutes)
                      full   218,058 articles (all), cities500 (236,262 places), 2-3 rounds
  --full            same as --tier full: the full-size run (the numbers kept in bench/out/BENCHMARKS.md as history)
  --dataset D       articles | places | all (default all)
  --engines LIST    comma separated engine ids of bench/engines.tsv (default: the engines with default = yes: sift, sift_nopos
                    (positions = off), elasticsearch, pg_search, pg_textsearch, rum, bm25). setup: which engines to
                    build. An engine whose extension is missing, or that has no workload for the dataset, is skipped with a message.
  --classes LIST    comma separated classes. articles: and or phrase mixed count filter join term prefix fuzzy update; places:
                    prefix-1 prefix-2 prefix-3 prefix-4 whole-word word-prefix-2 word-prefix-1 name-region-country name-country (default: the classes of the tier; a dataset with none of the names is not run)
  --workloads LIST  places workloads: top boost filter count scan (default: those of the tier)
  --rebuild         build every engine database again instead of taking it from the cache of built clusters ($BENCH_STORE/cache/lanes)
  --keep            keep the cache of built clusters after the session (written by every run, bounded by BENCH_CACHE_MAX); without it the
                    cache is written only when a second session needs it (`all` with the cold-cache tables) and removed after the session
  --sample N        statements per class of the articles trace every engine gets (default 0 = the whole trace; a fixed
                    pseudo-random order, the same for every engine)
  --clients N       concurrent clients of the articles cells (default 8; places is single client)
  --workers N       cap the worker processes: pgbench threads and the server's parallel workers (default: 4 pgbench threads, one parallel worker per hardware thread, an index build one fewer than the physical cores)
  --max-time S      the longest a cell is measured in a round, seconds (alias --duration; defaults: slim 1, ab 2, full 2 for articles;
                    places 1 / 1 / 1). A cell stops earlier as soon as its relative standard error is at most --rse; the slow ones
                    (tens of ms and more per query) run the whole time
  --min-time S      the shortest a cell is measured in a round, seconds (default 0.25)
  --min-samples N   the fewest transactions of a cell in a round (default 3 for places, the number of clients for articles)
  --rounds MIN-MAX  rounds (default 2-3, regress 3-3): after MIN only the cells whose relative standard error is above --rse are measured
                    again, up to MAX. Three is the most there is: a MAX above 3 runs, with a warning. The standard error of a cell
                    combines the one of each round (batch means, order statistics) with the spread of the rounds
  --rse X           the relative standard error a cell is measured to in a round, and below which a cell is not measured again
                    (default 0.05; the cells that one more round cannot bring below it are not repeated)
  --ref REF         regress, setup: the git ref sift_ref is built from (default the tag SIFT_REF of bench/versions.env, v1.1.0)
  --run             regress: build and measure the engines and their references first
  --out DIR         result directory (default bench/out/run-<UTC time>, gitignored; `bench/out/latest` links
                    to the last one). report: where results.tsv / builds.tsv / tables.md are written
  --docs            report: also write bench/results/<dataset>.tsv (the committed chart input), redraw bench/out/img and rewrite the generated
                    blocks of README.md and bench/out/BENCHMARKS.md; all: also measure the cold-cache tables, then report --docs
  --merge           report --docs: replace only the cells of the result directories in bench/results (engine, class, variant, metric); every
                    other row of the tier stays: the way to add the classes of a later run (`run --classes prefix-2,prefix-4,word-prefix-1`) to a published tier
  --no-build        setup/all: use the engines already built under $BENCH_WORK (analytics: do not build sift from this checkout)
  analytics options  --scale S (orders = 1.2M x S), --orders N, --stores N (40000), --cluster store (heap clustered by store and
                    date), --flat, --clickhouse, --runs N (5, after one warm-up), --sets K10,K400,K4000, --queries metrics-summary,whole (ids, or the first word of several),
                    --engines heap,col,flat,ch, --col-index 'DDL'|@FILE, --base-gucs / --col-gucs 'name=value ...' (default
                    sift.enable_agg=off / on), --col-plan REGEX, --timeout S, --rows N (clickbench), --docs (also copy analytics.json / clickbench.json
                    to bench/results); SIFT_TMP, SIFT_PORT
  --size P          the size of a pass for every command: smoke | quick | standard (default, the release size) | full (bench/presets.env, `bench/bench --list`); it sets
                    the orders and articles, runs, cold runs, update windows and rounds, and --docs is refused below standard
  store options     --sizes N,N (orders, default the size's), --cpus N (the CPU quota of every container and the thread budget of every engine at the load and the updates, default 3),
                    --cpus-list N,N (the budgets the questions are timed at, each a result set; default 1,3), --memory SIZE (limit of every container, default 8g), --runs N, --warmups N,
                    --cold-runs N (0 = none, the server's memory emptied between runs), --restart-runs N (container restarted between runs), --cold-check Q,Q (restart against emptied, off), --rates R,R
                    (update rates), --update-paths E,E (engines of the update table), --load-seconds N, --probes N, --engines pg,sift,sift_table,citus,es,es_cached,ch, --queries IDS (01..10 or names),
                    --remeasure-all, --remeasure E,E, --no-cache, --cache-dir D, --cache-max-age DAYS (cells of Elasticsearch, ClickHouse and Citus come from the cache of cells unless one of these or a
                    changed key), --parallel-engines N (independent engines at once on disjoint cpusets), --max-rerun N (NOISY cells measured again), --docs, --out DIR,
                    --compression (the pg_sift compression variants as extra lanes and the size of every system with its other settings; bench/results/store-compression.json);
                    STORE_PORT_BASE (first of five ports on 127.0.0.1, default 56280), STORE_NAME (container name prefix, default sift-store)
  final options     --stages store,compression,stats,gap,search, --budget MIN (default 96 at --size standard, 25 below it; 0 = no check), --dry-run, --no-resume, --docs; every other option goes to the stages
  --no-datasets     setup/all: do not download the datasets
  --cache, --build, --all   clean: also remove the dumps and the cache of lanes, the engine builds, everything
  --run-id ID       name the run (bench/out/run-ID); a directory that has a manifest is continued, results of the same key are kept (`bench/bench resume`)
  --fail-fast, --keep-going   final: stop at the first failed stage, or (default) go on and list the failed ones; `resume` runs only those
  -h, --help        this text

Exit status: 0 done, 1 failed, 3 a result differs from PostgreSQL's, 4 over the stage budget (BUDGET_STRICT=1), 75 interrupted (SIGINT/SIGTERM): `bench/bench resume` continues.

report takes result directories as arguments: a run directory (default: bench/out/latest) or
DATASET:TIER:DIR for a raw tables directory of steps/articles.sh (articles) or steps/places.sh (places).

Output of run/all: a result directory with machine.txt, articles-<tier>/ and places-<tier>/ (raw cell tables: every round's
value), and results.tsv, builds.tsv and tables.md (value, standard error, rounds); the formats are in bench/README.md.

Environment
  BENCH_WORK        the work directory: live clusters, scratch and engine builds (default /tmp/sift-bench, a tmpfs here: the published
                    numbers were measured with the clusters in memory; a disk gives page cache, writeback and real cold-cache reads
                    instead, so do not compare numbers across the two). SIFT_BENCH_ROOT is the old name
  BENCH_STORE       datasets, downloads, corpus dumps and the cache of lanes (default BENCH_WORK; point it at a disk to keep them out of RAM:
                    inputs and caches only, it changes no measurement). `bench du` shows both
  BENCH_CACHE_MAX   MiB of the cache of lanes kept after a session with --keep (default 4096)
  SIFT_QUIET_MAX    percent of cpu / io pressure (PSI, avg10) under which the host is quiet before a timed batch (default 10); SIFT_QUIET_TIMEOUT: seconds to
                    wait for that (default 60) before the batch runs marked NOISY; SIFT_QUIET_WINDOW avg10 | avg60 (scripts/quiet.sh)
  KEEP_DOWNLOADS=1  keep the raw downloads and engine tarballs after setup (removed by default); REFRESH_DATASETS=1, REBUILD_ENGINES=1: setup
                    converts / builds again what it finds up to date
  SIFT_SRC          head | worktree | ref:REF (a tag or commit, ref:v3.1.0): sift is built from the last commit, the tracked files (default) or that ref
  SIFT_REF          the git ref of sift_ref (default v1.1.0)
  BM25_PKG, BM25_SRC  pgx-bm25 when it is not installed (it is a system package: pacman -S pgx-bm25): the Arch package to unpack, or the source tarball to build
  JOBS              parallel jobs of the engine builds (default 4)
  MINR, MAXR, RSE, SECS, WARM, CLIENTS, TIMEOUT, UPDATE_RATE, KEEP=1 (keep the cluster), Q_DOCS  see steps/articles.sh, steps/places.sh
  Versions, dump dates and checksums: bench/versions.env

Examples
  bench/bench all                                     clean machine to tables, slim tier, about 2 minutes of measurements
  bench/bench all --docs                              the same with the cold-cache tables, then bench/results, charts, README.md and
                                                      the slim section of bench/out/BENCHMARKS.md: regenerates every published number
  bench/bench all --full                              the full-size run (about an hour), with the cold-cache tables
  bench/bench du                                      sizes by component; bench/bench clean --all removes everything
  bench/bench setup && bench/bench run --tier full --engines sift,pg_search,bm25
  bench/bench run --dataset places --tier ab --duration 4
  bench/bench run --dataset articles --engines gin,tantivy --classes and,or,update
  bench/bench report --docs --engines sift,sift_nopos,pg_search bench/out/latest
  bench/bench charts                                  redraw bench/out/img from bench/results
  bench/bench regress --run --ref v1.1.0              sift against the tag v1.1.0, exit 1 on a regression
  bench/bench check oracle sift 200
  bench/bench readme                                  the README block from bench/results, without measuring
  bench/bench clean --all
  bench/bench analytics run --flat --clickhouse        the analytics corpus: baseline, flat tables and ClickHouse (+ sift_col when built)
  bench/bench analytics run --queries metrics-summary,item-summary-rollup --sets K400 --col-index @my_index.sql
  bench/bench analytics clickbench --rows 1000000      the ClickBench subset (explicit: it downloads parquet files)
  bench/bench store all --docs                         the orders benchmark at 1M and 10M orders, then bench/results/store.json, README.md and bench/out/BENCHMARKS.md
  bench/bench store all --sizes 1000000 --cold-runs 0  one size, warm runs only
  bench/bench store all --size quick                  the development loop; `compare A.json B.json` diffs two runs, `store gap` prints the gap tables
  bench/bench final --dry-run                         the release pass: stages, budgets, total against 25 min
EOF
}

die() { echo "bench: $*" >&2; exit 1; }
trap '[[ -z ${PEAK_PID:-} ]] || kill "$PEAK_PID" 2>/dev/null || true' EXIT   # the sampler of the footprint (steps/footprint.sh)

# --- options
SLIM_DOCS=50000   # articles of the slim tier (the places are cities15000): bench/README.md says why this size
a_scale='' a_orders='' a_stores='' a_cluster='' a_flat='' a_ch='' a_runs='' a_sets='' a_queries='' a_col_index='' a_base_gucs='' a_col_gucs='' a_col_plan='' a_timeout='' a_rows=''
s_warmups='' s_sizes='' s_cpus='' s_cpus_list='' s_memory='' s_cold='' s_update_sizes='' s_compression='' size='' dry='' list='' stages='' budget='' run_id='' keep_going=''
ix=() s_restarts='' s_check='' s_rates='' s_paths='' s_parallel='' s_remeasure_all='' s_remeasure='' s_no_cache='' s_cache_dir='' s_cache_age='' s_max_rerun='' s_load_sec='' s_probes=''
tier=slim dataset=all engines='' classes='' workloads='' clients='' workers='' duration='' rounds='' out='' docs='' merge='' build=1 fetch_data=1 all_clean='' ref='' run='' keep='' clean_cache='' clean_build=''
rest=()
parse() {
  while (( $# )); do
    case $1 in
      --tier) tier=$2; shift ;;
      --dataset|--datasets) dataset=$2; shift ;;
      --engines) engines=${2//,/ }; shift ;;
      --classes) classes=${2//,/ }; shift ;;
      --workloads) workloads=${2//,/ }; shift ;;
      --clients) clients=$2; shift ;;
      --workers) workers=$2; shift ;;
      --duration|--max-time) duration=$2; shift ;;
      --min-time) export MIN_TIME=$2; shift ;;
      --min-samples) export MIN_N=$2; shift ;;
      --rounds) rounds=$2; shift ;;
      --ref) ref=$2; shift ;;
      --run) run=1 ;;
      --rebuild) export NO_CACHE=1 ;;
      --keep) export KEEP_CACHE=1 LANE_CACHE_WRITE=1 ;;
      --cache) clean_cache=1 ;;
      --build) clean_build=1 ;;
      --sample) export SAMPLE=$2; shift ;;
      --rse) export RSE=$2; shift ;;
      --out) out=$2; shift ;;
      --docs) docs=1 ;;
      --merge) merge=1 ;;
      --no-build) build= ;;
      --no-datasets) fetch_data= ;;
      --all) all_clean=1 ;;
      --full) tier=full ;;
      --scale) a_scale=$2; shift ;;
      --orders) a_orders=$2; shift ;;
      --stores) a_stores=$2; shift ;;
      --cluster) a_cluster=$2; shift ;;
      --flat) a_flat=1 ;;
      --clickhouse) a_ch=1 ;;
      --runs) a_runs=$2; shift ;;
      --sets) a_sets=$2; shift ;;
      --queries) a_queries=$2; shift ;;
      --col-index) a_col_index=$2; shift ;;
      --base-gucs) a_base_gucs=$2; shift ;;
      --col-gucs) a_col_gucs=$2; shift ;;
      --col-plan) a_col_plan=$2; shift ;;
      --timeout) a_timeout=$2; shift ;;
      --rows) a_rows=$2; shift ;;
      --sizes) s_sizes=$2; shift ;;
      --cpus) s_cpus=$2; shift ;;
      --cpus-list) s_cpus_list=$2; shift ;;
      --memory) s_memory=$2; shift ;;
      --cold-runs) s_cold=$2; shift ;;
      --warmups) s_warmups=$2; shift ;;
      --update-sizes) s_update_sizes=$2; shift ;;
      --size) size=$2; shift ;;
      --restart-runs) s_restarts=$2; shift ;;
      --cold-check) s_check=$2; shift ;;
      --rates) s_rates=$2; shift ;;
      --update-paths) s_paths=${2//,/ }; shift ;;
      --parallel-engines) s_parallel=$2; shift ;;
      --remeasure-all) s_remeasure_all=1 ;;
      --remeasure) s_remeasure=$2; shift ;;
      --no-cache) s_no_cache=1 ;;
      --cache-dir) s_cache_dir=$2; shift ;;
      --cache-max-age) s_cache_age=$2; shift ;;
      --max-rerun) s_max_rerun=$2; shift ;;
      --load-seconds) s_load_sec=$2; shift ;;
      --probes) s_probes=$2; shift ;;
      --target|--concurrent|--samples) ix+=("$1" "$2"); shift ;;   # interference
      --run-id) run_id=$2; shift ;;
      --fail-fast) keep_going=0 ;;
      --keep-going) keep_going=1 ;;
      --dry-run) dry=1 ;;
      --list) list=1 ;;
      --stages) stages=$2; shift ;;
      --budget) budget=$2; shift ;;
      --compression) s_compression=1 ;;
      -h|--help) usage; exit 0 ;;
      --*) die "unknown option $1 (bench/bench --help)" ;;
      *) rest+=("$1") ;;
    esac
    shift
  done
  [[ $tier != quick ]] || tier=slim   # the old name
  if [[ -n $size ]]; then   # the size of the pass (bench/presets.env) also names the search tier
    . "$HERE/presets.env"; preset "$size" || die "unknown size $size ($PRESETS)"
    case $size in full) tier=full ;; *) tier=slim ;; esac
    [[ -z $docs || -n ${REHEARSAL:-} || $size == standard || $size == release || $size == full ]] || die "--docs publishes numbers: they are only measured at --size standard, release or full, not $size"
  fi
  case $tier in slim|ab|full) ;; *) die "unknown tier $tier (slim, ab or full)" ;; esac
  case $dataset in articles|places|all) ;; *) die "unknown dataset $dataset" ;; esac
  [[ -z $ref ]] || export SIFT_REF=$ref
  [[ $dataset == all ]] && datasets="articles places" || datasets=$dataset
}

ARTICLE_CLASSES="and or phrase mixed count filter join term prefix fuzzy update"
PLACE_CLASSES="prefix-1 prefix-2 prefix-3 prefix-4 whole-word word-prefix-2 word-prefix-1 name-region-country name-country"
class_set() { # DATASET: the --classes that belong to the dataset
  local c set=$ARTICLE_CLASSES
  [[ $1 == places ]] && set=$PLACE_CLASSES
  for c in $classes; do [[ " $set " == *" $c "* ]] && printf '%s ' "$c"; done
  return 0
}
check_classes() {
  local c d keep=''
  for c in $classes; do [[ " $ARTICLE_CLASSES $PLACE_CLASSES " == *" $c "* ]] || die "unknown class $c (articles: $ARTICLE_CLASSES; places: $PLACE_CLASSES)"; done
  [[ -n $classes ]] || return 0
  for d in $datasets; do [[ -z $(class_set "$d") ]] || keep+="$d "; done   # a dataset none of whose classes were named is not run
  datasets=${keep% }
  [[ -n $datasets ]] || die "none of the classes ($classes) belongs to the dataset $dataset"
}

# --- tiers and engines (ALL_ENGINES, DEFAULT_ENGINES come from engines.tsv through steps/lib.sh)
tier_env() {
  step_mode=quick
  case $tier in
    slim) export Q_DOCS=${Q_DOCS:-$SLIM_DOCS} MINR=${MINR:-2} MAXR=${MAXR:-3} SECS=${SECS:-1} WARM=${WARM:-1} ;;
    ab) ;;
    full) step_mode=full ;;
  esac
  if [[ -n $size ]]; then   # the preset's search settings; an option or a variable of the environment wins
    export Q_DOCS=${Q_DOCS:-$P_DOCS} MINR=${MINR:-${P_ROUNDS%-*}} MAXR=${MAXR:-${P_ROUNDS#*-}} SECS=${SECS:-$P_SECS}
    [[ $P_DOCS != ALL ]] || unset Q_DOCS
    [[ -n $classes || -z $P_ART_CLASSES ]] || classes="$P_ART_CLASSES $P_PLACE_CLASSES"
    [[ -n $workloads || -z $P_PLACE_WLS ]] || export WLS=$P_PLACE_WLS
  fi
  [[ -z $rounds ]] || { export MINR=${rounds%-*} MAXR=${rounds#*-}; }
  [[ -z $duration ]] || export SECS=$duration
  [[ -z $clients ]] || export CLIENTS=$clients
  [[ -z $workers ]] || export WORKERS=$workers THREADS=$workers
  [[ -z $workloads ]] || export WLS=$workloads
  case $tier in full) export TIER=F ;; *) export TIER=Q ;; esac
}
engine_default=DEFAULT_ENGINES   # the lib.sh list `selected` falls back to: DEFAULT_ENGINES, SUITE_ENGINES (suite), PAIR_ENGINES (regress)
selected() { # the engines of --engines (default: those with default = yes)
  local e
  for e in ${engines:-${!engine_default}}; do
    [[ " $ALL_ENGINES " == *" $e "* ]] || die "unknown engine $e (bench/engines.tsv: $ALL_ENGINES)"
    printf '%s ' "$e"
  done
}

load_lib() { . "$STEPS/lib.sh"; . "$STEPS/footprint.sh"; }   # the settings of the steps, then du / clean / the footprint of a run

# --- prerequisites, checked before anything is downloaded or built
preflight() {
  local missing=() c x
  for c in pg_config gcc make python3 curl bzip2 gzip zstd unzip ar tar flock awk; do command -v "$c" >/dev/null || missing+=("$c"); done
  if command -v pg_config >/dev/null; then
    PATH=$(pg_config --bindir):$PATH
    for c in psql pgbench initdb pg_ctl pg_dump pg_restore; do command -v "$c" >/dev/null || missing+=("$c"); done
    [[ -f $(pg_config --pgxs) ]] || missing+=("PostgreSQL server development files (pgxs.mk)")
    for x in pg_stat_statements pg_prewarm pg_buffercache; do [[ -f $(pg_config --sharedir)/extension/$x.control ]] || missing+=("contrib module $x"); done
  fi
  (( ${#missing[@]} == 0 )) || die "missing prerequisites: ${missing[*]}"
  export PATH
  load_lib
  [[ $(pg_config --version | grep -oP '\d+' | head -1) == "$PG_MAJOR" ]] || die "PostgreSQL $PG_MAJOR is required, pg_config is $(pg_config --version)"
  mkdir -p "$ROOT" "$STORE"
  local need free
  need=$([[ $tier == full ]] && echo 8 || echo 3)   # GiB at the peak of a session, bench du and the footprint line after a run show the real numbers
  free=$(( $(df -Pk "$ROOT" | awk 'NR == 2 {print $4}') / 1048576 ))
  (( free >= need )) || echo "warning: only $free GiB free under $ROOT (BENCH_WORK), $need GiB recommended" >&2
}

fetch_targets() { # the steps/fetch.sh targets of the engines: the variants of sift are one build, gin is built in
  local e
  for e in "$@"; do
    case $e in
      sift|sift_nopos) echo sift ;;
      sift_ref) echo sift_ref ;;
      gin) ;;
      *) echo "$e" ;;
    esac
  done | awk '!seen[$0]++'
}

cmd_setup() {
  preflight
  # the datasets are downloaded and converted while the engines build; what is up to date (stamps next to the datasets and the engines) is skipped
  local t dlog dpid=''
  if [[ $fetch_data ]]; then dlog=$(mktemp); "$HERE/datasets/fetch.sh" "$dataset" >"$dlog" 2>&1 & dpid=$!; fi
  if [[ $build ]]; then
    # Without network or a toolchain the system's copy of an engine is used when installed (machine.txt says which).
    # shellcheck disable=SC2046  # one argument per engine
    for t in $(fetch_targets $(selected)); do
      [[ $t != sift ]] || echo "sift ($(git -C "$REPO" rev-parse --short HEAD 2>/dev/null || echo 'no git'))"
      "$STEPS/fetch.sh" "$t" || case $t in
        elasticsearch|bm25) die "$t is a system package of this suite and is not installed (the message above says how)" ;;
        *) echo "warning: $t could not be built or fetched; using the system's if installed" >&2; [[ $t == sift ]] || rm -rf "${ENG:?}/$t/root" ;;
      esac
    done
  fi
  [[ -z $dpid ]] || { wait "$dpid" || { cat "$dlog" >&2; rm -f "$dlog"; die "the datasets could not be downloaded or converted"; }; cat "$dlog"; rm -f "$dlog"; }
}

ready() { # the datasets are present
  local d
  for d in $datasets; do
    case $d in
      articles) [[ -f $DATA/articles.tsv.gz || -f $DATA/articles.tsv ]] ;;
      places) [[ -f $DATA/places-$([[ $tier == full ]] && echo full || echo quick)/places.tsv ]] ;;
    esac || die "the $d dataset is missing under $DATA: run bench/bench setup"
  done
}

new_out() { # the run directory (--run-id ID or --out DIR names it, else a new one) and its manifest; a directory that has one is the run that `resume` continues
  declare -F run_begin >/dev/null || load_lib
  OUT=${out:-$HERE/out/run-${run_id:-$(date -u +%Y%m%dT%H%M%SZ)}}
  mkdir -p "$OUT" "$HERE/out"
  OUT=$(cd "$OUT" && pwd)
  export BENCH_RUN_ID=${OUT##*/run-}
  ln -sfn "$OUT" "$HERE/out/latest"
  exec > >(tee -a "$OUT/bench.log") 2>&1
  if run_alive "$OUT/.run" && [[ $(<"$OUT/.run/pid") != "$$" ]] && [[ $(cut -f1 "$OUT/.run/state") == running ]]; then die "run $BENCH_RUN_ID is running (pid $(<"$OUT/.run/pid"))"; fi
  [[ ! -s $OUT/.run/argv ]] || echo "run $BENCH_RUN_ID: continuing (state $(cut -f1 "$OUT/.run/state" 2>/dev/null || echo unknown)), what finished with the same key is kept"
  run_reclaim
  local args=("${ARGV[@]}"); [[ " ${ARGV[*]} " == *" --run-id "* ]] || args+=(--run-id "$BENCH_RUN_ID")
  run_begin "${args[@]}"
}

latest_unfinished() { # the manifest directory of the newest run that is not done
  local x
  # shellcheck disable=SC2045  # run directory names have no spaces; newest first
  for x in $(ls -dt "$HERE"/out/run-*/.run 2>/dev/null); do [[ $(cut -f1 "$x/state" 2>/dev/null) != 'done' ]] && { echo "$x"; return; }; done
}
cmd_resume() { # [ID]: the unfinished run (the latest, or ID) with the command line it started with
  local d argv
  if [[ -n ${rest[0]:-} ]]; then d=$HERE/out/run-${rest[0]#run-}/.run; else d=$(latest_unfinished); fi
  [[ -n $d && -s $d/argv ]] || die "no run to resume (bench/out/run-*/.run holds the unfinished ones)"
  [[ $(cut -f1 "$d/state") != 'done' ]] || die "the run $(basename "$(dirname "$d")") is finished"
  eval "argv=($(<"$d/argv"))"
  echo "resuming ${argv[*]}"
  exec "$HERE/bench" "${argv[@]}"
}

cmd_clean_run() { # --run-id ID: what that run started and nothing else: its process if it is still there, its containers and its run directory
  local d=$HERE/out/run-$run_id pid
  [[ -d $d ]] || die "no run $run_id (bench/out/run-*)"
  if [[ -s $d/.run/pid ]]; then pid=$(<"$d/.run/pid"); [[ ! -d /proc/$pid ]] || [[ $(tr '\0' ' ' <"/proc/$pid/cmdline") != *bench* ]] || { kill_tree "$pid"; echo "stopped $pid"; }; fi
  run_stop "$run_id"
  run_containers "$run_id" | xargs -r docker rm -fv >/dev/null
  rm -rf "$d"
  echo "removed the run $run_id (its containers and $d); datasets, data volumes and the caches stay (clean --all)"
}

measure() { # [cold]: every selected dataset at the tier into $OUT, after the machine description
  local d t0=$SECONDS suffix=${1:+-cold} e key
  perf_hold
  [[ -n ${PEAK_PID:-} ]] || peak_start
  [[ -n ${1:-} && -s $OUT/machine.txt ]] || "$STEPS/machine.sh" >"$OUT/machine.txt"
  e=$(selected)
  for d in $datasets; do
    echo; echo "=== $d, tier $tier${1:+, cold cache} ($(( SECONDS - t0 )) s)"
    key=$(python3 -I "$STEPS/benchlib.py" key "$(code_key)" "$(fingerprint)" "$d $tier ${1:-} $e $(class_set "$d") ${WLS:-} ${Q_DOCS:-} ${MINR:-} ${MAXR:-} ${SECS:-} ${RSE:-} ${MIN_TIME:-} ${MIN_N:-} ${CLIENTS:-}")
    case $d in
      articles) stage_run "$d$suffix" "$key" 0 env ENGINES="$e" CLASSES="$(class_set articles)" DEST="$OUT/articles-$tier$suffix" "$STEPS/articles.sh" ${1:+cold} "$step_mode" ;;
      places)   stage_run "$d$suffix" "$key" 0 env ENGINES="$e" CLASSES="$(class_set places)" DEST="$OUT/places-$tier$suffix" "$STEPS/places.sh" "$step_mode" ${1:+cold} ;;
    esac
  done
  echo "wall time of the measurements $(( SECONDS - t0 )) s, at end $(cat "$OUT"/*/noisy.txt 2>/dev/null | wc -l) NOISY batches (noisy.txt of each table directory), $(quiet_report 2>&1)" | tee -a "$OUT/machine.txt"
}

end_session() { cache_prune; peak_report; }   # after a successful session: the cache of lanes goes unless it is kept, the peak footprint is printed

cmd_run() { # [cold]
  check_classes; preflight; ready; tier_env; new_out
  measure "$@"
  echo "results: $OUT"
  rest=("$OUT"); cmd_report
  end_session
}

cmd_report() {
  command -v python3 >/dev/null || die "python3 is required"
  local specs=() arg d dir
  set -- "${rest[@]}"
  (( $# )) || set -- "$HERE/out/latest"
  for arg in "$@"; do
    if [[ $arg == *:*:* ]]; then specs+=("$arg"); continue; fi
    [[ -d $arg ]] || die "no such result directory: $arg"
    dir=$(cd "$arg" && pwd)
    [[ -n $out ]] || out=$dir
    for d in "$dir"/articles-* "$dir"/places-*; do
      [[ -f $d/cells.tsv || -f $d/cold.tsv ]] || continue
      specs+=("$(basename "$d" | sed -E 's/^([a-z]+)-(.*)$/\1:\2/'):$d")
    done
  done
  (( ${#specs[@]} )) || die "no results to report"
  [[ -n $out ]] || die "report with DATASET:TIER:DIR arguments needs --out DIR"
  local args=("$out")
  [[ -z $engines ]] || args+=(--engines "${engines// /,}")
  [[ -z $docs ]] || args+=(--per-dataset "$REPO/bench/results")
  [[ -z $merge ]] || args+=(--merge)
  python3 -I "$STEPS/tables.py" "${args[@]}" "${specs[@]}"
  echo; echo "written: $out/{results.tsv,builds.tsv,tables.md}${docs:+ and $REPO/bench/results/}"
  [[ -z $docs || ${specs[*]} != *:slim:* ]] || { cmd_charts; cmd_readme >/dev/null; }   # the charts and the README are the slim tier's
}

cmd_charts() {
  command -v python3 >/dev/null || die "python3 is required"
  python3 -I "$STEPS/charts.py" --results "$REPO/bench/results" --out "$REPO/bench/out/img" --tier slim
}

cmd_readme() { # the generated blocks of README.md and bench/out/BENCHMARKS.md from the slim rows of bench/results
  command -v python3 >/dev/null || die "python3 is required"
  python3 -B -I "$STEPS/readme.py" --results "$REPO/bench/results" --readme "$REPO/bench/out/README-blocks.md" --benchmarks "$REPO/bench/out/BENCHMARKS.md" --tier slim
}

cmd_all() {
  local t0=$SECONDS
  check_classes
  new_out
  echo "=== setup"; cmd_setup
  ready; tier_env
  local cold=''; [[ $tier != slim || -n $docs ]] && cold=1   # the cold-cache tables: the published tiers and --docs
  # the lanes of the cold session are those of the warm one: cached in between, unless the tier builds faster than the cache would be written (slim: Elasticsearch's warm-up, not a build, sets the pace)
  [[ -z $cold || $tier == slim ]] || export LANE_CACHE_WRITE=1
  measure
  [[ -z $cold ]] || measure cold
  echo "=== report"; rest=("$OUT"); cmd_report
  echo "bench all --tier $tier: wall time $(( SECONDS - t0 )) s" | tee -a "$OUT/machine.txt"
  end_session
}

cmd_analytics() { # setup | run | report [DIR] | clean | clickbench | clickbench-setup: steps/analytics.sh; the settings travel as A_* variables
  local sub=${rest[0]:-run}
  case $sub in setup|run|report|clean|clickbench|clickbench-setup) ;; *) die "usage: analytics setup|run|report|clean|clickbench|clickbench-setup" ;; esac
  for c in psql initdb pg_ctl python3; do command -v "$c" >/dev/null || die "missing prerequisite: $c"; done
  export A_SCALE=$a_scale A_ORDERS=$a_orders A_STORES=$a_stores A_CLUSTER=$a_cluster A_FLAT=$a_flat A_CH=$a_ch A_RUNS=${a_runs:-5} A_SETS=$a_sets \
         A_QUERIES=$a_queries A_COL_INDEX=$a_col_index A_BASE_GUCS=$a_base_gucs A_COL_GUCS=$a_col_gucs A_COL_PLAN=$a_col_plan A_TIMEOUT=$a_timeout \
         A_ROWS=$a_rows A_ENGINES=${engines// /,} A_BUILD=${build:-0} A_ALL=$all_clean
  for c in A_SCALE A_ORDERS A_STORES A_ROWS A_TIMEOUT; do [[ -n ${!c} ]] || unset "$c"; done
  case $sub in
    run|clickbench)   # the load needs no lock: the corpus is there when the lock is taken
      load_lib; new_out; export OUT
      "$STEPS/analytics.sh" "$([[ $sub == run ]] && echo setup || echo clickbench-setup)"
      perf_hold; "$STEPS/analytics.sh" "$sub"
      [[ -z $docs ]] || { cp "$OUT/analytics.json" "$REPO/bench/results/${sub/run/analytics}.json"; echo "written: $REPO/bench/results/${sub/run/analytics}.json"; } ;;
    report) "$STEPS/analytics.sh" report "${rest[1]:-}" ;;
    *) "$STEPS/analytics.sh" "$sub" ;;
  esac
}

cmd_store() { # [all|setup|run|cold|updates|report|merge|clean]: steps/store.sh; the settings travel as S_* variables, the timed runs take the perf lock themselves
  local sub=${rest[0]:-all}
  [[ $sub != scaling ]] || exec "$STEPS/store_scaling.sh" "${rest[@]:1}"   # the results of other sizes into one file: steps/store_scaling.sh
  case $sub in all|plan|setup|run|cold|updates|section|prepare|finish|interference|stats|report|merge|clean|table|gap|compare) ;; *) die "usage: store [all|plan|setup|run|cold|updates|section|prepare|finish|interference|stats|report|merge|clean|table|gap|compare]" ;; esac
  if [[ $s_compression && -z $engines ]]; then   # the pg_sift compression variants next to PostgreSQL, the default sift_col, Citus, ClickHouse and Elasticsearch
    engines="pg sift sift_none sift_enc sift_auto sift_lz4 sift_zstd sift_table es ch citus"
  fi
  for c in docker psql python3 flock ss; do command -v "$c" >/dev/null || die "missing prerequisite: $c"; done
  export S_PRESET=${size:-$DEFAULT_PRESET} S_SIZES=$s_sizes S_CPUS=$s_cpus S_CPUS_LIST=$s_cpus_list S_MEMORY=$s_memory S_RUNS=$a_runs S_COLD=$s_cold S_WARMUPS=$s_warmups S_ENGINES=${engines// /,} \
         S_QUERIES=$a_queries S_DOCS=$docs S_UPDATE_SIZES=$s_update_sizes S_COMPRESSION=$s_compression S_RESTARTS=$s_restarts S_CHECK=$s_check S_RATES=$s_rates S_UPDATE_PATHS=$s_paths \
         S_PARALLEL=$s_parallel S_REMEASURE_ALL=$s_remeasure_all S_REMEASURE=$s_remeasure S_NO_CACHE=$s_no_cache S_CACHE_DIR=$s_cache_dir S_CACHE_MAX_AGE=$s_cache_age S_MAX_RERUN=$s_max_rerun \
         S_LOAD_SEC=$s_load_sec S_PROBES=$s_probes
  for c in S_SIZES S_CPUS S_CPUS_LIST S_MEMORY S_RUNS S_WARMUPS S_COLD S_UPDATE_SIZES S_COMPRESSION S_PORT S_NAME S_RESTARTS S_CHECK S_RATES S_UPDATE_PATHS S_PARALLEL S_REMEASURE_ALL S_REMEASURE \
           S_NO_CACHE S_CACHE_DIR S_CACHE_MAX_AGE S_MAX_RERUN S_LOAD_SEC S_PROBES; do [[ -n ${!c:-} ]] || unset "$c"; done
  case $sub in all|plan|setup|run|cold|updates|section|prepare|interference|stats) new_out; export OUT ;; esac
  "$STEPS/store.sh" "$sub" "${rest[@]:1}" ${ix[@]+"${ix[@]}"}
}

cmd_regress() { # [--run] [DIR|results.tsv ...]
  local files=() arg rc=0
  if [[ $run ]]; then
    # the reduced matrix: sift against its reference on both datasets, the classes and workloads a change to the index or the scan
    # touches (--engines adds sift_nopos; --classes and --workloads widen it)
    [[ -n $engines ]] || engines="sift sift_ref"
    [[ -n $classes ]] || classes="and or phrase count filter join prefix-1 prefix-3 whole-word name-region-country"
    [[ -n $workloads ]] || workloads="top boost"
    [[ -n $rounds ]] || rounds=3-3   # three rounds, the most the harness runs by default: a 5% difference is told from the noise by the standard errors of the cells
    check_classes; preflight; ready; tier_env
    "$STEPS/fetch.sh" sift
    local sel; sel=" $(selected)"
    [[ $sel != *" sift_ref "* ]] || [[ $(cut -d' ' -f1 "$ENG/sift_ref/root/.build-info" 2>/dev/null) == "$SIFT_REF" ]] || "$STEPS/fetch.sh" sift_ref
    new_out
    measure
    rest=("$OUT"); cmd_report >/dev/null
    files=("$OUT/results.tsv")
    end_session
  else
    set -- "${rest[@]}"
    (( $# )) || set -- "$HERE/out/latest"
    for arg in "$@"; do
      if [[ -d $arg ]]; then
        [[ -f $arg/results.tsv ]] || { rest=("$arg"); cmd_report >/dev/null; }
        files+=("$arg/results.tsv")
      else files+=("$arg"); fi
    done
  fi
  python3 -I "$STEPS/regress.py" "${files[@]}" || rc=$?
  return $rc
}

cluster_up() { # the tier's cluster with the `bench` corpus, left running (bench clean stops it)
  preflight; ready; tier_env
  "$STEPS/corpus.sh" ensure >/dev/null
}

cmd_check() {
  if [[ $dataset == places ]]; then
    [[ ${rest[0]:-} != [0-9]* ]] || rest=('' "${rest[@]}")   # check --dataset places N: both checks
    case ${rest[0]:-} in oracle|rank|'') ;; *) die "usage: check --dataset places [oracle|rank] [N]" ;; esac
    datasets=places; preflight; ready; tier_env
    ENGINES=$(selected) DEST=$HERE/out/places-check-$tier "$STEPS/places.sh" "$step_mode" check "${rest[0]:-all}" "${rest[1]:-}"
    return
  fi
  local what=${rest[0]:-} engine=${rest[1]:-} n=${rest[2]:-} rc=0 e
  [[ $what == oracle || $what == rank ]] || die "check oracle|rank [ENGINE] [N]"
  [[ $engine != [0-9]* ]] || { n=$engine; engine=''; }
  [[ -z $engine ]] || engines=$engine
  datasets=articles; cluster_up
  for e in $(selected); do   # without ENGINE every engine of --engines (default: all of them, pgx-bm25 included)
    case $what in
      oracle) "$STEPS/oracle.sh" "$e" "$n" || rc=1 ;;
      rank) "$STEPS/rank_check.sh" "$e" "$n" || rc=1 ;;
    esac
  done
  return $rc
}

cmd_suite() { # the standard suite: run on articles over the named engines (default every engine but the references)
  dataset=articles datasets=articles
  engines=${engines:-${rest[*]}}; engine_default=SUITE_ENGINES
  [[ -n $classes ]] || classes="term and or phrase prefix fuzzy count mixed"
  rest=(); cmd_run
}

cmd_workload() { # ENGINE [CLASS...]
  [[ -n ${rest[0]:-} ]] || die "usage: workload ENGINE [CLASS...]"
  dataset=articles datasets=articles engines=${rest[0]}
  [[ -n $classes ]] || classes=${rest[*]:1}
  [[ -n $classes ]] || classes="and or phrase mixed count filter join update"
  [[ -n $duration ]] || duration=30
  [[ -n $rounds ]] || rounds=1-1
  export RSE=${RSE:-0}   # a timed run: every cell takes the whole duration
  rest=(); cmd_run
}

cmd_workers() { datasets=articles; cluster_up; "$STEPS/build.sh" "${rest[@]}"; }

cmd_shred() { load_lib; perf_hold; "$STEPS/shred.sh"; }

cmd_list() { # the commands, the sizes of a pass and the stages of the release pass
  local n
  printf 'commands: setup run cold report charts readme all regress check suite workload workers shred remote machine du analytics store kit sets cases records compare final release release-quick resume clean\n'
  printf '%-9s %9s %8s %5s %5s %-14s %6s %6s %9s %10s\n' size orders articles runs cold rates update min min-store min-search
  for n in $PRESETS; do
    preset "$n"
    printf '%-9s %9s %8s %5s %5s %-14s %6s %6s %9s %10s\n' "$n" "$P_ROWS" "$P_DOCS" "$P_RUNS" "$P_COLD" "$P_RATES" "$(wc -w <<<"$P_UPDATE_ENGINES")" \
      "$(( P_MIN_STORE_CACHED + ${P_MIN_COMPRESSION:-0} + ${P_MIN_STATS:-0} + ${P_MIN_GAP:-0} + P_MIN_SEARCH ))" "$(( P_MIN_SETUP + P_MIN_WARM + P_MIN_COLD + P_MIN_UPDATES ))" "$P_MIN_SEARCH"
  done
  printf 'default size: %s; final stages: store compression stats gap search (bench/bench final --dry-run)\n' "$DEFAULT_PRESET"
}

ARGV=("$@")
cmd=${1:-}
[[ -n $cmd ]] || { usage; exit 1; }
[[ $cmd != --list ]] || { cmd_list; exit 0; }
shift || true
case $cmd in
  -h|--help|help) usage; exit 0 ;;
  refresh) exec "$STEPS/refresh.sh" "$@" ;;   # its own options, the rest goes to `final`
  final) exec "$STEPS/final.sh" "$@" ;;   # its own options, the rest goes to the stages
  release-quick) exec "$STEPS/final.sh" --quick "$@" ;;   # the quick release profile: a budget of 60 min on one slot
  release) exec "$STEPS/release.sh" "$@" ;;   # the authoritative run: `final --size release --docs`, its own options
  sets) exec "$STEPS/sets.sh" "$@" ;;     # steps/sets.sh has its own options
  kit) exec "$STEPS/kit.sh" "$@" ;;       # steps/kit.sh has its own options
  scaling) exec "$STEPS/scaling.sh" "$@" ;;   # partitions and parallel workers: steps/scaling.sh has its own options
  cases) exec "$STEPS/cases.sh" "$@" ;;
  records) exec python3 -I "$STEPS/records" "$@" ;;
  generic) exec python3 -I "$STEPS/generic.py" run "$@" ;;
  duckdb) exec "$STEPS/duckdb.sh" "$@" ;;   # steps/duckdb.sh has its own options
  extras) exec "$STEPS/extras.sh" "$@" ;; # steps/extras.sh: rollups, table writes, BM25 sizes, leaf types, facets, build cost, GPU
esac
parse "$@"
case $cmd in
  setup) cmd_setup ;;
  run) cmd_run ;;
  cold) cmd_run cold ;;
  report) cmd_report ;;
  charts) cmd_charts ;;
  all) cmd_all ;;
  regress) cmd_regress ;;
  check) cmd_check ;;
  suite) cmd_suite ;;
  workload) cmd_workload ;;
  workers) cmd_workers ;;
  shred) cmd_shred ;;
  remote) "$STEPS/remote.sh" "${rest[@]}" ;;
  machine) "$STEPS/machine.sh" ;;
  clean) load_lib; if [[ -n $run_id ]]; then cmd_clean_run; else cmd_clean; fi ;;
  resume) cmd_resume ;;
  du) load_lib; cmd_du ;;
  readme) cmd_readme ;;
  analytics) cmd_analytics ;;
  store) cmd_store ;;
  compare) rest=(compare "${rest[@]}"); cmd_store ;;   # compare A.json B.json: the per-cell deltas with their intervals
  *) echo "bench: unknown command $cmd" >&2; usage; exit 1 ;;
esac
