diff --git a/src/charts/RateByGroupBar.jsx b/src/charts/RateByGroupBar.jsx
index c47d467..0c8a0fb 100644
--- a/src/charts/RateByGroupBar.jsx
+++ b/src/charts/RateByGroupBar.jsx
@@ -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 }) {
Arrest rate by student group — 2021–22 (per 1,000)
- {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)
diff --git a/src/charts/RateDensityRidgeline.jsx b/src/charts/RateDensityRidgeline.jsx
index a6abe7b..9e79910 100644
--- a/src/charts/RateDensityRidgeline.jsx
+++ b/src/charts/RateDensityRidgeline.jsx
@@ -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 = (
{modelSelect}
@@ -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
diff --git a/src/hooks/useDrawDistribution.js b/src/hooks/useDrawDistribution.js
index ffe15fd..78e08d8 100644
--- a/src/hooks/useDrawDistribution.js
+++ b/src/hooks/useDrawDistribution.js
@@ -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
diff --git a/src/utils/drawGroups.js b/src/utils/drawGroups.js
new file mode 100644
index 0000000..f7a6966
--- /dev/null
+++ b/src/utils/drawGroups.js
@@ -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 | 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)
+}
diff --git a/src/utils/drawGroups.test.js b/src/utils/drawGroups.test.js
new file mode 100644
index 0000000..377f2a3
--- /dev/null
+++ b/src/utils/drawGroups.test.js
@@ -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)
+})