Files
crdc-demo/docs/superpowers/specs/2026-08-11-empirical-draws-wasm-design.md
T
jared 2cb04ad9c1 docs: add design spec for empirical draw distributions via duckdb-wasm
Scopes replacing the analytic distribution approximation with real
posterior draws, fetched client-side from the Hugging Face parquet
dataset using duckdb-wasm — no new backend endpoint required.
2026-08-11 07:41:21 -04:00

188 lines
9.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Empirical draw distributions via DuckDB-Wasm — Design Spec
**Date:** 2026-08-11
**Status:** Draft for review
---
## 1. Purpose & context
Two of this app's three charts currently show a *modeled* distribution shape that is
not the real posterior — `src/utils/distributionApprox.js` fits a two-piece-normal
curve to each group's `(median, lower, upper)` summary stats returned by the
`/estimates` API, because the API's raw posterior draws are only available in bulk
as Hive-partitioned Parquet on Hugging Face
(`civilytics/crdc-school-arrest-rates`), meant for DuckDB/bulk consumption, not
browser fetches (see `AGENTS.md` §"API Endpoint Availability" and
`HANDOFF.md` §"Future Enhancements").
This spec replaces the approximation with the **real** empirical draws (500 per
group), fetched client-side using `@duckdb/duckdb-wasm` to query the actual Parquet
shard for the district's state directly from Hugging Face — no new backend
endpoint, no change to the existing summary API calls.
**Verified feasibility (2026-08-11):**
- The HF dataset is public, non-gated. Each `(model_id, YEAR, LEA_STATE)` partition
is a single file (`data_0.parquet`).
- File sizes range from ~130KB (DC) to ~6.4MB (CA, the largest state) — confirmed
by resolving the `resolve/main/...` redirect to the actual CDN blob.
- The redirect target sends `access-control-allow-origin: *` and
`accept-ranges: bytes` — browser `fetch()` works directly, no proxy needed.
- Schema (from `crdc-arrests/R/postprocess.R` + `R/export_parquet.R`):
`LEAID, LEA_STATE, YEAR, RACE, SEX, model_id, subgroup_id, draw_id, pred`, sorted
within each shard by `(LEAID, RACE, SEX)`. `LEA_STATE`/`YEAR`/`model_id` are
Hive-partition columns (encoded in the path, not repeated in every row).
`stu_enroll` is **not** in the draws table — it's already available in this app
from the existing `/estimates` summary call.
---
## 2. Architecture
```
district selected (leaid, state)
│
▼
resolve HF parquet URL for (model_id, YEAR=21-22, LEA_STATE=state)
e.g. https://huggingface.co/datasets/civilytics/crdc-school-arrest-rates/
resolve/main/parquet/model_id=unified_m4_mod/YEAR=21-22/LEA_STATE=CO/data_0.parquet
│
▼
fetch() the shard (native fetch, follows the HF→CDN redirect automatically)
│
▼
duckdb-wasm: registerFileBuffer + query
SELECT RACE, SEX, pred FROM shard WHERE LEAID = '<leaid>'
│
▼
join `pred` (posterior count draws) against stu_enroll already in app state
(from the existing /estimates summary call) → rate-per-1000 draws per group
│
▼
KDE per race×sex group → smooth density curve, same shape the charts draw today
```
Given verified shard sizes (≤6.4MB), the design fetches the **whole shard** with a
plain `fetch()` and queries it in-memory via duckdb-wasm, rather than relying on
fine-grained HTTP range / row-group pruning. This is simpler and more robust than
depending on duckdb-wasm's HTTP virtual filesystem correctly handling the HF→CDN
redirect chain under partial-range requests — an unverified behavior — for a
saving that wouldn't matter at these file sizes.
`@duckdb/duckdb-wasm` is MIT-licensed and runs entirely client-side in a Web
Worker. It introduces no new server dependency and no new hosted service beyond
the Hugging Face dataset the `crdc-arrests` project's `/draws` endpoint already
points to (per `2026-05-30-draws-api-design.md`, decision #5) — this spec doesn't
introduce that dependency, it makes the demo app actually use data that was
already published there for exactly this purpose.
**Deployment risk:** duckdb-wasm's threaded ("eh") bundle requires
`Cross-Origin-Opener-Policy` / `Cross-Origin-Embedder-Policy` response headers
(for `SharedArrayBuffer`), which the git-pages static host does not send today.
This design uses the **single-threaded ("mvp") bundle** instead — at these file
sizes threading has no meaningful benefit, and it avoids needing new headers on
both the git-pages and Docker/nginx deploy paths.
---
## 3. File-level changes
### New files
- **`src/utils/duckdbClient.js`** — lazy-initialized singleton. Dynamic-imports
`@duckdb/duckdb-wasm`, selects the MVP (non-threaded) bundle, starts the worker
once. Dynamic `import()` keeps the ~3–5MB wasm payload out of the main bundle;
it only loads when a chart actually needs draws.
- **`src/utils/kde.js`** — Gaussian KDE over an array of numbers (Silverman
bandwidth). Takes over the role `distributionApprox.js`'s `densityCurve` plays
today, fed real empirical draws instead of a parametric fit.
- **`src/hooks/useDrawDistribution.js`** — given
`{ leaid, state, model, year, groups }` (groups = race/sex + `stu_enroll` already
in app state), resolves the HF URL, fetches, registers the buffer with
duckdb-wasm, runs the query, joins enrollment, and returns
`{ status: 'loading' | 'ready' | 'error', drawsByGroup }`. Owns an in-memory
`Map` cache keyed by `model+state+year` so re-selecting a model in Chart 3's
dropdown, or viewing another district in the same state, reuses the shard
already fetched.
### Modified files
- **`RateDensityRidgeline.jsx`** — on model-dropdown change, calls
`useDrawDistribution` for the selected model; replaces
`fitSkewedInterval`/`densityCurve` with the hook's real draws → `kde.js`. Shows
an inline spinner in the ridge area while that model's shard is loading (Charts
1–2 aren't blocked).
- **`RateByGroupBar.jsx`** — fetches draws for `unified_m3_mod` (the one model
this chart uses) alongside its existing data fetch. `q1`/`q3` become exact
empirical quantiles from the 500 real draws — removes this chart's use of
`fitSkewedInterval`'s fitted quantile function entirely.
- **`ChartPanel.jsx`** — passes `district.leaid`, `state`, and each group's
`stu_enroll` (already fetched) down to the two charts above.
- **`ApproxNote.jsx`** — becomes conditional: renders the "estimated shape" note
only when a chart is in fallback mode; charts backed by real draws show no note
(or a neutral "500 posterior draws" caption).
- **`package.json` / `vite.config.mjs`** — add `@duckdb/duckdb-wasm`; wasm/worker
assets are pulled in via Vite's native `?url` imports, which already respect the
`/crdc-demo/` `base` path — no bundler plugin needed.
### Unchanged
- **`ArrestsOverTime.jsx`** — already uses real summary stats (point-range from
`/estimates`), no approximation involved; out of scope.
- **`useApi.js`** — still the source for medians, intervals, and enrollment.
- **`distributionApprox.js`** — kept as the fallback path (see §4).
---
## 4. Error handling & caching
**Fallback:** `distributionApprox.js` is retained. `useDrawDistribution` catches
fetch/wasm/query failures and returns `status: 'error'`; both chart components
branch on that to render the current analytic-approximation path with
`<ApproxNote />` visible. A Hugging Face outage, a network failure, or an
unsupported browser degrades to today's behavior rather than breaking the chart.
**Caching:** in-memory only (a `Map` inside the hook), scoped to the browser
session. No IndexedDB/persistent cache in this iteration — a demo session
typically covers one or two districts, and shards are cheap enough to refetch on
reload.
---
## 5. Testing
This project has no automated test suite (per `AGENTS.md`); this follows the
existing manual-verification convention:
1. `npm run dev`; walk a small state (DC or WY, ~130–200KB shard) and a large one
(CA or TX, several MB) through the full district-search flow.
2. Confirm both charts render from real draws; confirm the Network tab shows the
expected parquet fetch(es) and sizes.
3. Simulate failure (block the `huggingface.co` / CDN domain in devtools) and
confirm both charts fall back to the analytic approximation with the note
visible, rather than breaking.
4. Confirm the model dropdown in Chart 3 re-fetches on first selection and is
instant on re-selection (cache hit).
---
## 6. Open risk to de-risk first
Before wiring up the full UI, spike: does duckdb-wasm's MVP bundle load and query
correctly when deployed under the `/crdc-demo/` subpath on git-pages, and does
nginx/git-pages serve `.wasm` with a usable content type? Everything else in this
design is standard Vite asset handling already exercised elsewhere in the app, but
this specific combination (wasm worker + subpath base + static host) hasn't been
verified end-to-end and should be checked with a throwaway spike rather than
assumed.
---
## 7. Explicitly out of scope
- `ArrestsOverTime.jsx` (Chart 1) — no approximation to replace.
- A new server-side `/draws`-streaming API endpoint — explicitly rejected in favor
of client-side wasm access, per the brainstorming decision that led to this spec.
- Persistent (IndexedDB) caching of fetched shards.
- Fine-grained HTTP range / row-group-level partial reads — shard sizes are small
enough that whole-file fetch is simpler and sufficiently fast.
- Extending empirical draws to national/exceedance-probability views — those
charts aren't part of the current 3-chart demo.