Skip to content

Benchmarks

Reproducible numbers. Re-run any row with the command in its "How" column.

Last verified: 2026-10-02. Commit SHAs for each run are in the session evidence in docs/PROGRESS.md.

Ranking quality (synthetic eval, CI-enforced)

make eval (backend/scripts/rank_eval.py; gate: PRS ≥ chronological + 0.05)

Ranker NDCG@10
PRS (learned) 0.7784
Semantic-only 0.6365
Chronological 0.6269
Random 0.6122

Also CI-asserted: exploration/IPW recovery under position bias, diversity cap never exceeded, explanation contributions sum to the score (1e-6), label table per A4.

Extraction quality (golden corpus, offline)

make extract-eval (100 generated pages: 80 articles, 20 listings; gate: article word-F1 ≥ 0.90 and snippet recall ≥ 0.90)

Metric Result
Article word-F1 ≥ 0.90 (gate)
Snippet recall ≥ 0.90 (gate)
Extraction success (articles) ≥ 95%

Extraction on real pages (#230)

52 real published pages (13 each from the Go blog, the Rust blog, the Kubernetes blog and Wikipedia), with ground truth taken from each page's own Markdown or plain-text source in a public, permissively licensed repository (licences per page in backend/tests/fixtures/real_pages/manifest.json). Collected 2026-10-20 with backend/scripts/collect_real_pages.py; scored by evaluate_real() and gated in test_real_page_extraction.py.

Source Pages Precision Recall Word F1
Go blog 13 0.970 0.997 0.983
Rust blog 13 0.995 0.999 0.997
Kubernetes blog 13 0.992 0.994 0.993
Wikipedia 13 0.860 0.997 0.922
All 52 0.954 0.997 0.974

Nothing a reader needs is lost (recall ≥ 0.99 everywhere). Wikipedia was 0.641 precision / 0.774 F1 until #319, because MediaWiki reference lists and [1] citation markers were extracted as article text; they are now removed before extraction (generic footnotes and data tables are kept). The rest of the Wikipedia gap is data tables: real content, but the plain-text ground truth leaves them out. The sample covers three static-site generators and MediaWiki, not yet WordPress, Ghost or Substack.

Retrieval / embedding decision (IQ-02)

make retrieval-eval (200 fixture pairs; ADR 0004)

Input MRR
Full text 1.00
8-token prefix (MiniLM truncation) 0.75

Starter-pack liveness (CS-02)

make starter-pack-check (weekly CI gate ≥ 90%)

Measure Result (2026-10-02)
Packs / feeds 21 / 211
Live feeds 211/211 (100%)

First-digest quality gate (CS-05)

CI (pytest test_first_digest_gate.py, 5 personas × real builder): ≥ 5 items, ≥ 3 clusters, ≥ 1 discovery, 0 duplicates, ≥ 80% summaries: all pass.

Resource profile (compose)

Profile Services Notes
lite (default) 8 No Browserless; hash embeddings; search = Postgres FTS+pgvector
full +2 Browserless (rendered extraction) + egress proxy

Idle RSS measured 2026-09-24: db ~78 MiB, redis ~20 MiB. Backend full image includes torch (CUDA wheels on arm ≈ 454 MB extra); :latest-lite image skips torch/sentence-transformers entirely (RL-01).

Lighthouse (#216, #315)

Lighthouse 12 (simulated mobile) on a production build (next start) against the compose API, Apple M4 / 16 GB, headless Chromium, 2026-10-20. Ranges are over 2 to 3 runs. Landing is measured as an anonymous visitor (signed-in users are redirected to their digest). Reproduce with frontend/scripts/lighthouse.sh <content_id>. Lighthouse 12 has no PWA category; offline reading is covered by e2e-stack/offline-pwa.spec.ts.

Page Performance Accessibility Best practices SEO
Landing /, before #315 81 to 84 100 79 100
Landing /, after #315 89 to 91 100 100 100
Reader /read/<id> (signed in) 86 to 87 95 96 66

315 removed every third-party request from the landing page (fonts

self-hosted with next/font; hero images served from this origin as WebP; the proxy had been sending a 4.3 MB PNG) and made the headline paint with the first frame instead of after its fade-in delay.

Reader, not counted as defects or tracked elsewhere: - SEO 66 is is-crawlable: /read/ is disallowed in robots.txt on purpose (private, signed-in pages). - Accessibility 95: in OS dark mode the article text is near-invisible, #316. - Best practices 96: a fresh test account has no digest yet, so the reader's next/previous lookup (/digest/latest) logs a 404. - Performance: real paint is ~150 ms; the simulated LCP counts the article being rendered client-side after its API call.

Digest build at 1,000 candidates and 1,000 users (#192)

backend/scripts/bench_digest_build.py on a throwaway database: 1,000 fresh items across 200 shared feeds (384-d embeddings around 24 topic centres), 1,000 onboarded users following every feed with 6 interests each. In process, sequential, no Celery. Apple M4 / 16 GB, Postgres 16 + pgvector in Docker, 2026-10-20.

Measure Result
rank() on 1,000 candidates, one user p50 166 ms, max 300 ms (5 runs)
Full build_digest() per user (x1,000) p50 1.61 s, p95 3.18 s, max 4.82 s
1,000 builds, one process 30.5 min total

The ranker is a small share of a build; most of the time is the queries and writes around it (impressions, stories, preferences). p95 is just over the 3 s budget; part of the run overlapped another local test run, so treat the tail as approximate. Daily builds spread over each user's send time and the digest workers, so 1,000 users is ~30 min of single-worker time per day.

The first 1,000-user run crashed: every subscriber's source row for the user's feeds was bound as its own SQL parameter, and past asyncpg's 32,767 limit (here 200 feeds x 1,000 users) every digest build failed. Fixed in the same change (ids bound as one array).

Candidate window (#313): builds used to rank an arbitrary 200 window items (no ordering). They now rank the newest items, at most DIGEST_PER_SOURCE_CAP per feed, up to DIGEST_CANDIDATE_LIMIT (1,000). Story clustering was pure-Python and took ~12 s at 1,000 candidates; it is now one matrix product per item. Same machine, 50 users, 400 feeds x 3 items (1,000 candidates after the per-feed cap):

Builder Candidates ranked p50 p95
Before (#313) 200, arbitrary 808 ms 907 ms
After 1,000, newest per feed 429 ms 476 ms

Hybrid search at 100,000 items (#231, #311)

backend/scripts/bench_search.py on a throwaway database: one user's archive of 100,000 items in 300 feeds, GET /api/v1/search called in process (ASGI) for 20 queries x 5 rounds after a warm-up, every query text unique so the query-vector cache never helps. Apple M4 / 16 GB, Postgres 16 + pgvector in Docker, 2026-10-20. Budget: p95 < 300 ms.

Measure Result
p50 18 ms
p95 29 ms
max 108 ms
MiniLM query encode alone (uncached) p50 3.6 ms, p95 4.9 ms

Both legs ran (Postgres FTS on search_tsv, pgvector HNSW), fused with RRF; every query returned a full page of 20. Items and queries are embedded by the same MiniLM model. The first #231 run (p95 52 ms) used hash vectors on both sides, because search embedded queries with the hash fallback even on the standard image; #311 fixed that, and these numbers include real query inference.