Batch runs
bibr batch runs a corpus — hundreds to tens of thousands of papers — and
keeps an append-only ledger of what happened to each one, so a run can be
interrupted, resumed, audited and summarised. It replaces the ad-hoc campaign
scripts that used to sit next to the repository.
bibr batch papers/ --out results/ # local: one warm pipeline
bibr batch manifest.txt --out results/ --refs llm # a manifest, LLM reference parsing
bibr batch manifest.txt --out results/ \
--serve-url https://bibr.example.org # remote: a bibr serve job API
bibr batch manifest.txt --out results/ --dry-run # the plan, nothing processed
bibr batch report results/ # summarise the ledger
Re-running the same command continues where the previous run stopped.
Inputs
Any mix of:
| Input | Behaviour |
|---|---|
a manifest text file (.txt, .lst, .list, .manifest, or no extension) |
one path per line; blank lines and lines starting with # are ignored; relative entries resolve against the manifest's directory; a directory entry is walked recursively |
| a directory | walked recursively for .pdf, .docx, .xml, .html/.htm, .epub, sorted |
| a file | processed as given |
| any other file | skipped with a warning (a stray binary in a shell glob never aborts the run) |
Missing entries are reported (and counted in the dry run) but never abort the
run. Every file is identified by its paper_id — the file stem — and its
export is written to <out>/<paper_id>.json. Two inputs whose stems a
filesystem would not tell apart (case-insensitively, as macOS and Windows
compare, and with NFC and NFD spellings equal, as APFS compares) would
overwrite each other, so colliding files get <stem>-<sha256[:8]> instead.
So do a file named run_info, whose export would replace the run's own
run_info.json, a file named like a Windows device (CON, PRN, AUX,
NUL, COM1…COM9, LPT1…LPT9, also before a dot: nul.tar becomes
nul-<sha256[:8]>.tar), and a stem longer than 200 bytes, which is cut to fit
(an export name and its sidecars must stay under the 255-byte file-name
limit). The mapping is printed by --dry-run and recorded under collisions
in run_info.json, and every ledger line carries the original stem.
A file keeps the id the ledger recorded for it — found by its path, however
it is spelled (relative or absolute, from another working directory, through
a manifest's ..), else by its sha256 — and an id recorded for other bytes is
never given to another file. So a corpus can grow, and several directories can
share one --out: a second paper.pdf, from another directory or another
run, gets a suffix and never renames, re-processes or overwrites the paper
already done. A file whose bytes changed (a different size at the same path)
is a new paper too, with its own suffixed id; the earlier export stays.
Selection flags: --limit N processes at most N papers this run (after
resume filtering), --shuffle randomises the order (--seed makes it
reproducible; the seed used is recorded in run_info.json), --deadline
(ISO-8601 or epoch seconds) stops submitting new papers after that time —
in-flight papers finish, and the next run picks up the rest.
Resumability
The ledger's latest line per paper_id decides what a new run does with it:
| latest line | default | --retry-failed |
--force |
|---|---|---|---|
| none | run | run | run |
ok |
skip | skip | run |
failed |
skip | run | run |
failed before the paper got a verdict: error_code interrupted, http_401 or http_403 |
run | run | run |
failed on a crash or a service outage: error_code chunk_error, upstream_unavailable or output_write_failed, or transient_exhausted: true |
run until the paper has failed this way 3 times, then skip | run | run |
started with no verdict after it: the run was killed outright (after an ok, the ok row applies: its export is intact) |
as a crash: run (alone) until it happened 3 times, then skip | run | run |
A paper the run was interrupted on (Ctrl-C), or whose upload the serve refused
because of the token, never got a verdict, so it always runs again. A crash or
a service outage — the OCR or LLM server unreachable or unable to start, a
serve that kept answering 502/503 until the retries ran out, an export that
could not be written (a full disk, a permission) — is usually the machine's
or the service's, so it runs again too. It can also be the paper's own: a bug
its content triggers, a prompt that brings the LLM server down, a model reply
the serve reports as a 502. So a paper that has failed this way three times
since its last success waits for --retry-failed, and a batch still
finishes. A timeout is in neither group: a paper can be too slow on its own,
and re-running it by default would never finish.
The local executor appends a started line for each paper before its chunk
runs. A run killed outright — the out-of-memory killer, a native crash in an
OCR or layout library — leaves that line without a verdict, and the next run
counts it as a crash: it runs each such paper again in a chunk of its own,
after all the others, so a paper that kills the run takes neither its chunk
neighbours nor the rest of the batch with it, and waits for --retry-failed
once it has done so three times.
Every attempt appends a new verdict line with an incremented attempt
counter (a local run writes the attempt's started line first, with the same
counter) — nothing is ever rewritten, so outcomes.jsonl is a full history.
A run killed mid-write (out of memory, a full disk) can leave a torn last
line; it is skipped with a warning and the next run starts on a fresh line.
One run at a time uses an out dir: a run holds an exclusive lock on
<out>/.lock (released when it ends, also when it is killed), and a second
run on the same --out — an overlapping cron job, say — stops at once with
"another bibr batch run is using <out>" and exit code 2. --dry-run and
bibr batch report do not take the lock.
Ctrl-C is graceful in both executors: the local executor records the chunk
that was running as interrupted; the remote executor stops submitting,
waits up to 30 s for in-flight jobs, cancels the ones still queued on the
serve and records the rest as interrupted.
A second Ctrl-C exits immediately. SIGTERM and SIGHUP (kill, docker stop,
a closed SSH session) are handled the same way, so managed local servers are
shut down rather than left holding GPU memory. The process exits 130 when
interrupted (by Ctrl-C, SIGTERM or SIGHUP alike), 1 when any paper failed, 0
otherwise.
Local vs remote
Local (the default) builds one warm pipeline (bibr.api.Chewer) and feeds
it chunks of --batch-size files (auto-sized from the memory mode like
bibr chew). Inside a chunk the pipeline is stage-major — every file's
layout, then every file's OCR, and so on — so models load once and OCR batches
fill up. Each paper's started line is written as its chunk begins, its
verdict line as the chunk finishes. All of bibr chew's
pipeline flags apply: --ocr, --ocr-url, --llm, --refs, --ref-seg,
--consolidate, --no-crossref, --no-llm, --pages, --memory,
--device, --figure-images, --include-regions, --preset, …
After each chunk the local executor returns the memory the chunk freed to the
system (malloc_trim, Linux with glibc only). Without that, freed page images
stay in the allocator and a long run keeps every chunk's high-water mark.
PIPELINE_HEAP_TRIM sets when this happens: chunk (the default), window
(also between the render windows inside a chunk: a lower footprint with a large
--batch-size, at a speed cost; a 24-paper chunk ran 20-30% slower) or
off.
Remote (--serve-url URL) submits papers to a
bibr serve async job API (POST /papers/jobs, poll
GET /papers/jobs/{id}, fetch …/result). The bearer token comes from
--token, else AUTH_API_KEY / BIBR_SERVE_TOKEN in the environment, else
AUTH_API_KEY in bibr's .env. The token rides every request, so a plain
http:// serve URL is accepted only for a loopback or private-network host
(a LAN or tailnet address, a single-label name such as gpu-box, or a
.local/.internal/.lan/.ts.net name); a public host needs https://
or an explicit --allow-insecure-http. Plain http:// to a LAN host still
shows the token to anyone on that network, so the run warns about it; a TLS
reverse proxy, an SSH tunnel (ssh -L 8000:127.0.0.1:8000 gpu-box, then
--serve-url http://127.0.0.1:8000) or a tailnet address avoids that. The
run waits for GET /ready first (up to --ready-timeout) and records the
serve's build_sha in every ledger line.
The options the job API accepts are passed through — --refs, --ref-seg,
--consolidate, --pages (as start_page/end_page), --figure-images,
--include-regions — plus arbitrary --form k=v fields; the pipeline flags
that configure a local pipeline are ignored with a warning (the serve's own
settings apply).
Concurrency adapts to the serve:
--concurrencyjobs start in flight (default 2).- A 429 on submit is the serve's queue cap (
JOBS_MAX_ACTIVE) — normal under load. In-flight drops to--min-concurrencyand the submit waitsRetry-After; it is never counted as a failure. - 502/503/504 answers to a submit or poll (an open circuit breaker on
the serve answers 502), connection errors, and a job the serve failed
because a service or model it needs was down (OCR server unreachable, a
layout model or LLM server that could not start: the job's error says
"outage": true) are transient: in-flight shrinks by one and the paper is retried with backoff up to--retriestimes (default 3). If it never recovers, the last transient code is recorded, and the next run picks the paper up again. - Every success grows in-flight by one, back toward
--max-concurrency. - A job the serve failed with 504 ran out of the serve's
PIPELINE_TIMEOUT, which counts the wait for a free pipeline slot as well as the run: the serve is up and the paper was too slow. It is submitted once more (the serve's OCR and LLM are shared with other jobs) without shrinking in-flight, then recorded aspipeline_timeout. - Other 4xx answers are the paper's own problem — recorded once, no retry. A
401/403 stops the whole run; the papers it failed are picked up again once
the token is fixed. An LLM response truncated at its token limit or rejected
by validation is a 422 with
error_codellm_truncatedorllm_invalid_output, since the same request fails the same way again. --poll-timeout(default 2400 s) bounds one paper's wall clock; expiry is recorded aspoll_timeoutwithout a retry.- A job that vanished from the serve — while polled, or after it succeeded
but before its result was fetched (results expire and are evicted) — is
re-submitted like any transient failure and recorded as
job_lostif it never comes back. - A job the run walks away from (re-submitted after errors, past
--poll-timeout, or still in flight after Ctrl-C) is cancelled withDELETE /papers/jobs/{id}, so a job still queued never runs for nothing or holds aJOBS_MAX_ACTIVEslot. The serve can only cancel a queued job: one already running finishes, unread. - An export of the current schema major that the lenient reader rejects is
recorded as
invalid_remote_exportand not written to<out>.
The serve dispatches at most JOBS_MAX_RUNNING jobs at once, so a client
in-flight much above that only lengthens the queue.
Output layout
<out>/
<paper_id>.json the export, one per successful paper
tables/*.parquet every successful paper as one Parquet file per table
outcomes.jsonl the ledger — a verdict line per attempt (local: a started line first)
run_info.json the latest run: options, executor, redacted settings, counts
runs.jsonl run_info of every run, appended
.lock held by the running run
Each export's paper_id is the batch's own id, the name of its JSON file, so it
is unique across the corpus even when papers in different directories share a
file name. tables/ is rebuilt from every paper whose latest attempt is ok
at the end of each run (--no-tables skips it), unless it was already built
from exactly those exports (tables/.batch-source records which), so a
resumed run with nothing new does not re-read the corpus; see the
Python guide for its layout. An export of
another schema major, left in <out> by an older bibr, is left out of the
tables with a warning; --force re-runs every paper under the current schema.
For a reference manager, bibr references <out>/ -o refs.bib writes the
reference lists of every export in <out> to one BibTeX file, with cite keys
unique across it; a .ris name writes RIS and a .json name CSL-JSON.
run_info.json is not an export, so it is skipped with a warning. The
Python guide describes which values are written.
run_info.json carries the run_id that stamps this run's ledger lines,
started_at/finished_at, the invocation (options, without the token),
bibr_version, build_sha (the serve's for remote runs, the checkout's git
head or BIBR_BUILD_SHA locally), the resume counts, the shuffle seed, the
collision map, and settings — every non-default bibr setting as
bibr config show renders it, secrets masked.
Ledger schema
One JSON object per line of outcomes.jsonl:
| Field | Type | Meaning |
|---|---|---|
paper_id |
str | export name (<out>/<paper_id>.json); the file stem, sha-suffixed on collisions |
stem |
str | the original file stem |
path |
str | input path as given |
sha256, bytes |
str, int | identity and size of the input |
status |
ok / failed / started |
started lines are described below |
error_code |
str / null | see below |
failed_stage |
str / null | pipeline stage that failed, when known |
error |
str / null | error text, clipped to 800 characters |
started_at, finished_at |
ISO-8601 UTC | for local runs the chunk's start/end |
duration_s |
float | remote: submit-to-result wall clock; local: the export's own pipeline time, else the chunk's |
pipeline_seconds |
float / null | extraction.timings.total_seconds from the export |
stage_times |
{stage: seconds} / null | extraction.timings.stages from the export |
llm_tokens, llm_input_tokens, llm_output_tokens |
int | from extraction.usage.totals (legacy: llm_usage) |
n_refs, n_matched |
int | bib rows and accepted bib_match rows (falls back to extraction.enrichment.refs_enriched) |
n_sentences |
int | text rows |
warnings |
{count, first, codes} |
extraction.warnings (legacy: processing_warnings): total, the first three (as CODE: message), and a frequency map by warning code |
n_validation_errors, n_validation_warnings |
int | from the export's validation block |
bibr_version, build_sha |
str | producing bibr; remote runs record the serve's build |
executor |
local / remote |
|
attempt |
int | 1 for the paper's first attempt, +1 per further attempt; a started line and its verdict share one |
run_id |
str | the run that wrote the line (matches run_info.json) |
job_id, retries, http_status, transient_exhausted |
remote only | serve job id, transient retries used, the failure's HTTP status |
A local run also writes a started line for each paper just before its
chunk runs: paper_id, stem, path, sha256, bytes,
status: "started", started_at, bibr_version, build_sha, executor,
attempt and run_id. The verdict line (ok or failed) that follows
repeats its attempt; a started line with no verdict after it is a run
killed mid-paper (see Resumability). bibr batch report, the
tables and Ledger.read() see only verdict lines; when you read
outcomes.jsonl yourself, drop the started lines first
(df[df.status != "started"]). An older bibr resuming such a ledger treats
a killed paper as failed: it waits for --retry-failed.
error_code values: locally, the pipeline's own code (ChewFailure.error_code, e.g.
an OCR or reference-parse code) or processing_error, upstream_unavailable
(an OCR or LLM service was down or could not start, or a required classifier
failed to load: ChewFailure.outage),
chunk_error (the pipeline crashed on the paper: a chunk that crashes runs its
papers again one by one, so only a paper that crashes on its own gets it, or
the whole chunk when the pipeline could not run at all), interrupted;
in both executors, output_write_failed (the export could not be written to
<out>: a full disk, a permission, a value JSON cannot hold);
remotely, the serve's error_code when it gave one, else http_<status>,
connection_error, upstream_unavailable, job_lost,
submit_wait_exhausted, pipeline_timeout, poll_timeout,
bad_submit_response, bad_result_json, invalid_remote_export,
client_error, unreadable_input, interrupted.
Reading the report
bibr batch report <out> (or --report, and --json for the same as JSON)
prints one compact table; every run ends with the same table for its own
lines:
bibr batch report · results/
papers 412 ok · 9 failed · 421 total (430 attempts)
window 2026-09-02T10:00:00+00:00 → 2026-09-03T04:12:31+00:00 (18.21 h)
throughput 22.6 papers/h
latency p50 128.4s · p90 301.0s · max 812.7s (n=421)
stage share ocr 63% · extract 24% · enrich 8% · parse 3% · layout 2%
llm tokens 41,220,118 total · 100,049 / paper
references 19,870 refs · 17,102 matched (86%) · 48.2 / paper
failures poll_timeout ×5 · http_413 ×3 · OCR_EMPTY ×1 | stage: ocr ×1
warnings 140 papers · OCR_REGION_FAILED ×212 · STATEMENT_LEXICAL_FALLBACK ×31
- papers counts the latest attempt per paper; attempts is the
number of verdict lines (a resumed run's earlier failures are history, not
state;
startedlines are not counted). - throughput is successful papers over the window from the earliest
started_atto the latestfinished_at— for a directory that holds several runs, that window spans all of them. - latency percentiles are over successful attempts'
duration_s. - stage share is each stage's share of the summed stage time across successful attempts — where the pipeline spends its time, not a per-paper mean.
- references is the corpus match rate (
n_matched / n_refs). - failures groups the latest-failed papers by
error_codeand, when the export reported it, byfailed_stage. - warnings lists the ten most frequent warning codes
(
extraction.warnings[].code). An export older than 12.0 has prose warnings, counted by the text before the first colon (VALIDATION:<severity>:<CODE>for validation findings).
The ledger is plain JSONL, so anything else is one pandas.read_json(...,
lines=True) away.