๐Ÿ“ฆ EqualifyEverything / equalify-iris-bench

Corpus-scale benchmark harness for Iris: success rate, latency and cost over a large list of real-world PDF URLs.

โ˜… 0 stars โ‘‚ 0 forks ๐Ÿ‘ 0 watching โš–๏ธ GNU Affero General Public License v3.0
๐Ÿ“ฅ Clone https://github.com/EqualifyEverything/equalify-iris-bench.git
HTTPS git clone https://github.com/EqualifyEverything/equalify-iris-bench.git
SSH git clone git@github.com:EqualifyEverything/equalify-iris-bench.git
CLI gh repo clone EqualifyEverything/equalify-iris-bench
Blake Bertuccelli-Booth Blake Bertuccelli-Booth fix(prepare): stop keeping 5.4 GB to show 265 MB of corpus 97a9161 26 days ago ๐Ÿ“ History
๐Ÿ“‚ 97a91612dc9a3601c491ddeb220f805b279212c5 View all commits โ†’
๐Ÿ“ src
๐Ÿ“ test
๐Ÿ“„ .env.example
๐Ÿ“„ .gitignore
๐Ÿ“„ example.csv
๐Ÿ“„ LICENSE
๐Ÿ“„ package-lock.json
๐Ÿ“„ package.json
๐Ÿ“„ README.md
๐Ÿ“„ README.md

equalify-iris-bench

A benchmark harness for Iris. Give it a CSV of PDF URLs; it tells you how often Iris succeeds, how long it takes, and what it costs per page โ€” and leaves behind the per-document artifacts needed to work out why the failures failed.

It lives outside equalify-iris on purpose. This is a client of the public API, it has dependencies and gigabytes of cached PDFs that the service should not carry, and it needs to be pointable at any deployment โ€” including the live one โ€” without a release.

Why these three numbers

  • Success rate โ€” how much of an unfiltered real-world corpus Iris can actually process.
Reported twice: over documents submitted, and over every URL the CSV contained. The second number is lower and is the one an outside claim has to survive, because 404s, login walls served as 200 application/pdf, and encrypted files are part of the world.
  • Time โ€” as percentiles, per document and per page. The tail is what decides how long a
campaign takes and it is where the timeouts live; the mean hides it. Three clocks are captured separately: caller wall time, the server's own elapsed_ms, and the queue wait between them, which is the cost of concurrency rather than of the document.
  • Cost โ€” from the token counts Iris reports per session, multiplied by a rate table that
carries a date and a source. Iris deliberately reports tokens and never dollars: the price of a token depends on the provider, region and model, all of which are deployment config. So cost is computed here, at a stated date, under stated assumptions.

Accuracy, and what "accuracy" can mean here

There is no ground truth. Nobody has hand-authored the correct accessible HTML for 2000 PDFs, so this harness does not measure agreement with a reference. It measures the conjunction of Iris's own pipeline signals with one independent check:

SignalSourceWhat a bad value means
Final axe violationsre-linted locally by src/lint.mjsthe delivered document has accessibility defects
iterations, unresolvedrun_completethe review loop hit its cap and gave up with issues open
editor_links_droppedrun logthe copy editor deleted links; unrecoverable and invisible downstream
assembly_anchorsrun logid collisions, ambiguous cross-references, pages left as written
page_verify_failed, specialist_declinedrun logper-page extraction problems the pipeline reported but did not fix
headings / chars-per-pageshape() in src/lint.mjsa clean lint on a nearly empty document โ€” the failure no rule catches
The local re-lint exists because of a real gap: Iris lints during the review loop (visible in log.jsonl) and again after it, but the final lint โ€” the one describing the document actually handed back โ€” is written to the deployment's aggregate quality tally and has no per-session endpoint. src/lint.mjs is therefore a deliberate rule-for-rule port of the deployment's src/pipeline/lint.ts, and package.json pins axe-core and jsdom to exact versions rather than ranges, because which axe rule claims which element is an internal that a version bump can move. **If Iris's lint config or axe version changes, update src/lint.mjs in the same breath** โ€” otherwise this stops measuring Iris and starts measuring a different linter.

Requirements

  • Node 22+ (24 recommended โ€” the same runtime Iris uses).
  • poppler-utils for pdfinfo (required): brew install poppler / apt install poppler-utils.
  • qpdf (strongly recommended): brew install qpdf / apt install qpdf. Without it, PDFs
over the deployment's page cap cannot be split, which drops long documents from the corpus and biases every result toward short ones. pdfseparate + pdfunite are used as a fallback.
  • A GitHub token the target deployment accepts. Iris has no API keys and no anonymous mode โ€”
the token is the identity that a session's feedback is filed under. src/login.mjs runs the device flow for you.

Getting started

