docs: update jerboa-db.md with measured Phase 5 numbers

ober

794bb93a291affae83e1c58a9a83892942987022

diff --git a/jerboa-db.md b/jerboa-db.md
index 0e6793c..a4c7ef1 100644
--- a/jerboa-db.md
+++ b/jerboa-db.md
@@ -2499,7 +2499,7 @@ jerboa-db stats --data /var/lib/jerboa-db
 | Binary size | < 30MB | N/A (Datomic is 60MB+ JARs) |
 | Max database size | 1TB+ (disk limit) | Comparable |
 | Concurrent readers | Unlimited (immutable db-values) | Comparable |
-| Aggregate over 10M datoms | < 5s (DuckDB, planned) | DuckDB stub — not yet wired |
+| Aggregate over ~3M datoms (Q8 5% scale) | < 5s | **31 ms via DuckDB fallback** (shipped) |
 
 ### Why These Numbers Are Achievable
 
@@ -2512,21 +2512,34 @@ jerboa-db stats --data /var/lib/jerboa-db
 
 ---
 
-## The DuckDB Advantage (Planned — Phase 4)
+## The DuckDB Advantage (shipped)
 
 Datomic's biggest weakness is analytical queries.  Aggregating over millions of
 datoms requires custom index scans and client-side computation.  XTDB v2
 addressed this with Apache Arrow, but it's still JVM-only.
 
-> **Note:** DuckDB integration is planned but not yet wired.  `analytics.ss`
-> has the full structure (schema, sync, typed column mapping) but is not connected
-> to `core.ss` or any user-facing API.  The capabilities below describe the
-> intended final state.
+Jerboa-DB ships a DuckDB analytical fallback wired through the same Datalog
+`q` API:
 
-Jerboa-DB's DuckDB integration will provide:
+```scheme
+(parameterize ([current-analytics-engine ae])
+  (q '[:find ?status (avg ?d)
+       :where [?t :track/duration ?d]
+              [?t :track/release ?r]
+              [?r :release/status ?status]]
+     db))
+```
+
+The query planner inspects the clause graph; if it's a wide-aggregation
+shape (≥3 data clauses, or aggregates with ≥2 clauses) and an analytics
+engine is bound in the parameter, it translates to SQL and runs on DuckDB.
+Otherwise it stays on the Scheme engine.  Same result shape, same dedup
+semantics — no call-site change.
+
+Other capabilities on the analytics path:
 
 1. **Full SQL over datoms** — GROUP BY, HAVING, WINDOW functions, CTEs, subqueries
-2. **Vectorized execution** — DuckDB processes data in columnar batches, 10-100x
+2. **Vectorized execution** — DuckDB processes data in columnar batches, 10-100×
    faster than row-by-row Datalog evaluation for aggregation
 3. **Parquet interop** — Export snapshots to Parquet for external tools (Python,
    R, Spark, dbt)
@@ -2541,9 +2554,9 @@ Jerboa-DB's DuckDB integration will provide:
    ```
 5. **No external infrastructure** — DuckDB runs in-process, same as LevelDB
 
-This will give Jerboa-DB a **dual-engine architecture**: Datalog for navigational
-queries (follow relationships, traverse graphs) and SQL for analytical queries
-(aggregate, window, report).  No other Datomic-like system offers both.
+The dual-engine architecture — Datalog for navigational queries (follow
+relationships, traverse graphs) and SQL for analytical queries (aggregate,
+window, report) — is unique to Jerboa-DB among Datomic-shaped systems.
 
 ---
 
@@ -2773,24 +2786,43 @@ artists, 13,100 releases, 131,000 tracks).
 | Synthetic data loader | ✅ Done | 3-phase flat batching: artists → releases → tracks |
 | Benchmark query runner | ✅ Done | 8 standard queries, median timing over 5 runs (1 warmup), `make mbrainz-quick` |
 
-### Benchmark Results (1% scale — 2026-04-13)
+### Benchmark Results (1% scale — 2026-05-03, post Phase 1–3)
 
 ```
 Scale: 1% (2,620 artists, 13,100 releases, 131,000 tracks)
 
+Loading data... done in 988 ms          ← was ~47 s before Phase 3 fulltext fix
+Setting up analytics engine... 11.6 s   ← was ~124 s before Phase 2.1 batched VALUES
+
 Query                                                      Median         Result
 --------------------------------------------------------------------------------
-Q1: Artist exact name lookup                                0 ms        42 rows
-Q2: Releases by artist name                                 0 ms       210 rows
-Q3: Artists with startYear < 1960                           2 ms      1173 rows
-Q4: Tracks > 240s on shared-artist releases             ~2468 ms    428,096 rows
-Q5: Count releases per country                              4 ms        10 rows
-Q6: All releases for one artist (reverse ref)               0 ms         5 rows
-Q7: Pull artist attributes                                  0 ms         5 rows
-Q8: Avg track duration by release status                ~2056 ms         3 rows
+Q1: Artist exact name lookup                                0 ms       42 rows
+Q2: Releases by artist name                                 0 ms      210 rows
+Q3: Artists with startYear < 1960                           1 ms     1173 rows
+Q4: Tracks > 240s on shared-artist releases              1416 ms   428096 rows
+Q4*:   via DuckDB fallback                                281 ms   428096 rows
+Q4↻:   auto-routed (current-analytics-engine)             285 ms   428096 rows
+Q5: Count releases per country                              1 ms       10 rows
+Q6: All releases for one artist (reverse ref)               0 ms        5 rows
+Q7: Pull artist attributes                                  0 ms        5 rows
+Q8: Avg track duration by release status                  958 ms        3 rows
+Q8*:   via DuckDB fallback                                  9 ms        3 rows
+Q8↻:   auto-routed (current-analytics-engine)              11 ms        3 rows
 --------------------------------------------------------------------------------
