AGENTS.md described 6 charts, D3 selections, and DistrictVsNational / ModelDrawsComparison / ExceedanceProbability — none of which exist. An agent reading it as authoritative would have been actively misled, so it now opens by saying src/ wins any disagreement. Rewritten: the real component tree and data flow, ChartPanel as the owner of all cross-chart state, the pooled/unpooled key namespace trap, the four properties of the draws pipeline that are easy to break, the multi-part shard layout and why discovery uses the tree listing, the "posterior predictive draws" wording rule and the reason for it, the 95% convention, and a table of which tuning decisions carry stated rationale and should not be re-derived (the CVD-validated palette, the KDE bandwidth clamp, the pooling threshold, the mass/KDE cutoff, the axis cap). Adds a testing section — there are automated tests now — and notes that the deployment check should use a multi-part state like California, since a single-part state cannot catch a regression in part discovery. Records that App.jsx's "Search another district" is a full page reload that discards the shard cache. README: the chart list becomes the summary table plus the two ported figures, the file tree matches src/, the d3 role is stated precisely (scales and paths, not selections), and the endpoint table warns that /estimates?state= ranks by LEAID on a short read. Also commits the plan this work followed.
13 KiB
CRDC demo — rebuild the visuals around the white-paper figures
Context
The demo app at pages.civilytics.org/crdc-demo/ works, but the charts don't show off the
modelling. The navigation (state → district) is good and stays. The three existing charts get
cut to one, replaced by a summary table and two charts ported from the white paper:
wp_fig_group_density (Fig 6) and wp_fig_group_difference (Fig 7) in
crdc-arrests/R/paper_figures.R:472-568.
Two real defects surfaced while scoping:
- The "suggested districts" list is ranked over a truncated set.
DistrictSearch.jsx:22calls/estimates?state=XX&year=21-22&limit=500, but that endpoint returns rowsORDER BY LEAID(crdc-arrests/api/R/handlers_estimates.R:46) at 8 rows per district. So the app ranks the ~62 lowest-LEAID districts in the state. California has 11,488 rows. - The analytic fallback assumes 90% bounds but the API returns 95%.
distributionApprox.jsdefaultsintervalMass = 0.90; the API default isinterval=95(crdc-arrests/api/R/handlers_estimates.R). The new charts compute intervals from real draws, so this only affects the fallback path — fix the default while we're in there.
What the data supports (verified, not assumed)
- The HF parquet carries
LEAID, RACE, SEX, pred, draw_id, subgroup_id, batch_num.draw_idis dense 1–500 for every group. The current query atuseDrawDistribution.js:93just doesn't select it — that one column is what unlocks both pooling and differences. - Sex pooling is the project's own house method.
build_state_summary()incrdc-arrests/R/summarize_draws.R:152-182pools across LEAs by summingpredandstu_enrollwithin each draw, then summarizing across draws. Pooling M+F within a district is the identical operation on a different axis. Honest effort estimate: ~3–4 hours, most of it UI and labelling, not statistics. - Caveat to word carefully:
draw_idis renumbered 1–500 per write batch (crdc-arrests/R/postprocess.R:106-133), and a district's groups land in different batches, so cross-group draw pairing is effectively independent. Measured correlation between Black-male and Hispanic-malepredin Clark County was 0.019 even within a batch — observation noise fromposterior_predictdominates. Published Fig 7 has the same property, so the app matches the paper. Captions should say "posterior predictive draws", never "paired parameter draws". - Enrollment covers only AM/BL/HI/WH (verified against the API). Clark County sums to 148,928 against a district enrollment near 304,000. The table must label this.
Decisions taken
| Question | Decision |
|---|---|
| Model specs | One selected spec by default; opt-in "compare all four" expands to 4 ridge rows |
| Existing charts | Keep ArrestsOverTime; delete RateByGroupBar and RateDensityRidgeline |
| Pooling trigger | Whole-district: pool when total observed arrests across the 8 cells < 20 |
| Rendering | React owns the DOM; add d3 submodules for scales/paths/interpolation |
Work
1. Draws pipeline — expose draw_id, return counts not rates
src/hooks/useDrawDistribution.js — the one structural change everything else rests on.
- Query becomes
SELECT RACE, SEX, draw_id, pred FROM read_parquet(...) WHERE LEAID = ?. - Return raw counts indexed by draw, not rates:
countsByGroup[key][draw_id - 1] = pred. Indexing bydraw_idrather than push-order means row ordering from DuckDB is irrelevant. - Accept
models: string[]instead of a singlemodel, returning{status, byModel, nDraws}. A fixed-length array avoids conditional hooks when "compare all four" is on. The existing module-levelshardCachealready keys on(model, year, state), so four models is four cache entries with no other change. - Reject a group whose count array has holes (fewer entries than
nDraws) — a partial group must fall back, not silently render a short draw set. - Keep the reset-before-guard ordering at
useDrawDistribution.js:79-85; it exists to stop one model's draws being shown under another model's label.
src/utils/pooling.js (new, + test) — pure functions, no React:
poolBySex(countsByGroup, enrollByGroup)→ sums counts within each draw index acrossSEX ∈ {F,M}and sums enrollment, keyed by race alone.toRates(counts, enroll)→ per-1,000 array.shouldPoolBySex(rows)→ totalobserved_arrestsacross rows <POOL_BY_SEX_ARREST_THRESHOLD(20, a named constant with the rationale in a comment).
src/utils/drawGroups.js — extend groupKey to handle a pooled key (race only) and update
hasDrawsForAll for the new count-array shape.
2. Frequentist interval
src/utils/agrestiCoull.js (new, + test) — direct port of
crdc-arrests/R/paper_figures.R:219-237, including the zero-numerator branch
(ci_upper = -log(1 - level), the rule of three; ci_lower = 0). Note the R function returns
c(upper, lower, sd, se, phat) — upper first. Return a named object here instead. Needs a
qnorm/probit; distributionApprox.js already has one — reuse it rather than adding a second.
Tests should pin at least one case against R output (e.g. agresti_coull(15, 499, 0.95)).
3. Density profile — handle discrete posteriors honestly
src/utils/densityProfile.js (new, + test).
In sparse districts the posterior predictive is a discrete count distribution. Carson City NV
(3200390) has a 53-student AI/AN female cell where one arrest is 18.9 per 1,000 — the draws
take four distinct values and a Gaussian KDE renders them as a lumpy smear that reads as a
rendering bug.
densityProfile(counts, enroll, domain)returns{kind: 'kde'|'mass', points}.kind: 'mass'when the draws take ≤ 12 distinct values: probability mass at each achievable rate, drawn as a filled staircase so it visually rhymes with the smooth areas beside it.- Otherwise delegate to the existing
kdeCurveinsrc/utils/kde.js— its bandwidth clamp (BANDWIDTH_FLOOR_DIVISOR/BANDWIDTH_CEILING_DIVISOR) was tuned for exactly these zero-inflated posteriors and should not be touched.
4. Summary table (top of results)
src/components/DistrictSummaryTable.jsx (new). One row per student group plus a total:
| Student group | Students | Observed arrests | Rate per 1,000 |
- Sorted by observed arrests descending. Zero-arrest rows de-emphasized, not hidden.
- Each row carries the checkbox that drives chart A — the table is the legend and the control.
Default checked =
observed_arrests > 0; if no group has any, check the two largest by enrollment and say so. - Footnote: students counted are those in the four modeled race groups (AI/AN, Black, Hispanic, White), not total district enrollment.
- When pooling is active, rows collapse to four races and a banner states the rule in one sentence, with a switch to force it off.
5. Chart A — "Arrest rate probability density"
src/charts/RateDensityPanel.jsx (new). Replaces RateDensityRidgeline.jsx.
- Two stacked sub-panels, Female over Male, sharing one x-axis (per 1,000). Collapses to a
single panel when pooled. This preserves the palette contract documented at
src/utils/colors.js:9-13: race is hue, sex is position — never a second hue. - Within a sub-panel, selected groups overlap as filled areas (fill ~0.4 opacity, 2px stroke in the race color), direct-labelled at each peak so there's no legend hunting.
- Below each sub-panel's baseline, a thin rail stacks one Agresti–Coull point-range per selected
group in the matching color — the R figure's
position_nudgeidea, but un-overplotted. - Segmented control for the four unified quadrant specs. A "Compare all four specifications" switch expands to four ridge rows (matching Fig 1's structure) and triggers four shard fetches — cheap for NV (~100KB each), ~6.3MB each for CA, so it stays opt-in with a spinner.
- x-domain: max of the density supports and the frequentist upper bounds, with the existing cap
logic from
RateDensityRidgeline.jsx:101andniceTicks. - Caption states 500 posterior predictive draws and a 95% Agresti–Coull observed interval.
6. Chart B — "Model Estimated Differences"
src/charts/GroupDifference.jsx (new).
- Two group pickers; defaults are the two groups with the most observed arrests (pooled groups when pooling is on). Δ = rate(A) − rate(B) per 1,000, computed per draw index.
- Single density, filled with an SVG
linearGradientmapped across x. Use a diverging ramp centered at zero — navy for Δ<0, paper at 0, ember for Δ>0 — rather than the paper's YlOrRd: the quantity is signed, and diverging-at-zero is the honest encoding. On-brand viatokens.css. - Dashed vertical rule at 0 in
--cv-danger, matching the paper's red line. - Large readout
Pr(Δ > 0)with a plain-language sentence beneath ("In 94.4% of posterior draws, the Black male arrest rate exceeds the White male rate"), plus median Δ and an 80%/95% interval as a point-range. - Degrade explicitly when fewer than two groups have usable draws — say why, don't render empty.
7. Fix the district suggestions
scripts/build-top-districts.mjs (new) — pages /estimates?state=XX&year=21-22&limit=1000
using meta.total (confirmed present in the envelope) across all 51 states, aggregates observed
arrests per LEAID, and writes public/data/top_districts.json with the top 15 per state
(leaid, name, arrests, enrollment, rate). Roughly 140 requests as a one-off; the output is
~50KB and gets committed, following the public/data/national_rates.json precedent.
src/components/DistrictSearch.jsx — read the fixture instead of calling
fetchStateDistricts at runtime. The search screen loses a multi-second fetch and the ranking
becomes correct. Keep live name search on /districts unchanged. Drop the hardcoded
"Try Derby (KS), Paterson (NJ)…" hint at DistrictSearch.jsx:164 — the real list supersedes it.
8. Wiring, deletions, docs
src/components/ChartPanel.jsx— owns pooling state, selected groups, selected spec, and the difference pair; passes them down. KeepArrestsOverTime. DropQUADRANT_MODELSprefetch of all four models' summaries if only the selected one is needed.- Delete:
src/charts/RateByGroupBar.jsx,src/charts/RateDensityRidgeline.jsx. - Keep:
distributionApprox.jsandApproxNote.jsx— still the fallback when draws can't be fetched (AGENTS.md:91-101). Fix itsintervalMassdefault to 0.95 to match the API. package.json— addd3-scale,d3-shape,d3-array,d3-interpolateas realdependencies(the existing deps are all miscategorised underdevDependencies; leave that alone unless it's breaking the build).- Docs:
AGENTS.mdstill describes 6 charts, D3 selections, andDistrictVsNational/ModelDrawsComparison/ExceedanceProbability— none of which exist. Rewrite the architecture, data-flow, and interval sections. UpdateREADME.md's chart list.
Verification
npm run buildclean;npm test(node --test 'src/**/*.test.js') passes, including new tests foragrestiCoull,pooling,densityProfile, and the extendeddrawGroups.npm run dev, then walk these districts:- Clark County NV (
3200060, 100 arrests / 148,928 students) — pooling stays off, all four races render, differences chart defaults to the top two groups. - Carson City NV (
3200390, 6 arrests / 4,073 students) — pooling auto-engages (6 < 20), banner appears, table collapses to four races. The AI/AN cell should render as a discrete mass profile, not a smear. Verified against the draws: pooling narrows AI/AN's 90% interval from 37.7 to 27.0 per 1,000, and Hispanic male's from 4.9 to 2.5. - Washoe County NV (
3200480) — cross-check the summary table's observed counts and rates against/api/v1/estimates/3200480?model=unified_m4_mod&year=21-22. - A California district — confirm the suggestion fixture ranks correctly (this is the case the current code gets wrong), and that "compare all four" warns/spins before pulling ~25MB of shards.
- Clark County NV (
- Toggle every group off, then on; switch specs; flip pooling manually — no stale draws from a previous model should ever appear under a new label.
- Compare chart A against
wp_fig_group_densityoutput for Clark County: same curve shapes, same point-range positions. - Browser console clean on hard refresh; check the Network tab shows one shard fetch per (model, state) and no repeats when navigating between districts.
Effort
Roughly 2–3 focused days end to end: ~1 day for the draws/pooling/util layer with tests, ~1 day for the two charts, ~half a day for the table, the suggestion fixture, and docs. At ~10 hours a week that's about two calendar weeks.
The R Shiny alternative would be slower, not faster — it trades a zero-server static site for a container, an R runtime, and server-side access to either the 91GB draws DuckDB or the 51-state parquet tree, and turns every toggle into a round-trip re-render. The React app already fetches real draws client-side and already carries the design tokens.