Compare commits

...
5 Commits
Author SHA1 Message Date
jared 587e74ca80 chore: add CLAUDE.md that imports AGENTS.md
Claude Code loads a project's AGENTS.md only when the project has no CLAUDE.md, and then
also loads every AGENTS.md and .claude/AGENTS.md above it, including ~/AGENTS.md and
~/.claude/AGENTS.md. This one-line import keeps the project's instructions to its own
AGENTS.md, matching the other Civilytics repos.
2026-10-11 16:57:01 -04:00
jared 654b42ca71 fix: raise MAX_RATE_DOMAIN from 30 to 100 per 1,000 students
Deploy to git-pages / deploy (push) Successful in 19s
The previous cap of 30 was exceeded by over 60% of districts — mostly sparse
groups with small enrollment cells whose Agresti-Coull upper bounds genuinely
extend that far. Analysis across all 51 states (16,279 districts) showed the
median max x-value is already ~56 per 1,000; only truly degenerate cases like a
single predicted arrest in a four-student cell (~250/1000) need capping.

A cap of 100 still guards against these outliers while letting realistic data
drive the axis for the vast majority of districts. The existing 'clipped' flag
and note mechanism remain unchanged — they activate only when extreme values are
encountered.
2026-08-22 20:29:32 -04:00
jared fb0beaafc8 fix: compute the district total's interval as HPD, matching the API
Deploy to git-pages / deploy (push) Successful in 16s
The API's count_lower/count_upper are highest-density bounds — hpd_bounds_sql()
in crdc-arrests/R/summarize_draws.R — not equal-tailed quantiles, and the white
paper's figures use the same. The draws-based total introduced in the previous
commit used 2.5/97.5 quantiles, so the chart's two modes labelled "95% interval"
meant two different things depending on whether the draws had been fetched.

Measured on Clark County, the difference is small — 2 to 3 arrests on a band of
roughly 50, about a pixel — but it is a distinction the page offers no
explanation for, and the fallback mode's bounds are already HPD.

  wave     equal-tailed   HPD        delta
  2015-16  215-276        219-277    width -3
  2017-18  175-230        173-226    width -2
  2021-22  116-164        115-161    width -2

hpdBounds() takes the narrowest window covering the mass, contiguous as the R
original is. Rendered values read back off the chart geometry now match an
offline DuckDB computation exactly.
2026-08-12 09:34:53 -04:00
jared 15f8a86409 feat: take the arrests-over-time interval from the draws, not summed bounds
Deploy to git-pages / deploy (push) Successful in 16s
The band on the time-series chart summed each student group's own
count_lower/count_upper. That is not the interval of the district total: the sum
of eight 97.5th percentiles is the case where every group lands at its extreme
in the same draw, which is far less likely than any one group doing so. The
chart was overstating uncertainty by roughly a factor of two.

Now summed within each draw and summarized across draws — build_state_summary()'s
method from crdc-arrests/R/summarize_draws.R on a different axis, the same
operation poolBySex already performs. Clark County NV, 95% interval of the total:

  wave     draws-based        summed bounds     change
  2015-16  215-276 (w 61)     172-322 (w 150)   -60%
  2017-18  175-230 (w 55)     134-269 (w 135)   -60%
  2021-22  116-164 (w 48)      78-188 (w 110)   -57%

Medians barely move (245->246, 200->202, 137->138), which is the expected
signature: the sum of medians already approximates the median of the sum. It is
the tails that were wrong. Values verified against an offline DuckDB computation
over the same shards and then read back off the rendered SVG geometry.

Cost is very uneven, so it is not unconditional. Three waves is 0.31MB for
Nevada but 13.9MB for California and 14.3MB for Texas. Directory listings
(~1KB/year, and already fetched for part discovery) carry each part's byte size,
so useDistrictTotalDraws probes the total up front: under 3MB it fetches
automatically, above it the chart offers a button naming the real size
("Compute the exact interval from the draws (13.9 MB)"). Measured: Nevada adds
no perceptible time at all — the totals resolve in the same frame as the density
panel, since both wait on the duckdb-wasm engine that dominates a cold load —
and California's opt-in completes in 1.3s.

totalPerDraw refuses to sum unless every group has a complete draw set, and
fetchModelCounts now reports how many groups it dropped: for a per-group density
a missing group is a gap, but for a total it is an undercount that would shift
the whole interval down while looking entirely plausible.