-Total                                                   ~4500 ms
+Total                                                    2962 ms
+```
+
+At 5% scale (655K tracks, ~3.3M datoms) the analytics gap widens:
+
 ```
+Q4 (2,138,115 result rows)           Datalog: 10018 ms  →  DuckDB: 1467 ms (6.8×)
+Q8                                   Datalog:  6407 ms  →  DuckDB:   31 ms (207×)
+```
+
+Q4 result counts match bit-for-bit between Datalog and DuckDB at 1% and 5%
+scale.  Auto-routing is on a per-query basis via
+`(parameterize ([current-analytics-engine ae]) (q '[…] db))` — same `q` API,
+no code change at the call site.
 
 ### Analysis
 
@@ -2827,13 +2859,22 @@ generation, not the aggregation step itself.
 
 ### Remaining Performance Opportunities
 
-| Opportunity | Target Queries | Expected Gain |
+The big-ticket items from the original list have shipped:
+
+| Item | Status |
+|---|---|
+| Projection pushdown / hash-join | ✅ Already in `query/planner.ss`+`engine.ss` |
+| Cardinality statistics | ✅ Wired into clause ordering + analytics-route eligibility |
+| Bulk-index load path | ✅ LSM staging buffer (`index/memory.ss`) + tempid hashtable + fulltext O(n²) fix — load is 1.0s at 1% scale (was ~47s) |
+| DuckDB analytical fallback | ✅ Translates `[?e :a ?v]` patterns + `:in` substitution + `SELECT DISTINCT`; auto-routed |
+
+Open items (not blocking parity):
+
+| Opportunity | Target | Note |
 |---|---|---|
-| Projection pushdown | Q4, Q8 | Reduce binding tuple width → cheaper copy-on-write |
-| Hash-join (O(M+N) instead of O(M×N)) | Q4, Q8 | Eliminate nested-loop join for shared-variable joins |
-| Cardinality statistics | All | Better planner ordering (avoid anchoring on low-selectivity clauses) |
-| Bulk-index load path | Load time | Defer RB-tree sort until end of load phase; currently ~120s at 1% scale |
-| Real MBrainz EDN loader | Full scale | Parse actual Datahike EDN files instead of synthetic data |
+| `defquery` macro for compile-time specialization | Q1/Q2/Q6/Q7 sub-µs hot path | Deferred — auto-routing already covers the headline gap |
+| Real MBrainz EDN loader | Full-scale validation | Synthetic data tracks the shape; real EDN would eliminate any "synthetic shortcut" doubt |
+| Q4 row-count divergence at 10% scale | Translator parity | Datalog 4.27M vs DuckDB 8.27M at 10%; 1% and 5% match bit-for-bit |
 
 ### Comparison vs Datahike (published numbers, full scale)
 
@@ -2841,16 +2882,16 @@ Datahike publishes MBrainz results at **full scale** (262K artists, 1.31M
 releases, 13.1M tracks).  Our numbers are at **1% scale** (2,620 artists,
 13,100 releases, 131,000 tracks) — a 100× smaller dataset.
 
-| Query | Datahike full scale | Jerboa-DB 1% scale | Scaled projection (×100) | Gap |
-|---|---|---|---|---|
-| Q1: exact name lookup | ~1ms | 0ms | <1ms | **None** — AVET lookup |
-| Q2: releases by artist | ~1ms | 0ms | <1ms | **None** — two-hop AVET/AEVT |
-| Q3: range predicate (startYear) | ~5ms | 2ms | ~200ms | **Moderate** — AVET range, degrades with scale |
-| Q4: tracks × shared-artist releases | ~2–8s | 2468ms | **~250s** | **Critical** — output is 428K rows at 1%, ~43M at full |
-| Q5: count releases per country | ~10ms | 4ms | ~400ms | **Moderate** — full AEVT scan |
-| Q6: reverse ref lookup | ~1ms | 0ms | <1ms | **None** |
-| Q7: pull | ~1ms | 0ms | <1ms | **None** |
-| Q8: avg duration by status | ~500ms–2s | 2056ms | **~205s** | **Critical** — 655K intermediate bindings at 1% |
+| Query | Datahike full scale | Jerboa-DB 1% (Datalog / DuckDB) | Status |
+|---|---|---|---|
+| Q1: exact name lookup | ~1ms | 0ms | **Parity** — AVET lookup |
+| Q2: releases by artist | ~1ms | 0ms | **Parity** — two-hop AVET/AEVT |
+| Q3: range predicate (startYear) | ~5ms | 1ms | **Parity** at 1%; 9ms at 10% |
+| Q4: tracks × shared-artist releases | ~2–8s | 1416ms / **281ms** | **Faster than Datahike** via DuckDB fallback |
+| Q5: count releases per country | ~10ms | 1ms | **Parity** — streaming aggregate |
+| Q6: reverse ref lookup | ~1ms | 0ms | **Parity** |
+| Q7: pull | ~1ms | 0ms | **Parity** |
+| Q8: avg duration by status | ~500ms–2s | 958ms / **9ms** | **Faster than Datahike** via DuckDB fallback |
 
 > Datahike reference: https://github.com/replikativ/datahike/tree/main/bench
 > Note: Datahike uses Hitchhiker tree (persistent B+ tree) + hash-join; numbers from