git clone https://github.com/EqualifyEverything/equalify-iris-bench && cd equalify-iris-bench
npm install
npm test                        # proves the harness works: no token, no network, no deployment

cp .env.example .env                  # then set IRIS_BASE_URL if not the UIC deployment
node src/login.mjs --write-env .env    # approve a code in the browser; fills in IRIS_TOKEN
                                       # omit --write-env to print the token instead

Start with a handful of URLs, not the whole list. Ten is enough to prove the loop end to end and to learn what a document costs:

printf 'pdf_url\nhttps://example.org/a.pdf\nhttps://example.org/b.pdf\n' > small.csv
node --env-file=.env src/prepare.mjs --csv small.csv
node --env-file=.env src/run.mjs
node src/report.mjs

The report's usd_per_page and ms_per_page from that first handful are what size everything after it: multiply by the corpus's page count for the bill, and by pages รท 2 for the wall clock at the deployment's current concurrency.

Usage

Any column of URLs works โ€” the column is chosen by name and the choice is logged rather than assumed. See example.csv for the shape.

# 1. Download, inspect, classify, split. Idempotent and resumable.
node --env-file=.env src/prepare.mjs --csv urls.csv

# 2. A 25-document pilot first. Always.
node --env-file=.env src/run.mjs --limit 25

# 3. Read the pilot before spending days of wall clock.
node src/report.mjs

# 4. Then the rest, in stages.
node --env-file=.env src/run.mjs --limit 200
node --env-file=.env src/run.mjs

Tokens are GitHub user tokens and can expire before a multi-day campaign finishes. A refused token stops the run rather than failing the rest of the corpus against it, and nothing is written to the ledger for the items it never attempted โ€” so node src/login.mjs and the same run.mjs command resume from exactly where the old token stopped being accepted.

Stage 1 โ€” prepare.mjs

Iris does not fetch URLs (POST /v1/sessions is multipart only), so every file is downloaded client-side, then screened before anything is submitted. A URL that turns out to be an HTML error page costs one download here; discovering it during the run costs a session, a concurrency slot, and a place in the failure statistics.

Each URL lands in prepared.jsonl with a class: ok, duplicate, download_failed, download_stalled, download_timeout, tls_failed, too_large_bytes, not_pdf, pdfinfo_failed, encrypted, oversize_pages, prepare_error. PDFs are detected by the %PDF- magic bytes, not by content-type โ€” servers hand out PDFs as application/octet-stream and sign-in pages as application/pdf, and only one of those is visible from a header. The runnable subset is rewritten to corpus.jsonl on every pass.

Documents over the deployment's max_pages are split into cap-sized chunks rather than dropped, and labelled (parent_sha, page_from, page_to) so the report can keep them in a separate population โ€” a chunk's review score isn't comparable to a whole document's, because the reviewer saw a document with no beginning. At most --max-chunks (default 4) chunks come from any one PDF; the dropped tail is recorded and printed, because a bound that doesn't announce itself reads as "we covered everything".

FlagDefault
--csv(required)any column of URLs; the chosen column is logged, not assumed
--concurrency8parallel downloads
--max-download-mb200disk guard; larger files are recorded as skipped
--max-chunks4cap-sized chunks per oversize PDF
--stall-sec45give up after this long with no bytes at all
--total-sec1800backstop for a server that dribbles forever
--retryre-attempt URLs that failed the fetch (see below)
--splitterqpdf if installedforce qpdf or poppler (see below)
--limitprepare only the first N new URLs
Install qpdf. Both splitters work, but they are not interchangeable: pdfseparate + pdfunite copies the source's entire shared resource set into every slice, so a 25-page cut of the 1,004-page Texas appropriations act came out at 477 MB from a 34 MB source. qpdf subsets properly. Every chunk is measured against the deployment's max_request_bytes before it can be submitted and recorded as chunk_too_large if it doesn't fit โ€” per chunk, so the pages that did fit still run โ€” and then deleted, since its size is the only useful thing about it. That class is retryable, because the fix is on this side.

Slow is not failed. Document servers are slow โ€” 35 MB at 40 KB/s is a fifteen-minute download that is working perfectly โ€” so the stall clock is what detects failure, and it is re-armed on every chunk received. A single flat deadline cannot tell a hung transfer from a large one, and set short enough to catch the former it discards every big PDF on a slow host, biasing the corpus toward small files on fast servers.

The four fetch classes (download_failed, download_stalled, download_timeout, tls_failed) say something about the transfer, not the document, and each is recoverable by changing a setting. prepared.jsonl is append-only and normally skips any URL already in it, so --retry is what re-attempts those โ€” settled verdicts (ok, not_pdf, encrypted, duplicate, too_large_bytes) are left alone. Only a URL's most recent attempt is counted, by prepare and by the report.