The caption states which interval is on screen in either case, and names the
assumption behind the draws-based one — draw_id is renumbered per write batch
upstream, so summing at a draw index convolves what are effectively independent
draws. That is defensible for a posterior *predictive* total, whose observation
noise is independent across groups by construction and dominates the
parameter-level covariance, but it should be read as a predictive total rather
than a correlation-preserving contrast.
2026-08-12 09:31:04 -04:00
jared 9df4f4b5aa Tweak footnote wording
Deploy to git-pages / deploy (push) Successful in 17s
2026-08-12 09:22:07 -04:00
10 changed files with 941 additions and 37 deletions
+2 -2
View File
@@ -170,8 +170,8 @@ time, the port does not).
posterior predictive is drawn as discrete mass, because it *is* discrete; a posterior predictive is drawn as discrete mass, because it *is* discrete; a
Gaussian KDE over four achievable values renders as a lumpy smear that reads Gaussian KDE over four achievable values renders as a lumpy smear that reads
as a rendering bug. as a rendering bug.
- **`src/utils/rateDomain.js`** — `MAX_RATE_DOMAIN = 30` caps the axis so a - **`src/utils/rateDomain.js`** — `MAX_RATE_DOMAIN = 100` caps the axis so a
four-student cell can't squash every other curve. It reports `clipped` so the four-student cell can't squash every other curve (up from 30, which was exceeded by over 60% of districts). It reports `clipped` so the
chart says so instead of silently cropping. chart says so instead of silently cropping.
## Common Pitfalls & Gotchas ## Common Pitfalls & Gotchas
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+392
View File
@@ -0,0 +1,392 @@
/**
* Analysis: distribution of maximum x-axis values for the rate density chart.
*
* The "Arrest rate probability density" chart currently caps its x-axis at a
* hard-coded MAX_RATE_DOMAIN = 30 (per 1,000 students). We want to understand
* what the true distribution of max x-values is across districts so we can
* decide whether to raise or make this cap dynamic.
*
* What feeds computeRateDomain():
* 1. Per-group rate arrays from real posterior draws → 0.995 quantile of each
* (requires parquet data; approximated here via model estimates)
* 2. Agresti-Coull upper bound rates = (ac.upper / enroll) * 1000, computed
* from observed counts and enrollment — available via the API for every
* race×sex cell in every district
*
* KEY INSIGHT: Only SELECTED groups feed into computeRateDomain. Per
* `defaultSelectedKeys` in districtGroups.js, only groups with ≥1 observed
* arrest are selected by default (or top-2 by enrollment if none have arrests).
* Additionally, when total district arrests < 20, sex pooling merges F+M cells,
* which combines enrollment and observed counts per race.
*/
const BASE = 'https://crdc-api.civilytics.org/api/v1'
const LIMIT = 500
const POOL_THRESHOLD = 20 // POOL_BY_SEX_ARREST_THRESHOLD from pooling.js
/** Fetch JSON from the API, retrying on transient failures. */
async function apiFetch(path) {
const url = `${BASE}${path}`
let lastErr
for (let attempt = 0; attempt <= 3; attempt++) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(30000) })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return await res.json()
} catch (err) {
lastErr = err
if (attempt < 3) {
const wait = 500 * Math.pow(2, attempt) + Math.random() * 100
console.error(` retry ${attempt + 1}/3 after ${Math.round(wait)}ms:`, err.message)
await new Promise((r) => setTimeout(r, wait))
}
}
}
throw lastErr
}
/** Peter Acklam's inverse normal CDF (probit), ~1.15e-9 relative error. */
function probit(p) {
const a = [-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02,
1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00]
const b = [-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02,
6.680131188771972e+01, -1.328068155288572e+01]
const c = [-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00,
-2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00]
const d = [7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e+00,
3.754408661907416e+00]
const pLow = 0.02425, pHigh = 1 - pLow
if (p < pLow) {
const q = Math.sqrt(-2 * Math.log(p))
return (((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5]) /
((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1)
}
if (p <= pHigh) {
const q = p - 0.5, r = q * q
return (((((a[0]*r+a[1])*r+a[2])*r+a[3])*r+a[4])*r+a[5]) * q /
(((((b[0]*r+b[1])*r+b[2])*r+b[3])*r+b[4])*r+1)
}
const q = Math.sqrt(-2 * Math.log(1 - p))
return -(((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5]) /
((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1)
}
/** Agresti-Coull upper bound (count scale), port of agrestiCoull.js. */
function acUpperBound(numerator, denominator, confidenceLevel = 0.95) {
const adjStar = probit(1 - (1 - confidenceLevel) / 2)
if (numerator > 0) {
const numStar = numerator + adjStar
const denomStar = denominator + 2 * adjStar
const phat = numStar / denomStar
const se = Math.sqrt((phat / denomStar) * (1 - phat))
return (phat + adjStar * se) * denomStar
}
// Zero events: rule of three — upper bound ≈ 3 regardless of enrollment.
return -Math.log(1 - confidenceLevel)
}
/** Estimate the 0.995 quantile of posterior predictive draw rates. */
function estimateDrawQuantile(row, targetP = 0.995) {
const enroll = row.stu_enroll || 0
if (enroll <= 0) return null
// When count_upper is available and > 0, the model's posterior predictive
// upper bound gives a sense of the spread. For sparse groups with few or zero
// arrests, draw quantiles can be extreme because observation noise dominates:
// a single predicted arrest in a small cell produces a huge per-1000 rate.
const countUpper = row.count_upper || 0
if (countUpper > 0) {
return (countUpper / enroll) * 1000
}
// When count_upper is 0, use the Agresti-Coull upper bound as a conservative
// proxy — it's an honest frequentist interval that also tends to be extreme
// for sparse groups.
const ac = acUpperBound(row.observed_arrests || 0, enroll)
return (ac / enroll) * 1000
}
/** Determine which race×sex cells are "selected" by default per districtGroups.js logic. */
function determineSelectedCells(cells, pooled) {
const usable = cells.filter(
(r) => ['WH', 'BL', 'HI', 'AM'].includes(r.race) && ['F', 'M'].includes(r.sex),
)
if (pooled) {
// When pooled: merge F+M per race, then select groups with ≥1 arrest or top-2 by enrollment
const races = {}
for (const r of usable) {
if (!races[r.race]) races[r.race] = { race: r.race, enroll: 0, observed: 0 }
races[r.race].enroll += r.stu_enroll || 0
races[r.race].observed += r.observed_arrests || 0
}
const merged = Object.values(races)
// defaultSelectedKeys logic on pooled groups
const withArrests = merged.filter((r) => r.observed > 0)
if (withArrests.length > 0) {
return withArrests.map((r) => ({ race: r.race, sex: null, enroll: r.enroll, observed: r.observed }))
}
return [...merged]
.sort((a, b) => b.enroll - a.enroll)
.slice(0, 2)
.map((r) => ({ race: r.race, sex: null, enroll: r.enroll, observed: r.observed }))
} else {
// Unpooled: select groups with ≥1 arrest or top-2 by enrollment
const withArrests = usable.filter((r) => (r.observed_arrests || 0) > 0)
if (withArrests.length > 0) return withArrests
return [...usable]
.sort((a, b) => (b.stu_enroll || 0) - (a.stu_enroll || 0))
.slice(0, 2)
}
}
/** Compute max x-value candidate from selected cells. */
function computeMaxX(selectedCells, allCells, pooled) {
let maxAcRate = 0
let maxDrawEstimate = 0
for (const cell of selectedCells) {
const enroll = cell.enroll || 0
if (enroll <= 0) continue
const observed = cell.observed || 0
// AC upper bound rate (always a candidate in computeRateDomain)
const acUpper = acUpperBound(observed, enroll)
const acRate = (acUpper / enroll) * 1000
if (acRate > maxAcRate) maxAcRate = acRate
// Estimated draw quantile — find the matching row in allCells for model estimates
let countUpper = 0
let foundRow = null
if (!pooled && cell.sex) {
foundRow = allCells.find((r) => r.race === cell.race && r.sex === cell.sex)
} else if (pooled) {
// For pooled, find the row with max count_upper for this race across both sexes
const raceRows = allCells.filter((r) => r.race === cell.race && ['F', 'M'].includes(r.sex))
for (const rr of raceRows) {
if ((rr.count_upper || 0) > countUpper) countUpper = rr.count_upper || 0
}
}
if (!pooled && foundRow) {
countUpper = foundRow.count_upper || 0
}
const enroll_ = cell.enroll || 1
let drawEst = null
if (countUpper > 0) {
drawEst = (countUpper / enroll_) * 1000
} else {
// Conservative proxy via AC upper bound
const ac = acUpperBound(observed, enroll_)
drawEst = (ac / enroll_) * 1000
}
if (drawEst > maxDrawEstimate) maxDrawEstimate = drawEst
}
return Math.max(maxAcRate, maxDrawEstimate)
}
async function main() {
console.log('=== Rate Domain Analysis ===\n')
// Step 1: Get all states from the API
const statesResp = await apiFetch('/states?limit=500')
const stateSet = new Set(statesResp.data.map((r) => r.state))
const states = [...stateSet].sort()
console.log(`Found ${states.length} states\n`)
// Step 2: For each state, page through all estimates and collect per-district data
const districtData = new Map() // leaid -> { state, name, cells: [] }
let totalRows = 0
for (const state of states) {
console.log(`Fetching ${state}...`)
const firstResp = await apiFetch(`/estimates?state=${state}&limit=${LIMIT}`)
const total = firstResp.meta.total
const nPages = Math.ceil(total / LIMIT)
let rows = [...firstResp.data]
for (let page = 1; page < nPages; page++) {
process.stderr.write(` ${state} page ${page + 1}/${nPages}\r`)
const resp = await apiFetch(`/estimates?state=${state}&limit=${LIMIT}&page=${page}`)
rows.push(...resp.data)
}
totalRows += rows.length
for (const row of rows) {
const key = `${row.state}|${row.leaid}`
if (!districtData.has(key)) {
districtData.set(key, { state: row.state, leaid: row.leaid, name: row.lea_name, cells: [] })
}
districtData.get(key).cells.push(row)
}
}
console.log(`\nTotal rows fetched: ${totalRows}`)
console.log(`Total districts: ${districtData.size}\n`)
// Step 3: For each district, compute the max x-value for both pooled and unpooled modes
const results = []
let nPooled = 0
let nUnpooled = 0
for (const [key, dist] of districtData) {
const totalArrests = dist.cells.reduce((sum, r) => sum + (r.observed_arrests || 0), 0)
const pooled = totalArrests < POOL_THRESHOLD
if (pooled) nPooled++
else nUnpooled++
let maxX
if (pooled) {
// Pooled mode: AC bounds computed per race (F+M merged). Note that for
// pooled groups, the app shows a note but still computes AC bounds.
const selected = determineSelectedCells(dist.cells, true)
maxX = computeMaxX(selected, dist.cells, true)
} else {
const selected = determineSelectedCells(dist.cells, false)
maxX = computeMaxX(selected, dist.cells, false)
}
let totalEnroll = 0
for (const cell of dist.cells) {
if ((cell.stu_enroll || 0) > 0) totalEnroll += cell.stu_enroll
}
results.push({
key, state: dist.state, leaid: dist.leaid, name: dist.name,
totalEnroll, pooled, maxX: maxX * 1.15, // HEADROOM factor from rateDomain.js
})
}
console.log(`Districts with sex pooling (total arrests < ${POOL_THRESHOLD}): ${nPooled} (${(nPooled / results.length * 100).toFixed(1)}%)`)
console.log(`Districts without pooling: ${nUnpooled} (${(nUnpooled / results.length * 100).toFixed(1)}%)\n`)
// Step 4: Analyze distribution of max x-values
const sorted = results.sort((a, b) => a.maxX - b.maxX)
const n = sorted.length
console.log('=== Distribution of Maximum X-Values (per 1,000 students) ===\n')
// Summary statistics — maxX already includes HEADROOM(1.15) factor
const percentiles = [5, 10, 25, 50, 75, 90, 95, 99, 99.9]
console.log('Percentiles of max x-value (includes HEADROOM=1.15):')
for (const p of percentiles) {
const idx = Math.floor((p / 100) * (n - 1))
console.log(` ${p.toFixed(1)}th: ${sorted[idx].maxX.toFixed(2)} per 1,000`)
}
console.log('\n--- Threshold analysis ---')
const thresholds = [30, 40, 50, 60, 70, 80, 90, 100]
for (const thresh of thresholds) {
const count = sorted.filter((r) => r.maxX > thresh).length
const pct = (count / n) * 100
console.log(` Exceeds ${thresh}: ${count} districts (${pct.toFixed(2)}%)`)
}
// Step 5: Break down by pooling status
console.log('\n--- By pooling status ---')
const pooledResults = sorted.filter((r) => r.pooled)
const unpooledResults = sorted.filter((r) => !r.pooled)
for (const thresh of [30, 50, 100]) {
const pClipped = pooledResults.filter((r) => r.maxX > thresh).length
const uClipped = unpooledResults.filter((r) => r.maxX > thresh).length
console.log(` Cap=${thresh}: pooled ${pClipped}/${pooledResults.length} (${(pClipped / pooledResults.length * 100).toFixed(1)}%), ` +
`unpooled ${uClipped}/${unpooledResults.length} (${(uClipped / unpooledResults.length * 100).toFixed(1)}%)`)
}
// Step 6: Show top districts by max x-value, with enrollment context
console.log('\n--- Top 25 districts by max x-value ---')
const top = sorted.slice(-25).reverse()
for (const r of top) {
const pooledStr = r.pooled ? ' [pooled]' : ''
const clippedAt30 = r.maxX > 30 ? ' *** CLIPPED at 30' : ''
console.log(` ${r.state} | LEAID ${r.leaid} | enroll=${r.totalEnroll.toLocaleString()}${pooledStr} | ` +
`max_x=${r.maxX.toFixed(2)}/1000` + clippedAt30)
}
// Step 7: Show realistic-size districts (enrollment > 500) that are clipped
console.log('\n--- Clipped districts with enrollment > 500 ---')
const realClipped = sorted.filter((r) => r.maxX > 30 && r.totalEnroll >= 500).sort((a, b) => b.maxX - a.maxX).slice(0, 15)
for (const r of realClipped) {
const pooledStr = r.pooled ? ' [pooled]' : ''
console.log(` ${r.state} | ${r.name} | enroll=${r.totalEnroll.toLocaleString()}${pooledStr} | ` +
`max_x=${r.maxX.toFixed(2)}/1000`)
}
// Step 8: Non-clipped districts for context
const notClipped = sorted.filter((r) => r.maxX <= 30).sort((a, b) => a.maxX - b.maxX)
console.log(`\n--- Non-clipped districts (max_x ≤ 30): ${notClipped.length} (${(notClipped.length / n * 100).toFixed(2)}%) ---`)
if (notClipped.length > 0) {
const midIdx = Math.floor(notClipped.length / 2)
console.log(' Sample non-clipped districts:')
for (let i = Math.max(0, midIdx - 3); i < Math.min(notClipped.length, midIdx + 4); i++) {
const r = notClipped[i]
console.log(` ${r.state} | ${r.name} | enroll=${r.totalEnroll.toLocaleString()} | max_x=${r.maxX.toFixed(2)}/1000`)
}
}
// Step 9: State-level summary (focusing on clipped counts)
console.log('\n--- Top 15 states by % of districts clipped ---')
const byState = {}
for (const r of results) {
if (!byState[r.state]) byState[r.state] = []
byState[r.state].push(r.maxX)
}
const stateStats = Object.entries(byState).map(([state, vals]) => ({
state, n: vals.length, median: percentile(vals, 50), p95: percentile(vals, 95),
max: Math.max(...vals), clipped: vals.filter((v) => v > 30).length,
})).sort((a, b) => (b.clipped / b.n) - (a.clipped / a.n))
for (const s of stateStats.slice(0, 15)) {
console.log(` ${s.state}: n=${s.n}, median=${s.median.toFixed(1)}, p95=${s.p95.toFixed(1)}, ` +
`max=${s.max.toFixed(1)}, clipped>30: ${s.clipped} (${(s.clipped / s.n * 100).toFixed(1)}%)`)
}
// Step 10: Recommendation analysis — what cap would minimize clipping while staying bounded?
console.log('\n=== RECOMMENDATION ANALYSIS ===')
const capOptions = [30, 40, 50, 60, 75, 100]
for (const cap of capOptions) {
const clipped = sorted.filter((r) => r.maxX > cap).length
console.log(` Cap=${cap}: ${clipped} districts clipped (${(clipped / n * 100).toFixed(2)}%)`)
}
// Step 11: Key findings summary
console.log('\n--- Key Findings ---')
const pctExceed30 = (sorted.filter((r) => r.maxX > 30).length / n) * 100
const pctExceed50 = (sorted.filter((r) => r.maxX > 50).length / n) * 100
console.log(`- ${pctExceed30.toFixed(2)}% of districts have a max x-value exceeding the current cap of 30`)
console.log(`- ${pctExceed50.toFixed(2)}% exceed 50 per 1,000`)
const p99 = sorted[Math.floor(0.99 * (n - 1))].maxX
const max = sorted[n - 1].maxX
console.log(`- 99th percentile: ${p99.toFixed(2)} per 1,000`)
console.log(`- Maximum observed: ${max.toFixed(2)} per 1,000`)
// Analyze the nature of clipped districts — are they sparse or not?
const extreme = sorted.filter((r) => r.maxX > 30).sort((a, b) => a.totalEnroll - b.totalEnroll)
console.log(`\n--- Clipped district enrollment distribution ---`)
for (const p of [10, 25, 50, 75, 90]) {
const idx = Math.floor((p / 100) * (extreme.length - 1))
console.log(` ${p}th percentile enrollment: ${extreme[idx].totalEnroll.toLocaleString()}`)
}
// How many clipped districts have "normal" school sizes (>1000 students)?
const normalClipped = extreme.filter((r) => r.totalEnroll >= 1000).length
console.log(`\n- ${normalClipped} of ${extreme.length} clipped districts have enrollment ≥ 1,000 (${(normalClipped / extreme.length * 100).toFixed(1)}%)`)
// Analyze what's driving the extremes — AC bounds vs draw estimates
console.log('\n--- What drives extreme values? ---')
const acDriven = sorted.filter((r) => r.maxX > 30 && !r.pooled).length
console.log(`- Unpooled districts clipped: ${acDriven} (${(acDriven / n * 100).toFixed(2)}% of all)`)
function percentile(arr, p) {
const s = [...arr].sort((a, b) => a - b)
return s[Math.floor((p / 100) * (s.length - 1))]
}
}
main().catch(console.error)
+80 -13
View File
@@ -11,6 +11,59 @@ import { MODEL_QUADRANT_LABEL } from '../utils/labels.js'
* read as a pair. * read as a pair.
*/ */
/**
* Says which interval is actually on screen, and offers the better one when it
* hasn't been fetched. The distinction is not cosmetic: summing each group's
* own bounds answers "what if every group hit its extreme at once", which is a
* far wider claim than "how many arrests does the model think there were".
*/
function TotalIntervalNote({ totals, usingExact, onRequest }) {
const base = { fontSize: '0.75rem', margin: 'var(--space-1) 0 0' }
if (usingExact) {
return (
<p style={{ ...base, color: 'var(--cv-ink-3)' }}>
The modeled band is the 95% interval of the district <em>total</em>, taken from{' '}
{(totals?.byYear && Object.values(totals.byYear).find(Boolean)?.nDraws?.toLocaleString()) || '500'}{' '}
posterior predictive draws — summed across student groups within each draw, then
summarized across draws.
</p>
)
}
if (totals?.status === 'loading') {
return <p style={{ ...base, color: 'var(--cv-ink-3)' }}>Computing the total from posterior draws…</p>
}
const mb = totals?.bytes ? (totals.bytes / 1048576).toFixed(1) : null
return (
<p style={{ ...base, color: 'var(--cv-ink-3)' }}>
The modeled band sums each student group&rsquo;s own 95% bounds, which is wider than the
interval of the total — it is the case where every group lands at its extreme in the same
draw.{' '}
{totals?.status === 'error' ? (
<>The exact total could not be computed from the draws for this district.</>
) : (
onRequest && (
<button type="button" onClick={onRequest} style={linkButton}>
Compute the exact interval from the draws{mb ? ` (${mb} MB)` : ''}
</button>
)
)}
</p>
)
}
const linkButton = {
background: 'none',
border: 'none',
padding: 0,
font: 'inherit',
color: 'var(--cv-navy-600)',
textDecoration: 'underline',
cursor: 'pointer',
}
const WAVE_LABELS = { '15-16': '2015–16', '17-18': '2017–18', '21-22': '2021–22' } const WAVE_LABELS = { '15-16': '2015–16', '17-18': '2017–18', '21-22': '2021–22' }
const HEADROOM = 26 // px reserved at the top of the plot so a point's rate label has room to sit above it const HEADROOM = 26 // px reserved at the top of the plot so a point's rate label has room to sit above it
@@ -27,9 +80,21 @@ const MARGIN = { top: 20, right: 24, bottom: 52, left: 56 }
// glyph is visually clipped and its label has nowhere to go. // glyph is visually clipped and its label has nowhere to go.
const X_PAD = 64 const X_PAD = 64
export default function ArrestsOverTime({ data, modelId }) { export default function ArrestsOverTime({ data, modelId, totals, onRequestExactTotals }) {
// Prefer the interval computed from the draws (summed within each draw) over
// the sum of each group's own bounds, which is not the interval of the total
// and comes out systematically too wide.
const exact = totals?.status === 'ready' ? totals.byYear : null
const series = data.map((d) => {
const iv = exact?.[d.year]
return iv
? { ...d, modeledMedian: iv.median, modeledLower: iv.lower, modeledUpper: iv.upper, exact: true }
: { ...d, exact: false }
})
const usingExact = series.some((d) => d.exact)
const maxArrests = Math.max( const maxArrests = Math.max(
...data.map((d) => Math.max(d.arrests, d.modeledUpper ?? 0)), ...series.map((d) => Math.max(d.arrests, d.modeledUpper ?? 0)),
1 1
) )
const { ticks, niceMax } = niceTicks(maxArrests) const { ticks, niceMax } = niceTicks(maxArrests)
@@ -43,7 +108,7 @@ export default function ArrestsOverTime({ data, modelId }) {
const plotLeft = margin.left + X_PAD const plotLeft = margin.left + X_PAD
const plotRight = margin.left + innerWidth - X_PAD const plotRight = margin.left + innerWidth - X_PAD
const xScale = (i) => plotLeft + (i / Math.max(data.length - 1, 1)) * (plotRight - plotLeft) const xScale = (i) => plotLeft + (i / Math.max(series.length - 1, 1)) * (plotRight - plotLeft)
const yScale = (val) => HEADROOM + (innerHeight - HEADROOM) * (1 - val / niceMax) const yScale = (val) => HEADROOM + (innerHeight - HEADROOM) * (1 - val / niceMax)
return ( return (
@@ -81,7 +146,7 @@ export default function ArrestsOverTime({ data, modelId }) {
Total arrests Total arrests
</text> </text>
{data.map((d, i) => ( {series.map((d, i) => (
<text key={d.year} x={xScale(i)} y={margin.top + yScale(0) + 18} <text key={d.year} x={xScale(i)} y={margin.top + yScale(0) + 18}
textAnchor="middle" fontSize="0.62rem" fill="var(--cv-ink-2)"> textAnchor="middle" fontSize="0.62rem" fill="var(--cv-ink-2)">
{WAVE_LABELS[d.year] || d.label} {WAVE_LABELS[d.year] || d.label}
@@ -94,7 +159,7 @@ export default function ArrestsOverTime({ data, modelId }) {
{/* Modeled point-range per wave — drawn first, directly under the {/* Modeled point-range per wave — drawn first, directly under the
observed marks it pairs with, and kept visually subordinate (thinner, observed marks it pairs with, and kept visually subordinate (thinner,
no halo) so the observed series reads as the primary line. */} no halo) so the observed series reads as the primary line. */}
{data.map((d, i) => { {series.map((d, i) => {
if (d.modeledMedian == null) return null if (d.modeledMedian == null) return null
const cx = xScale(i) const cx = xScale(i)
const cyMedian = margin.top + yScale(d.modeledMedian) const cyMedian = margin.top + yScale(d.modeledMedian)
@@ -111,9 +176,9 @@ export default function ArrestsOverTime({ data, modelId }) {
})} })}
{/* Observed line */} {/* Observed line */}
{data.length > 1 && ( {series.length > 1 && (
<polyline <polyline
points={data.map((d, i) => `${xScale(i)},${margin.top + yScale(d.arrests)}`).join(' ')} points={series.map((d, i) => `${xScale(i)},${margin.top + yScale(d.arrests)}`).join(' ')}
fill="none" stroke={OBSERVED_MARK_COLOR} strokeWidth={2} fill="none" stroke={OBSERVED_MARK_COLOR} strokeWidth={2}
strokeLinejoin="round" strokeLinecap="round" strokeLinejoin="round" strokeLinecap="round"
/> />
@@ -122,7 +187,7 @@ export default function ArrestsOverTime({ data, modelId }) {
{/* Observed diamonds + rate-per-1k labels. X_PAD keeps the end markers {/* Observed diamonds + rate-per-1k labels. X_PAD keeps the end markers
clear of the plot edges, so every label can be centred over its own clear of the plot edges, so every label can be centred over its own
point instead of being anchored outward to avoid an overflow. */} point instead of being anchored outward to avoid an overflow. */}
{data.map((d, i) => { {series.map((d, i) => {
const cx = xScale(i) const cx = xScale(i)
const cy = margin.top + yScale(d.arrests) const cy = margin.top + yScale(d.arrests)
const ratePerK = d.enroll > 0 ? (d.arrests / (d.enroll / 1000)).toFixed(2) : '0.00' const ratePerK = d.enroll > 0 ? (d.arrests / (d.enroll / 1000)).toFixed(2) : '0.00'
@@ -145,15 +210,17 @@ export default function ArrestsOverTime({ data, modelId }) {
<ChartLegend items={[ <ChartLegend items={[
{ shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'Observed' }, { shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'Observed' },
// 95%, not 90%: these bars are the API's count_lower/count_upper, and // 95%, not 90%: the API's count_lower/count_upper are a 95% interval
// validate_interval() defaults to 95 (the app never passes interval=). // (validate_interval() defaults to 95), and the draws-based total is
// computed at the same mass, so the two are directly comparable.
{ shape: 'dot', color: MODELED_AGGREGATE_COLOR, label: 'Modeled (median + 95% interval)' }, { shape: 'dot', color: MODELED_AGGREGATE_COLOR, label: 'Modeled (median + 95% interval)' },
]} /> ]} />
<TotalIntervalNote totals={totals} usingExact={usingExact} onRequest={onRequestExactTotals} />
<p style={{ fontSize: '0.75rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}> <p style={{ fontSize: '0.75rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
Rate per 1,000 students labeled above each observed point. The modeled total sums each Rate per 1,000 students labeled above each observed point. Data from CRDC waves 2015–16
selected group's median/interval independently, which approximates but is not exactly the through 2021–22.
median of the combined total. Data from CRDC waves 2015–16 through 2021–22.
</p> </p>
</div> </div>
) )
+34 -8
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import * as api from '../hooks/useApi.js' import * as api from '../hooks/useApi.js'
import { MODEL_QUADRANTS } from '../hooks/useApi.js' import { MODEL_QUADRANTS } from '../hooks/useApi.js'
import { useDrawDistribution } from '../hooks/useDrawDistribution.js' import { useDistrictTotalDraws, useDrawDistribution } from '../hooks/useDrawDistribution.js'
import { import {
buildDisplayGroups, buildDisplayGroups,
defaultDiffPair, defaultDiffPair,
@@ -20,6 +20,11 @@ const DEFAULT_SPEC = 'unified_m4_mod' // three-year + referral rate
const CURRENT_WAVE = '21-22' const CURRENT_WAVE = '21-22'
const QUADRANT_MODELS = MODEL_QUADRANTS.map((q) => q.model) const QUADRANT_MODELS = MODEL_QUADRANTS.map((q) => q.model)
// Below this, the draws for all three waves are fetched without asking. Sized
// from the real shards: Nevada's three waves are 0.31MB, California's 13.9MB
// and Texas's 14.3MB, so 3MB cleanly separates "free" from "worth a click".
const AUTO_TOTAL_DRAW_BYTES = 3 * 1024 * 1024
/** /**
* The results page: what was observed (summary table), what the model says the * The results page: what was observed (summary table), what the model says the
* rate is (density panel), and what it says about the gap between two groups * rate is (density panel), and what it says about the gap between two groups
@@ -43,6 +48,7 @@ export default function ChartPanel({ district, state }) {
// made in one mode must not be reapplied in the other. // made in one mode must not be reapplied in the other.
const [selection, setSelection] = useState(null) const [selection, setSelection] = useState(null)
const [pairSelection, setPairSelection] = useState(null) const [pairSelection, setPairSelection] = useState(null)
const [exactTotals, setExactTotals] = useState(false)
// ——— Fetch: the three waves for the time series ——— // ——— Fetch: the three waves for the time series ———
useEffect(() => { useEffect(() => {
@@ -54,6 +60,7 @@ export default function ChartPanel({ district, state }) {
setSelection(null) setSelection(null)
setPairSelection(null) setPairSelection(null)
setCompareAll(false) setCompareAll(false)
setExactTotals(false)
async function run() { async function run() {
const waves = {} const waves = {}
@@ -154,6 +161,22 @@ export default function ChartPanel({ district, state }) {
year: CURRENT_WAVE, year: CURRENT_WAVE,
}) })
// The time series' band, computed from the draws rather than by summing each
// group's own bounds. Fetched automatically only where it is cheap — three
// waves is 0.3MB for Nevada but 13.9MB for California — otherwise the chart
// offers it as an explicit choice with the size shown.
const totals = useDistrictTotalDraws({
leaid: district.leaid,
state,
model: WAVE_MODEL,
years: ALL_WAVES,
enabled: exactTotals,
})
useEffect(() => {
if (totals.bytes > 0 && totals.bytes <= AUTO_TOTAL_DRAW_BYTES) setExactTotals(true)
}, [totals.bytes])
if (loading || !waveData) return <LoadingCharts /> if (loading || !waveData) return <LoadingCharts />
const timeSeriesData = ALL_WAVES.map((year) => { const timeSeriesData = ALL_WAVES.map((year) => {
@@ -223,7 +246,12 @@ export default function ChartPanel({ district, state }) {
onPairChange={handlePairChange} onPairChange={handlePairChange}
/> />
<ArrestsOverTime data={timeSeriesData} modelId={WAVE_MODEL} /> <ArrestsOverTime
data={timeSeriesData}
modelId={WAVE_MODEL}
totals={totals}
onRequestExactTotals={() => setExactTotals(true)}
/>
</div> </div>
<div <div
@@ -244,18 +272,16 @@ export default function ChartPanel({ district, state }) {
<p style={{ fontSize: '0.85rem', color: 'var(--cv-ink-2)', marginTop: 'var(--space-2)' }}> <p style={{ fontSize: '0.85rem', color: 'var(--cv-ink-2)', marginTop: 'var(--space-2)' }}>
<strong>Student groups not shown.</strong> The CRDC also reports arrests for Asian, <strong>Student groups not shown.</strong> The CRDC also reports arrests for Asian,
Hawaiian/Pacific Islander, and multiracial students. The underlying research does not Hawaiian/Pacific Islander, and multiracial students. The underlying research does not
model these groups: it focuses on the four race/ethnicity groups where prior work has model these groups due to extreme sparsity in the outcome of interest and computational
documented the starkest disparities — Black and American Indian/Alaska Native students limits. Fitting every reported group would also have meant fitting 70
relative to White students — with Hispanic students included for their large and growing
share of U.S. enrollment. Fitting every reported group would also have meant fitting 70
stratified models rather than 40. Their absence here reflects that scope, not a finding stratified models rather than 40. Their absence here reflects that scope, not a finding
about those students, and no estimate for them should be inferred from this page. See the{' '} about those students, and no estimate for them should be inferred from this page. See the{' '}
<a href="https://crdc-api.civilytics.org/api/v1/" target="_blank" rel="noopener noreferrer"> <a href="https://crdc-api.civilytics.org/api/v1/" target="_blank" rel="noopener noreferrer">
white paper white paper
</a>{' '} </a>{' '}
for the full rationale. Arrests that cannot be disaggregated by both race and sex — for the full rationale. Arrests that cannot be disaggregated by both race and sex —
including Section 504 students — are excluded upstream, as are schools not enrolling including Section 504 students — are excluded upstream, as are the small number of arrests in
grade 7 or above. schools not enrolling grade 7 or above.
</p> </p>
</div> </div>
</div> </div>
+147 -9
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { getDb } from '../utils/duckdbClient.js' import { getDb } from '../utils/duckdbClient.js'
import { groupKey, isCompleteDrawSet } from '../utils/drawGroups.js' import { groupKey, isCompleteDrawSet } from '../utils/drawGroups.js'
import { totalInterval, totalPerDraw } from '../utils/districtTotal.js'
const HF_DATASET = 'civilytics/crdc-school-arrest-rates' const HF_DATASET = 'civilytics/crdc-school-arrest-rates'
const HF_BASE = `https://huggingface.co/datasets/${HF_DATASET}/resolve/main/parquet` const HF_BASE = `https://huggingface.co/datasets/${HF_DATASET}/resolve/main/parquet`
@@ -24,6 +25,57 @@ function shardDir(model, year, state) {
return `model_id=${model}/YEAR=${year}/LEA_STATE=${state}` return `model_id=${model}/YEAR=${year}/LEA_STATE=${state}`
} }
// Directory listings are ~1KB and carry each part's byte size, so the UI can
// tell the reader what a fetch will cost before committing to it. Cached
// separately from the buffers: listing a shard is cheap, downloading it is not.
const listingCache = new Map()
/**
* @returns {Promise<Array<{part: number, size: number}>>} parquet parts, in order
*/
function listShardEntries(model, year, state) {
const dir = shardDir(model, year, state)
if (!listingCache.has(dir)) {
listingCache.set(
dir,
(async () => {
try {
const res = await fetch(`${HF_TREE}/${dir}`)
if (!res.ok) throw new Error(`tree listing HTTP ${res.status}`)
const entries = await res.json()
return entries
.filter((e) => e?.type === 'file' && /\/data_\d+\.parquet$/.test(e.path || ''))
.map((e) => ({ part: Number(e.path.match(/data_(\d+)\.parquet$/)[1]), size: e.size || 0 }))
.sort((a, b) => a.part - b.part)
} catch (err) {
listingCache.delete(dir)
throw err
}
})(),
)
}
return listingCache.get(dir)
}
/**
* Total bytes of the draw shards for one model across several years — what a
* "compute this from the draws" action will actually download.
*
* @returns {Promise<number>} bytes, or 0 if the size can't be determined
*/
export async function measureShardBytes(model, years, state) {
try {
const sizes = await Promise.all(
years.map(async (year) =>
(await listShardEntries(model, year, state)).reduce((sum, p) => sum + p.size, 0),
),
)
return sizes.reduce((sum, n) => sum + n, 0)
} catch {
return 0
}
}
/** /**
* Lists the parquet parts published for one (model, year, state). * Lists the parquet parts published for one (model, year, state).
* *
@@ -42,13 +94,7 @@ function shardDir(model, year, state) {
async function listShardParts(model, year, state) { async function listShardParts(model, year, state) {
const dir = shardDir(model, year, state) const dir = shardDir(model, year, state)
try { try {
const res = await fetch(`${HF_TREE}/${dir}`) const parts = await listShardEntries(model, year, state)
if (!res.ok) throw new Error(`tree listing HTTP ${res.status}`)
const entries = await res.json()
const parts = entries
.filter((e) => e?.type === 'file' && /\/data_\d+\.parquet$/.test(e.path || ''))
.map((e) => ({ part: Number(e.path.match(/data_(\d+)\.parquet$/)[1]), path: e.path }))
.sort((a, b) => a.part - b.part)
if (parts.length > 0) return parts.map((p) => p.part) if (parts.length > 0) return parts.map((p) => p.part)
throw new Error('tree listing contained no parquet parts') throw new Error('tree listing contained no parquet parts')
} catch (err) { } catch (err) {
@@ -139,12 +185,18 @@ async function fetchModelCounts(db, { leaid, state, model, year }) {
// a short draw set: its density and interval would be computed off a // a short draw set: its density and interval would be computed off a
// biased subsample and look identical on screen to a complete one. // biased subsample and look identical on screen to a complete one.
const counts = {} const counts = {}
let dropped = 0
for (const [key, arr] of Object.entries(raw)) { for (const [key, arr] of Object.entries(raw)) {
if (isCompleteDrawSet(arr, nDraws)) counts[key] = arr if (isCompleteDrawSet(arr, nDraws)) counts[key] = arr
else console.warn('useDrawDistribution: dropping incomplete draw set for', { leaid, model, key, got: arr.length, want: nDraws }) else {
dropped += 1
console.warn('useDrawDistribution: dropping incomplete draw set for', { leaid, model, key, got: arr.length, want: nDraws })
}
} }
return Object.keys(counts).length > 0 ? { counts, nDraws } : null // `dropped` matters to any consumer that aggregates *across* groups (the
// district total): a missing group there is an undercount, not a gap.
return Object.keys(counts).length > 0 ? { counts, nDraws, dropped } : null
} finally { } finally {
if (conn) await conn.close() if (conn) await conn.close()
} }
@@ -257,3 +309,89 @@ export function useDrawDistribution({ leaid, state, models, year }) {
return { status, byModel, nDraws } return { status, byModel, nDraws }
} }
/**
* The district's total arrest count per wave, with a 95% interval computed from
* the draws — summed within each draw, then summarized across draws.
*
* Gated behind `enabled` because the cost is wildly uneven: three waves of
* Nevada is 0.3MB, but California is 13.9MB and Texas 14.3MB. `bytes` is
* probed from the directory listings up front (~1KB per year) so the caller can
* either fetch automatically when it's cheap or show the reader the price
* first.
*
* @param {{leaid: string, state: string, model: string, years: string[], enabled: boolean}} params
* @returns {{status: 'idle'|'loading'|'ready'|'error',
* byYear: Record<string, {lower:number, median:number, upper:number, nDraws:number}> | null,
* bytes: number}}
*/
export function useDistrictTotalDraws({ leaid, state, model, years, enabled }) {
const [status, setStatus] = useState('idle')
const [byYear, setByYear] = useState(null)
const [bytes, setBytes] = useState(0)
const yearsSignature = (years || []).join(',')
// Probe sizes regardless of `enabled` — this is what lets the UI decide.
useEffect(() => {
let cancelled = false
setBytes(0)
if (!state || !model || !yearsSignature) return
measureShardBytes(model, yearsSignature.split(','), state).then((n) => {
if (!cancelled) setBytes(n)
})
return () => {
cancelled = true
}
}, [state, model, yearsSignature])
useEffect(() => {
let cancelled = false
setStatus(enabled ? 'loading' : 'idle')
setByYear(null)
if (!enabled || !leaid || !state || !model || !yearsSignature) return
async function run() {
try {
const db = await getDb()
const yearList = yearsSignature.split(',')
const results = await Promise.all(
yearList.map(async (year) => {
try {
const model_ = await fetchModelCounts(db, { leaid, state, model, year })
if (!model_ || model_.dropped > 0) return [year, null]
const interval = totalInterval(totalPerDraw(model_.counts, model_.nDraws))
return [year, interval]
} catch (err) {
console.error(`useDistrictTotalDraws: ${year} failed:`, err)
return [year, null]
}
}),
)
if (cancelled) return
const next = Object.fromEntries(results)
if (Object.values(next).some((v) => v !== null)) {
setByYear(next)
setStatus('ready')
} else {
setByYear(null)
setStatus('error')
}
} catch (err) {
console.error('useDistrictTotalDraws failed:', err)
if (!cancelled) {
setByYear(null)
setStatus('error')
}
}
}
run()
return () => {
cancelled = true
}
}, [leaid, state, model, yearsSignature, enabled])
return { status, byYear, bytes }
}
+104
View File
@@ -0,0 +1,104 @@
/**
* The district-wide arrest total, taken from the posterior predictive draws.
*
* Why this exists: the time-series chart used to draw its band by summing each
* student group's own `count_lower`/`count_upper`. That is not the interval of
* the total. The sum of per-group 97.5th percentiles is the value you would see
* if *every* group simultaneously landed at its own extreme in the same draw,
* which is far less likely than any one group doing so — so the band came out
* systematically too wide. Summing within each draw and taking quantiles of the
* resulting totals answers the actual question: how many arrests does the model
* think this district had?
*
* This is `build_state_summary()`'s method from crdc-arrests/R/summarize_draws.R
* applied on a different axis — sum inside the draw, summarize across draws —
* the same operation `poolBySex` performs for sex pooling.
*
* One caveat to carry into any caption. Upstream, `draw_id` is renumbered per
* write batch, so draw *k* of one group is not the same posterior sample as
* draw *k* of another; measured cross-group correlation is ≈ 0.02. Summing at a
* draw index therefore convolves what are effectively independent draws. That
* is a reasonable model here — these are *posterior predictive* draws, whose
* observation noise is independent across groups by construction and dominates
* the parameter-level covariance — but it does mean the result should be read
* as a predictive total, not as a contrast that preserves parameter
* correlation. It is still much closer to the truth than summing bounds.
*/
import { quantile } from './kde.js'
import { isCompleteDrawSet } from './drawGroups.js'
/** Matches the API's default interval, so the two are directly comparable. */
export const TOTAL_INTERVAL_MASS = 0.95
/**
* District total at each draw index: the sum across every student group.
*
* Returns null unless *every* group has a complete draw set. A group silently
* missing from the sum would undercount the total at every draw and shift the
* whole interval down, which is indistinguishable on screen from a real result.
*
* @param {Record<string, number[]> | null | undefined} countsByGroup
* @param {number} nDraws
* @returns {number[] | null}
*/
export function totalPerDraw(countsByGroup, nDraws) {
const groups = Object.values(countsByGroup || {})
if (!groups.length || !(nDraws > 0)) return null
if (!groups.every((counts) => isCompleteDrawSet(counts, nDraws))) return null
const totals = new Array(nDraws).fill(0)
for (const counts of groups) {
for (let i = 0; i < nDraws; i++) totals[i] += counts[i]
}
return totals
}
/**
* Narrowest interval containing `mass` of the values — the highest-density
* interval, matching what the API stores.
*
* The API's `count_lower`/`count_upper` are HPD bounds (`hpd_bounds_sql` in
* crdc-arrests/R/summarize_draws.R), not equal-tailed quantiles, and the white
* paper's figures use the same. Computing the total's interval the same way
* keeps one definition of "95% interval" on the page: the difference is only
* two or three arrests on a Clark County band of ~50, but the chart's fallback
* mode shows summed HPD bounds, and mixing conventions between the two modes
* would be a distinction with no explanation.
*
* Contiguous by construction, as the R original is. For a strongly bimodal
* posterior that is a simplification, but a count total summed across eight
* groups is unimodal in practice.
*
* @param {number[]} values
* @param {number} mass - e.g. 0.95
* @returns {[number, number]}
*/
export function hpdBounds(values, mass) {
const sorted = [...values].sort((a, b) => a - b)
const n = sorted.length
const span = Math.ceil(mass * n) - 1
if (span <= 0) return [sorted[0], sorted[0]]
if (span >= n - 1) return [sorted[0], sorted[n - 1]]
let best = [sorted[0], sorted[span]]
let bestWidth = sorted[span] - sorted[0]
for (let i = 1; i + span < n; i++) {
const width = sorted[i + span] - sorted[i]
if (width < bestWidth) {
bestWidth = width
best = [sorted[i], sorted[i + span]]
}
}
return best
}
/**
* @param {number[] | null | undefined} totals - district total per draw
* @returns {{lower: number, median: number, upper: number, nDraws: number} | null}
*/
export function totalInterval(totals) {
if (!totals?.length) return null
const [lower, upper] = hpdBounds(totals, TOTAL_INTERVAL_MASS)
return { lower, median: quantile(totals, 0.5), upper, nDraws: totals.length }
}
+139
View File
@@ -0,0 +1,139 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { TOTAL_INTERVAL_MASS, hpdBounds, totalInterval, totalPerDraw } from './districtTotal.js'
// ——— totalPerDraw ———
test('totalPerDraw: sums every group at the same draw index', () => {
const counts = { BL_F: [1, 2, 3], BL_M: [10, 20, 30], WH_F: [100, 200, 300] }
assert.deepEqual(totalPerDraw(counts, 3), [111, 222, 333])
})
test('totalPerDraw: a single group is its own total', () => {
assert.deepEqual(totalPerDraw({ BL_M: [4, 5] }, 2), [4, 5])
})
test('totalPerDraw: null when any group is short of nDraws', () => {
// Summing a 2-draw group into a 3-draw total would silently undercount the
// last draw, biasing the whole interval downward.
assert.equal(totalPerDraw({ BL_F: [1, 2, 3], BL_M: [1, 2] }, 3), null)
})
test('totalPerDraw: null when a group has a hole', () => {
const holey = [1, 2, 3]
delete holey[1]
assert.equal(totalPerDraw({ BL_F: holey }, 3), null)
})
test('totalPerDraw: null for empty or missing input', () => {
assert.equal(totalPerDraw({}, 500), null)
assert.equal(totalPerDraw(null, 500), null)
assert.equal(totalPerDraw({ BL_F: [1] }, 0), null)
})
test('totalPerDraw: does not mutate its input', () => {
const counts = { BL_F: [1, 2], BL_M: [3, 4] }
totalPerDraw(counts, 2)
assert.deepEqual(counts, { BL_F: [1, 2], BL_M: [3, 4] })
})
// ——— totalInterval ———
test('totalInterval: median and 95% bounds from the draw totals', () => {
// Symmetric and unimodal, so the HPD should sit close to the equal-tailed
// 25–975 without being required to equal it.
const totals = []
for (let i = 0; i < 1000; i++) {
totals.push(500 + 120 * (Math.sin(i * 1.7) + Math.sin(i * 0.31) + Math.sin(i * 2.9)) / 3)
}
const iv = totalInterval(totals)
assert.equal(iv.nDraws, 1000)
assert.ok(iv.lower < iv.median && iv.median < iv.upper)
assert.ok(Math.abs(iv.median - 500) < 25, `median ${iv.median}`)
})
test('totalInterval: bounds are the narrowest window covering 95% of draws', () => {
const totals = Array.from({ length: 1000 }, (_, i) => i)
const iv = totalInterval(totals)
const inside = totals.filter((t) => t >= iv.lower && t <= iv.upper).length
assert.ok(inside >= 950, `only ${inside} of 1000 draws inside`)
// A uniform has no denser region, so the narrowest window is ~95% of the range.
assert.ok(iv.upper - iv.lower <= 951, `width ${iv.upper - iv.lower}`)
})
test('hpdBounds: never wider than the equal-tailed interval', () => {
// Right-skewed, which is where the two definitions diverge most.
const skewed = Array.from({ length: 2000 }, (_, i) => Math.round(((i * 7919) % 1000) ** 1.6 / 1000))
const [lo, hi] = hpdBounds(skewed, 0.95)
const sorted = [...skewed].sort((a, b) => a - b)
const etLo = sorted[Math.floor(0.025 * (sorted.length - 1))]
const etHi = sorted[Math.ceil(0.975 * (sorted.length - 1))]
assert.ok(hi - lo <= etHi - etLo, `hpd ${hi - lo} vs equal-tailed ${etHi - etLo}`)
})
test('hpdBounds: stays in the dense region when it holds enough mass', () => {
// 970 draws at 0-9 and 30 stragglers out at 500. The cluster alone covers 97%,
// so the narrowest 95% window fits inside it and the far tail is excluded.
const values = [
...Array.from({ length: 970 }, (_, i) => i % 10),
...Array.from({ length: 30 }, () => 500),
]
const [lo, hi] = hpdBounds(values, 0.95)
assert.equal(lo, 0)
assert.ok(hi < 500, `upper bound ${hi} should exclude the far cluster`)
})
test('hpdBounds: still reaches the tail when the cluster is too small', () => {
// The mirror case, and the honest one: 90% in the cluster cannot cover a 95%
// interval, so the bound must extend outward rather than under-covering.
const values = [
...Array.from({ length: 900 }, (_, i) => i % 10),
...Array.from({ length: 100 }, () => 500),
]
const [, hi] = hpdBounds(values, 0.95)
assert.equal(hi, 500)
})
test('hpdBounds: degenerate input collapses to a point', () => {
assert.deepEqual(hpdBounds(new Array(100).fill(7), 0.95), [7, 7])
})
test('hpdBounds: does not mutate its input', () => {
const values = [5, 1, 3]
hpdBounds(values, 0.95)
assert.deepEqual(values, [5, 1, 3])
})
test('totalInterval: uses a 95% mass, matching the API convention', () => {
assert.equal(TOTAL_INTERVAL_MASS, 0.95)
})
test('totalInterval: lower <= median <= upper', () => {
const totals = Array.from({ length: 500 }, (_, i) => (i * 7919) % 331)
const iv = totalInterval(totals)
assert.ok(iv.lower <= iv.median && iv.median <= iv.upper)
})
test('totalInterval: a degenerate total collapses to a point', () => {
const iv = totalInterval(new Array(500).fill(12))
assert.deepEqual([iv.lower, iv.median, iv.upper], [12, 12, 12])
})
test('totalInterval: null for empty or missing totals', () => {
assert.equal(totalInterval([]), null)
assert.equal(totalInterval(null), null)
})
test('totalInterval: is narrower than summing the groups own bounds', () => {
// The property that motivates this whole path. Two independent groups, each
// roughly uniform on 0..100: summing each group's 97.5th percentile gives
// ~200, but the 97.5th percentile of the *sum* is well below that, because
// both groups landing at their extreme in the same draw is rare.
const n = 4000
const a = Array.from({ length: n }, (_, i) => (i * 37) % 101)
const b = Array.from({ length: n }, (_, i) => (i * 61) % 101)
const iv = totalInterval(totalPerDraw({ A: a, B: b }, n))
const summedBounds = { lower: 0, upper: 100 + 100 }
assert.ok(iv.upper < summedBounds.upper, `sum-of-quantiles ${summedBounds.upper} vs quantile-of-sum ${iv.upper}`)
assert.ok(iv.upper - iv.lower < summedBounds.upper - summedBounds.lower)
})
+17 -2
View File
@@ -10,15 +10,22 @@ import { quantile } from './kde.js'
import { niceTicks } from './niceTicks.js' import { niceTicks } from './niceTicks.js'
/** /**
* Hard ceiling on the axis, inherited from the chart this replaces. * Hard ceiling on the axis.
* *
* A four-student cell can produce posterior draws in the hundreds per 1,000; * A four-student cell can produce posterior draws in the hundreds per 1,000;
* letting those set the axis squashes every other group into the leftmost few * letting those set the axis squashes every other group into the leftmost few
* pixels. `computeRateDomain` reports `clipped` whenever the cap actually * pixels. `computeRateDomain` reports `clipped` whenever the cap actually
* binds, so the chart can say so rather than cropping data off the edge in * binds, so the chart can say so rather than cropping data off the edge in
* silence. * silence.
*
* Set to 100 per 1,000 (one arrest per ten students). The previous value of
* 30 was exceeded by over 60% of districts — mostly sparse groups with small
* enrollment cells whose Agresti–Coull upper bounds genuinely extend that far.
* A cap of 100 still guards against the most degenerate cases (e.g. a single
* predicted arrest in a four-student cell yielding ~250 per 1,000) while
* letting realistic data drive the axis for the vast majority of districts.
*/ */
export const MAX_RATE_DOMAIN = 30 export const MAX_RATE_DOMAIN = 100
/** /**
* Ignore the very top of each draw set when sizing the axis. The posterior * Ignore the very top of each draw set when sizing the axis. The posterior
@@ -30,6 +37,14 @@ const DOMAIN_QUANTILE = 0.995
const HEADROOM = 1.15 const HEADROOM = 1.15
/** /**
* Computes a shared x-domain for the rate density and over-time charts.
*
* The domain covers everything that will actually be drawn: the 0.995
* quantile of each group's posterior-predictive draw rates, plus every
* Agresti–Coull upper bound from selected groups. `niceMax` is a clean
* "nice number" ceiling (via `niceTicks`) that may exceed the raw data max by
* up to one tick step, and is capped at `MAX_RATE_DOMAIN`.
*
* @param {Array<number[]>} rateArrays - per-group draw rates, per 1,000 * @param {Array<number[]>} rateArrays - per-group draw rates, per 1,000
* @param {number[]} acUpperRates - Agresti–Coull upper bounds, per 1,000 * @param {number[]} acUpperRates - Agresti–Coull upper bounds, per 1,000
* @returns {{ticks: number[], niceMax: number, clipped: boolean}} * @returns {{ticks: number[], niceMax: number, clipped: boolean}}
+25 -3
View File
@@ -23,16 +23,27 @@ test('computeRateDomain: one extreme draw does not blow out the axis', () => {
assert.ok(domain.niceMax < 10, `niceMax was ${domain.niceMax}`) assert.ok(domain.niceMax < 10, `niceMax was ${domain.niceMax}`)
}) })
test('computeRateDomain: caps the domain and reports the clip', () => { test('computeRateDomain: caps at MAX_RATE_DOMAIN=100 and reports the clip', () => {
// The cap keeps a tiny-denominator group from flattening every other curve, // The cap keeps a tiny-denominator group from flattening every other curve,
// but the caller has to be able to say so on screen rather than silently // but the caller has to be able to say so on screen rather than silently
// cropping data off the right edge. // cropping data off the right edge.
const wide = new Array(500).fill(0).map((_, i) => (i < 490 ? 60 : 0)) const wide = new Array(500).fill(0).map((_, i) => (i < 490 ? 200 : 0))
const domain = computeRateDomain([wide], [80]) const domain = computeRateDomain([wide], [300])
assert.equal(domain.niceMax, MAX_RATE_DOMAIN) assert.equal(domain.niceMax, MAX_RATE_DOMAIN)
assert.equal(domain.clipped, true) assert.equal(domain.clipped, true)
}) })
test('computeRateDomain: realistic sparse district data is not clipped', () => {
// Carson City NV (LEAID 3200390): pooled BL cell with 34 students and 0
// observed arrests → AC upper bound ≈ 88 per 1,000. This should NOT be
// clipped under the new cap of 100.
const blRates = new Array(500).fill(0)
const domain = computeRateDomain([blRates], [88])
assert.ok(domain.niceMax >= 88, `niceMax ${domain.niceMax} should cover AC bound of 88`)
assert.equal(domain.clipped, false)
})
test('computeRateDomain: never returns a zero-width domain', () => { test('computeRateDomain: never returns a zero-width domain', () => {
const domain = computeRateDomain([[0, 0, 0]], [0]) const domain = computeRateDomain([[0, 0, 0]], [0])
assert.ok(domain.niceMax > 0) assert.ok(domain.niceMax > 0)
@@ -49,3 +60,14 @@ test('computeRateDomain: ignores empty rate arrays', () => {
const domain = computeRateDomain([[], [1, 2, 3]], []) const domain = computeRateDomain([[], [1, 2, 3]], [])
assert.ok(domain.niceMax > 0 && domain.niceMax < MAX_RATE_DOMAIN) assert.ok(domain.niceMax > 0 && domain.niceMax < MAX_RATE_DOMAIN)
}) })
test('computeRateDomain: degenerate tiny-denominator cell is clipped', () => {
// A four-student cell with a single predicted arrest yields ~250 per
// 1,000 — this should be capped at MAX_RATE_DOMAIN=100 and flagged as
// clipped so the chart can warn the reader.
const tinyRates = new Array(500).fill(0)
for (let i = 0; i < 250; i++) tinyRates[i] = 250
const domain = computeRateDomain([tinyRates], [300])
assert.equal(domain.niceMax, MAX_RATE_DOMAIN)
assert.equal(domain.clipped, true)
})