fix: gate the "real posterior draws" claim on per-group coverage

useDrawDistribution's status is an any-group signal -- 'ready' means at least
one group came back with draws -- but both charts used it chart-wide to hide
<ApproxNote /> and print a caption claiming every box/ridge is real draws.
Individual boxes and ridges already fall back per-group, so the note and
caption were the only things over-claiming.

This is reachable with real data, not just in theory: for LEAID 0400311 (AZ,
unified_m4_mod) the estimates API returns all 8 race x sex groups but the
parquet shard contains draws for WH_M only. The chart reported 'ready' and hid
the note while 7 of its 8 ridges were the analytic approximation.

New src/utils/drawGroups.js owns the "RACE_SEX" key format (previously
duplicated across the hook and both charts) and a hasDrawsForAll() coverage
check. Each chart now checks the groups it actually renders -- RACE_ORDER x its
sex panels/columns -- rather than everything the API returned. A null map
(loading, or a fetch that failed outright) fails the check, so the error path
still shows the note.

Also fixes a stale-state hazard in the same hook: the input guard ran before
setStatus('loading')/setDrawsByGroup(null), so switching to a model whose
groups list is empty (that model's upstream fetch failed) left status at
'ready' with the previous model's draws still in state. Verified by
instrumenting the hook: with the old ordering, switching from a ready model to
an empty one kept status 'ready' and all 8 previous draw keys; with the reset
moved above the guard it correctly resets to 'loading' with no draws.
This commit is contained in:
2026-08-11 10:37:15 -04:00
parent 138a083c6f
commit 70a12cad22
5 changed files with 128 additions and 18 deletions
+16 -4
View File
@@ -2,6 +2,7 @@ import ChartLegend from '../components/ChartLegend.jsx'
import ApproxNote from '../components/ApproxNote.jsx'
import { raceColor, OBSERVED_MARK_COLOR, SHORT_RACE_LABEL } from '../utils/colors.js'
import { fitSkewedInterval } from '../utils/distributionApprox.js'
import { groupKey, hasDrawsForAll } from '../utils/drawGroups.js'
import { quantile } from '../utils/kde.js'
import { niceTicks } from '../utils/niceTicks.js'
import { useDrawDistribution } from '../hooks/useDrawDistribution.js'
@@ -34,7 +35,18 @@ function buildBox(d, draws) {
export default function RateByGroupBar({ data, leaid, state }) {
const groups = data.map((d) => ({ race: d.race, sex: d.sex, stuEnroll: d.enrollment }))
const { status, drawsByGroup } = useDrawDistribution({ leaid, state, model: MODEL, year: '21-22', groups })
const { drawsByGroup } = useDrawDistribution({ leaid, state, model: MODEL, year: '21-22', groups })
// Only the RACE_ORDER × SEX_PANELS cells are actually drawn, so coverage is
// judged against those — not against everything the API returned. The hook's
// 'ready' status is an any-group signal and would over-claim here: individual
// boxes still fall back to fitSkewedInterval whenever their group is missing
// from the shard. A null map (loading, or the whole fetch failed) fails this
// check too, so the error path still shows the note.
const renderedGroups = data.filter(
(d) => RACE_ORDER.includes(d.race) && SEX_PANELS.some((p) => p.sex === d.sex),
)
const allGroupsHaveDraws = hasDrawsForAll(drawsByGroup, renderedGroups)
const maxRate = Math.max(
...data.map((d) => Math.max(d.observedRate, d.rateUpper ?? d.modeledMedian ?? 0)),
@@ -47,7 +59,7 @@ export default function RateByGroupBar({ data, leaid, state }) {
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
Arrest rate by student group — 2021–22 (per 1,000)
</h3>
{status !== 'ready' && <ApproxNote />}
{!allGroupsHaveDraws && <ApproxNote />}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)', marginTop: 'var(--space-2)' }}>
{SEX_PANELS.map(({ sex, label }) => (
@@ -70,7 +82,7 @@ export default function RateByGroupBar({ data, leaid, state }) {
]} />
<p style={{ fontSize: '0.72rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
{status === 'ready'
{allGroupsHaveDraws
? "Box = 25th–75th percentile of 500 real posterior draws; whisker = the model's reported 90% interval; white tick = median."
: "Box = modeled 25th–75th percentile (fitted approximation); whisker = the model's reported 90% interval; white tick = median."}
{' '}The dark diamond is the observed rate.
@@ -115,7 +127,7 @@ function SexPanel({ label, rows, drawsByGroup, ticks, niceMax }) {
const boxTop = midY - ROW_HEIGHT * 0.26
const boxBottom = midY + ROW_HEIGHT * 0.26
const color = raceColor(d.race)
const draws = drawsByGroup?.[`${d.race}_${d.sex}`]
const draws = drawsByGroup?.[groupKey(d.race, d.sex)]
const box = buildBox(d, draws)
const observedX = xScale(d.observedRate)
+15 -3
View File
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { MODEL_QUADRANTS } from '../hooks/useApi.js'
import { raceColor, OBSERVED_MARK_COLOR, RACE_LABELS, SHORT_RACE_LABEL } from '../utils/colors.js'
import { fitSkewedInterval, densityCurve } from '../utils/distributionApprox.js'
import { groupKey, hasDrawsForAll } from '../utils/drawGroups.js'
import { kdeCurve } from '../utils/kde.js'
import { niceTicks } from '../utils/niceTicks.js'
import { useDrawDistribution } from '../hooks/useDrawDistribution.js'
@@ -39,7 +40,18 @@ export default function RateDensityRidgeline({ quadData, leaid, state }) {
const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL)
const rows = (quadData && quadData[selectedModel]) || []
const groups = rows.map((r) => ({ race: r.race, sex: r.sex, stuEnroll: r.stu_enroll || 0 }))
const { status, drawsByGroup } = useDrawDistribution({ leaid, state, model: selectedModel, year: '21-22', groups })
const { drawsByGroup } = useDrawDistribution({ leaid, state, model: selectedModel, year: '21-22', groups })
// Only the RACE_ORDER × SEX_COLUMNS cells get a ridge, so coverage is judged
// against those. The hook's 'ready' status is an any-group signal: individual
// ridges still fall back to densityCurve when their group is missing from the
// shard, so gating the note on `status` alone would hide it while part of the
// chart is an approximation. A null map (loading or a failed fetch) also fails
// this check, so the error path still shows the note.
const renderedGroups = rows.filter(
(r) => RACE_ORDER.includes(r.race) && SEX_COLUMNS.some((c) => c.sex === r.sex),
)
const allGroupsHaveDraws = hasDrawsForAll(drawsByGroup, renderedGroups)
const modelSelect = (
<select
@@ -60,7 +72,7 @@ export default function RateDensityRidgeline({ quadData, leaid, state }) {
<h3 style={{ fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' }}>
Predicted arrest rates by student group
</h3>
{status !== 'ready' && <ApproxNote />}
{!allGroupsHaveDraws && <ApproxNote />}
</div>
{modelSelect}
</div>
@@ -109,7 +121,7 @@ function SexRidgeColumn({ label, rows, drawsByGroup, ticks, maxRate }) {
const groups = RACE_ORDER
.map((race) => rows.find((r) => r.race === race))
.filter(Boolean)
.map((row) => buildGroupRow(row, drawsByGroup?.[`${row.race}_${row.sex}`]))
.map((row) => buildGroupRow(row, drawsByGroup?.[groupKey(row.race, row.sex)]))
const width = 300
const rowHeight = 58
+23 -11
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { getDb } from '../utils/duckdbClient.js'
import { groupKey } from '../utils/drawGroups.js'
const HF_BASE = 'https://huggingface.co/datasets/civilytics/crdc-school-arrest-rates/resolve/main/parquet'
@@ -45,7 +46,13 @@ function ensureShardRegistered(db, model, year, state) {
* Face parquet dataset via duckdb-wasm, converts predicted counts to
* rate-per-1,000 using each group's stu_enroll (not present in the draws
* table itself — joined here from data this app already has), and returns
* them keyed by "RACE_SEX".
* them keyed by "RACE_SEX" (see `groupKey` in utils/drawGroups.js).
*
* `status` is an ANY-group signal: 'ready' means at least one group came back
* with real draws, not that every requested group did. Groups can be missing
* individually (falsy enrollment, or absent from the parquet shard), so a caller
* that wants to claim "these are all real draws" must check its own rendered
* groups against `drawsByGroup` — use `hasDrawsForAll` from utils/drawGroups.js.
*
* @param {{leaid: string, state: string, model: string, year: string,
* groups: Array<{race: string, sex: string, stuEnroll: number}>}} params
@@ -60,18 +67,23 @@ export function useDrawDistribution({ leaid, state, model, year, groups }) {
const groupsSignature = (groups || []).map((g) => `${g.race}:${g.sex}:${g.stuEnroll}`).join(',')
useEffect(() => {
if (!leaid || !state || !model || !year || !groups?.length) return
let cancelled = false
// Reset BEFORE the input guard below, not after. Clearing any previous
// model/district's draws has to happen on every input change, including the
// ones that have nothing to fetch. Without this ordering, a chart that
// varies `model` across renders (e.g. RateDensityRidgeline's dropdown) and
// lands on a model whose `groups` is empty (that model's upstream fetch
// failed) would keep reporting 'ready' and keep handing back the *previous*
// model's real draws — keyed by the same RACE_SEX strings — under the newly
// selected model's summary stats, silently mixing two models' data.
setStatus('loading')
// Clear any previous model/district's draws immediately, not just on
// success/failure below. Without this, a chart that varies `model`
// across renders (e.g. RateDensityRidgeline's dropdown) would keep
// rendering the *previous* model's real draws — keyed by the same
// RACE_SEX strings — under the newly-selected model's summary stats
// while this fetch is in flight or if it fails, silently mixing data
// from two different models.
setDrawsByGroup(null)
// Nothing to fetch: stay in 'loading' with no draws, which every consumer
// already treats as "fall back to the approximation". No cleanup needed —
// nothing async was started.
if (!leaid || !state || !model || !year || !groups?.length) return
async function run() {
let conn
try {
@@ -84,11 +96,11 @@ export function useDrawDistribution({ leaid, state, model, year, groups }) {
const rows = table.toArray().map((r) => r.toJSON())
const enrollByGroup = {}
for (const g of groups) enrollByGroup[`${g.race}_${g.sex}`] = g.stuEnroll || 0
for (const g of groups) enrollByGroup[groupKey(g.race, g.sex)] = g.stuEnroll || 0
const byGroup = {}
for (const row of rows) {
const key = `${row.RACE}_${row.SEX}`
const key = groupKey(row.RACE, row.SEX)
const enroll = enrollByGroup[key]
if (!enroll) continue
const rate = (Number(row.pred) / enroll) * 1000
+33
View File
@@ -0,0 +1,33 @@
/**
* Shared key format and coverage check for the per-group posterior draws
* returned by `useDrawDistribution`. Both the hook (which builds the map) and
* the charts (which read it, and decide whether to claim "real draws") go
* through here so the key format lives in exactly one place.
*/
/** Canonical key for one race×sex group in a `drawsByGroup` map. */
export function groupKey(race, sex) {
return `${race}_${sex}`
}
/**
* True only when *every* group in `groups` has a non-empty draw array.
*
* `useDrawDistribution`'s `status` is an any-group signal: it reports 'ready'
* as soon as one group has real draws. Charts fall back per-group, so the
* chart-wide "these are real posterior draws" note/caption must be gated on
* complete coverage instead — a group can be missing because its enrollment is
* falsy or because its (LEAID, RACE, SEX) isn't in the parquet shard.
*
* Returns false for an empty group list (nothing rendered means nothing to
* claim) and for a null map (loading, or the fetch failed outright).
*
* @param {Record<string, number[]> | null | undefined} drawsByGroup
* @param {Array<{race: string, sex: string}>} groups - the groups a chart is
* actually rendering, not everything the API returned.
* @returns {boolean}
*/
export function hasDrawsForAll(drawsByGroup, groups) {
if (!drawsByGroup || !groups?.length) return false
return groups.every((g) => (drawsByGroup[groupKey(g.race, g.sex)]?.length ?? 0) > 0)
}
+41
View File
@@ -0,0 +1,41 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { groupKey, hasDrawsForAll } from './drawGroups.js'
test('groupKey: joins race and sex with an underscore', () => {
assert.equal(groupKey('BL', 'F'), 'BL_F')
})
test('hasDrawsForAll: true when every rendered group has draws', () => {
const map = { WH_F: [1, 2], BL_F: [3] }
assert.equal(hasDrawsForAll(map, [{ race: 'WH', sex: 'F' }, { race: 'BL', sex: 'F' }]), true)
})
test('hasDrawsForAll: false when one rendered group is missing', () => {
// The any-group 'ready' status would still be true here — this is exactly the
// case where the chart must keep showing the approximation note.
const map = { WH_F: [1, 2] }
assert.equal(hasDrawsForAll(map, [{ race: 'WH', sex: 'F' }, { race: 'BL', sex: 'F' }]), false)
})
test('hasDrawsForAll: false when a rendered group has an empty draw array', () => {
const map = { WH_F: [1, 2], BL_F: [] }
assert.equal(hasDrawsForAll(map, [{ race: 'WH', sex: 'F' }, { race: 'BL', sex: 'F' }]), false)
})
test('hasDrawsForAll: false for a null map (loading or failed fetch)', () => {
assert.equal(hasDrawsForAll(null, [{ race: 'WH', sex: 'F' }]), false)
assert.equal(hasDrawsForAll(undefined, [{ race: 'WH', sex: 'F' }]), false)
})
test('hasDrawsForAll: false for an empty or missing group list', () => {
assert.equal(hasDrawsForAll({ WH_F: [1] }, []), false)
assert.equal(hasDrawsForAll({ WH_F: [1] }, undefined), false)
})
test('hasDrawsForAll: ignores groups the chart is not rendering', () => {
// Extra keys in the map (e.g. a race outside RACE_ORDER) must not block the
// claim for the groups actually on screen.
const map = { WH_F: [1], BL_F: [2], AS_F: [3] }
assert.equal(hasDrawsForAll(map, [{ race: 'WH', sex: 'F' }, { race: 'BL', sex: 'F' }]), true)
})