tls_failed usually means a host serving an incomplete certificate chain โ€” something browsers paper over by fetching the missing intermediate and Node doesn't. Retry those with node --use-system-ca src/prepare.mjs ..., which widens trust to the OS store. It's opt-in rather than the default because widening a trust store shouldn't happen quietly.

Nothing here hardcodes a page cap or an image ceiling โ€” they come from GET /v1/limits at run time, which is the whole reason that endpoint exists. The one exception is the rasterization DPI behind predicted_max_edge_px, which Iris does not publish; it only ever produces a risk flag, never an exclusion, so a stale guess costs a failed run rather than a missing document.

Stage 2 โ€” run.mjs

Submits, polls to a terminal state, and captures status.json, log.jsonl, diagnostics.json and output.html per document โ€” including for failed runs, whose logs are the only account of why they failed and whose diagnostics still carry the tokens they spent getting there. A terminal outcome per item is appended to runs/ledger.jsonl, so an interrupted campaign resumes where it stopped.

Throughput is bounded by the deployment's max_concurrent_runs (2 by default), not by the rate limiter โ€” a conversation takes minutes. --concurrency defaults to 2 to match, so the harness cannot be the reason the numbers look bad; going higher doesn't run faster, it just moves the wait into Iris's queue and shows up as queue_wait_ms. Submissions are additionally spaced to stay inside the published upload_per_minute, and 429s honour Retry-After. A 400 is not retried: it's a verdict about the file, and it's recorded as a result.

FlagDefault
--limitrun only the first N unrun items โ€” how staging is done
--concurrency2match the deployment's max_concurrent_runs
--poll-ms5000status poll interval
--timeout-ms2700000client give-up, deliberately above the provider's own 15-minute backstop
--closeoffPOST /sessions/:id/close on success โ€” see below
--redooffignore the ledger and re-run everything
--close is off by default and should stay off for a large campaign. Closing a session finalizes it and captures regression fixtures server-side; on 2000 documents that is 2000 fixture captures against a deployment that has processed seventeen documents in its life. That should be a deliberate decision, not a side effect of measuring.

Stage 3 โ€” report.mjs

Writes results.jsonl (one row per document, the thing to query) and summary.json, prints a human summary to stderr and the JSON summary to stdout, so it can be piped into jq while it narrates.

FlagDefault
--runsrunsartifact directory
--ratesJSON overrides for the $/MTok table, merged over the built-in one
--no-lintoffskip the local axe re-lint (minutes of CPU at corpus scale)
--preparedprepared.jsonlfor the end-to-end denominator
Failure messages are grouped by shape, with request ids and numbers masked, so a provider error carrying a page number is one class of failure rather than two hundred. Every cost figure names the rate that produced it, its source and the date it was checked; an unrecognized model costs null, is excluded, and is counted loudly โ€” "we swapped the model and the corpus suddenly looks free" should not be able to happen quietly.

Tests

npm test runs all three stages against a stub of the Iris API (test/stub.mjs), over a hand-written multi-page PDF fixture โ€” no token, no network, no queue. It asserts the things a long unattended campaign can get quietly wrong: that a 7-page PDF against a cap of 3 becomes 3+3+1 with no page dropped or double-counted, that a URL counts as covered only when every chunk of it came back, that a failed run's tokens are still counted, that an unpriced model yields null rather than $0, and that failure messages collapse by shape.

test/lint.test.mjs additionally runs both linters โ€” this repo's port and the deployment's own src/pipeline/lint.ts โ€” over the same fixtures and requires identical verdicts. That comparison needs an equalify-iris checkout; it looks for one at ../equalify-iris and honours IRIS_REPO=/path/to/equalify-iris. Without one it falls back to asserting the specific rule ids the config is supposed to produce, and says it skipped the comparison.

Cost figures are estimates

The live deployment runs on Amazon Bedrock, which is partner-operated and billed through AWS at AWS's rates for the region in use. The built-in table carries Anthropic's first-party rates as a documented stand-in and flags every Bedrock-prefixed model id as estimate_only. Treat the dollars as an order of magnitude for comparing documents against each other, and settle real numbers against the invoice โ€” or pass --rates once you have them.

Before a large campaign

  • Raise max_concurrent_runs on the target deployment if you want the corpus finished this
week. At the default of 2 and a few minutes per document, 2000 documents is days.
  • Decide what to do about agent-suggestion issues. A completed run can file GitHub issues
upstream; across a corpus that is a lot of issues. The report counts what was filed (agent_issues_filed) but cannot un-file it.
  • Run 25 first, and read the report. Per-page cost and per-page latency from the pilot are
what tell you whether the full corpus is an afternoon or a fortnight, and how much it costs.