diff --git a/package-lock.json b/package-lock.json index b6789ec..bf967ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,12 @@ "name": "crdc-arrests-demo", "version": "0.1.0", "license": "MIT", + "dependencies": { + "d3-array": "^3.2.4", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0" + }, "devDependencies": { "@duckdb/duckdb-wasm": "^1.32.0", "@vitejs/plugin-react-swc": "^4.3.3", @@ -1001,6 +1007,109 @@ "node": ">= 8" } }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1480,6 +1589,15 @@ "dev": true, "license": "ISC" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", diff --git a/package.json b/package.json index b61930d..6b4c45e 100644 --- a/package.json +++ b/package.json @@ -27,5 +27,11 @@ "react-dom": "^19.2.8", "vite": "^8.2.1" }, - "type": "module" + "type": "module", + "dependencies": { + "d3-array": "^3.2.4", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0" + } } diff --git a/src/charts/GroupDifference.jsx b/src/charts/GroupDifference.jsx new file mode 100644 index 0000000..0685c9b --- /dev/null +++ b/src/charts/GroupDifference.jsx @@ -0,0 +1,394 @@ +import { useMemo } from 'react' +import { area, curveMonotoneX, curveStep } from 'd3-shape' +import { scaleLinear } from 'd3-scale' +import { interpolateRgb } from 'd3-interpolate' +import { rateProfile } from '../utils/densityProfile.js' +import { differenceRates, differenceSummary } from '../utils/groupDifference.js' +import { displayDraws } from '../utils/districtGroups.js' +import { niceTicks } from '../utils/niceTicks.js' + +/** + * Chart B — "Model estimated differences". + * + * Port of the white paper's Fig 7 (`wp_fig_group_difference`): the posterior + * distribution of Δ = rate(A) − rate(B), per 1,000 students, computed at each + * draw index. + * + * Two deliberate departures from the R figure: + * + * - The fill is a **diverging** ramp centred at zero (navy below, paper at + * zero, ember above) rather than the paper's sequential YlOrRd. Δ is a signed + * quantity; a sequential ramp encodes "more" where the data means "which + * direction", and would make a large negative difference read as a small one. + * - The readout is spelled out in a sentence. `Pr(Δ > 0) = 94.4%` is the number + * a reader is most likely to misread as "94.4% more arrests". + */ + +const NEGATIVE_COLOR = '#22406A' // --cv-navy-600 +const ZERO_COLOR = '#F2EDE4' // --cv-paper-2 +const POSITIVE_COLOR = '#C25311' // --cv-accent +const GRADIENT_STOPS = 24 + +const WIDTH = 760 +const HEIGHT = 250 +const MARGIN = { top: 18, right: 22, bottom: 46, left: 22 } + +export default function GroupDifference({ + groups, + enrollByGroup, + byModel, + selectedModel, + status, + pooled, + pair, + onPairChange, +}) { + const { counts, enroll } = useMemo( + () => displayDraws(byModel?.[selectedModel]?.counts, enrollByGroup, pooled), + [byModel, selectedModel, enrollByGroup, pooled], + ) + + // Only groups with a usable draw set can be differenced at all — offering the + // others in the picker would produce an empty chart with no explanation. + const comparable = useMemo( + () => groups.filter((g) => counts?.[g.key]?.length > 0 && enroll?.[g.key] > 0), + [groups, counts, enroll], + ) + + const [keyA, keyB] = pair || [] + const groupA = comparable.find((g) => g.key === keyA) + const groupB = comparable.find((g) => g.key === keyB) + + const deltas = useMemo( + () => + groupA && groupB + ? differenceRates(counts[groupA.key], enroll[groupA.key], counts[groupB.key], enroll[groupB.key]) + : [], + [groupA, groupB, counts, enroll], + ) + const summary = useMemo(() => differenceSummary(deltas), [deltas]) + + return ( +
+
+
+

Model estimated differences

+

+ How much higher is one group’s modelled arrest rate than another’s, and how + sure is the model of the direction? +

+
+ {comparable.length >= 2 && ( + + )} +
+ + {status === 'loading' ? ( +

Loading posterior draws…

+ ) : comparable.length < 2 ? ( + + ) : !groupA || !groupB || groupA.key === groupB.key ? ( + + ) : !summary ? ( + + ) : ( + <> + + +

+ Δ is computed at each of {summary.n.toLocaleString()} posterior predictive draws as{' '} + {groupA.label} minus {groupB.label}, per 1,000 students. The dashed line marks no + difference. Bars beneath the curve are the 80% and 95% intervals around the median Δ. +

+ + )} +
+ ) +} + +function Readout({ summary, groupA, groupB }) { + const pct = summary.prGreater * 100 + const higher = summary.median >= 0 ? groupA : groupB + const lower = summary.median >= 0 ? groupB : groupA + const share = summary.median >= 0 ? pct : 100 - pct + return ( +
+
+ {formatPercent(pct)} + Pr(Δ > 0) +
+

+ In {formatPercent(share)} of posterior predictive draws, the {higher.sentenceLabel} arrest + rate exceeds the {lower.sentenceLabel} rate. The median difference is{' '} + {formatDelta(summary.median)} per 1,000 students. +

+
+ ) +} + +function DifferencePlot({ deltas, summary, groupA, groupB }) { + const innerWidth = WIDTH - MARGIN.left - MARGIN.right + const baselineY = HEIGHT - MARGIN.bottom + + const { ticks, min, max } = useMemo(() => symmetricDomain(summary), [summary]) + const x = scaleLinear().domain([min, max]).range([MARGIN.left, MARGIN.left + innerWidth]) + + const profile = useMemo(() => rateProfile(deltas, { min, max, n: 80 }), [deltas, min, max]) + if (!profile) return null + + const y = scaleLinear().domain([0, profile.maxY || 1]).range([baselineY, MARGIN.top]) + const points = + profile.kind === 'mass' ? padMassPoints(profile.points, profile.step, min, max) : profile.points + + const areaGen = area() + .x((p) => x(clamp(p.x, min, max))) + .y0(baselineY) + .y1((p) => y(p.y)) + .curve(profile.kind === 'mass' ? curveStep : curveMonotoneX) + + const gradientId = `delta-gradient-${groupA.key}-${groupB.key}` + + return ( +
+ + + + {divergingStops(min, max).map((s) => ( + + ))} + + + + {ticks.map((t) => ( + + ))} + + + + + {/* No difference — the paper's red vertical line. */} + + + no difference + + + + + {/* Median with 80% (thick) and 95% (thin) intervals. */} + + + + + + + {ticks.map((t) => ( + + {formatTick(t)} + + ))} + + + ← {groupB.shortLabel} higher + + + {groupA.shortLabel} higher → + + +
+ ) +} + +/** + * A domain centred on zero. A signed quantity drawn on an off-centre axis makes + * the eye read the *position* of the curve as the size of the difference, so + * zero sits in the middle even when every draw falls on one side of it. + */ +function symmetricDomain(summary) { + const extent = Math.max( + Math.abs(summary.lower95), + Math.abs(summary.upper95), + Math.abs(summary.median), + 1e-6, + ) + const { niceMax } = niceTicks(extent * 1.25, 4) + const step = niceMax / 4 + const ticks = [] + for (let i = -4; i <= 4; i++) ticks.push(Math.round(step * i * 1e6) / 1e6) + return { ticks, min: -niceMax, max: niceMax } +} + +/** Diverging ramp, with the paper-coloured midpoint pinned to Δ = 0. */ +function divergingStops(min, max) { + const zeroOffset = (0 - min) / (max - min) + const toNegative = interpolateRgb(NEGATIVE_COLOR, ZERO_COLOR) + const toPositive = interpolateRgb(ZERO_COLOR, POSITIVE_COLOR) + const stops = [] + for (let i = 0; i <= GRADIENT_STOPS; i++) { + const offset = i / GRADIENT_STOPS + const color = + offset <= zeroOffset + ? toNegative(zeroOffset > 0 ? offset / zeroOffset : 1) + : toPositive(zeroOffset < 1 ? (offset - zeroOffset) / (1 - zeroOffset) : 0) + stops.push({ offset, color }) + } + return stops +} + +function padMassPoints(points, step, min, max) { + const half = Math.max(step, 1e-6) / 2 + return [ + { x: Math.max(points[0].x - half, min), y: 0 }, + ...points, + { x: Math.min(points[points.length - 1].x + half, max), y: 0 }, + ] +} + +function PairPickers({ comparable, keyA, keyB, onPairChange }) { + return ( +
+ onPairChange([v, keyB])} + /> + against + onPairChange([keyA, v])} + /> +
+ ) +} + +function GroupSelect({ label, value, options, onChange }) { + return ( + + ) +} + +function Degraded({ reason }) { + return ( +
+

{reason}

+
+ ) +} + +function clamp(v, min, max) { + return Math.min(Math.max(v, min), max) +} + +function formatPercent(pct) { + return `${pct.toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 })}%` +} + +function formatDelta(v) { + const sign = v > 0 ? '+' : '' + return `${sign}${v.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` +} + +function formatTick(v) { + if (v === 0) return '0' + const abs = Math.abs(v) + return v.toLocaleString(undefined, { + minimumFractionDigits: abs < 1 ? 2 : abs < 10 ? 1 : 0, + maximumFractionDigits: abs < 1 ? 2 : abs < 10 ? 1 : 0, + }) +} + +const cardTitle = { fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' } +const headerStyle = { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'flex-start', + flexWrap: 'wrap', + gap: 'var(--space-2)', +} +const subtitleStyle = { fontSize: '0.8rem', color: 'var(--cv-ink-3)', margin: 0, maxWidth: '32rem' } +const emptyStyle = { color: 'var(--cv-ink-3)', fontSize: '0.85rem', padding: 'var(--space-2) 0' } +const captionStyle = { fontSize: '0.74rem', color: 'var(--cv-ink-3)', margin: '0.4rem 0 0', lineHeight: 1.5 } +const readoutStyle = { + display: 'flex', + alignItems: 'center', + gap: 'var(--space-3)', + flexWrap: 'wrap', + padding: 'var(--space-2) 0', + borderTop: '3px double var(--cv-ink)', + borderBottom: '1px solid var(--cv-rule)', + margin: 'var(--space-2) 0', +} +const readoutNumber = { + fontFamily: "'Source Serif 4', Georgia, serif", + fontWeight: 700, + fontSize: '2.75rem', + lineHeight: 1, + letterSpacing: '-0.02em', + color: 'var(--cv-ink)', + fontVariantNumeric: 'tabular-nums', + display: 'block', +} +const readoutLabel = { + fontSize: '0.72rem', + fontWeight: 600, + textTransform: 'uppercase', + letterSpacing: '0.08em', + color: 'var(--cv-ink-3)', + display: 'block', + marginTop: '0.3rem', +} +const degradedStyle = { + background: 'var(--cv-paper-2)', + border: '1px solid var(--cv-rule)', + borderLeft: '3px solid var(--cv-ink-4)', + borderRadius: 'var(--radius-md)', + padding: 'var(--space-2)', + marginTop: 'var(--space-2)', + color: 'var(--cv-ink-2)', +} diff --git a/src/charts/RateByGroupBar.jsx b/src/charts/RateByGroupBar.jsx deleted file mode 100644 index 0c8a0fb..0000000 --- a/src/charts/RateByGroupBar.jsx +++ /dev/null @@ -1,162 +0,0 @@ -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' - -/** - * Arrest rate by student group, most recent year, disaggregated into two - * panels (Female / Male), each a horizontal box-and-whisker across the 4 - * race categories. Whisker = the model's reported 90% interval; box = the - * 25th-75th percentile of the group's 500 real posterior draws (or, if draws - * are unavailable, the fitSkewedInterval approximation); white tick = - * median; dark diamond = observed rate. - */ - -const RACE_ORDER = ['WH', 'BL', 'HI', 'AM'] -const SEX_PANELS = [{ sex: 'F', label: 'Female' }, { sex: 'M', label: 'Male' }] -const ROW_HEIGHT = 34 -const MODEL = 'unified_m3_mod' // matches ChartPanel's WAVE_MODEL, which built `data` - -function buildBox(d, draws) { - const median = Math.max(d.modeledMedian || 0, 0) - const lower = Math.max(Math.min(d.rateLower ?? median, median), 0) - const upper = Math.max(d.rateUpper ?? median, median) - - if (draws && draws.length > 0) { - return { lower, upper, median, q1: Math.max(quantile(draws, 0.25), 0), q3: Math.max(quantile(draws, 0.75), median) } - } - const fit = fitSkewedInterval({ median, lower, upper }) - return { lower, upper, median, q1: Math.max(fit.quantile(0.25), 0), q3: Math.max(fit.quantile(0.75), median) } -} - -export default function RateByGroupBar({ data, leaid, state }) { - const groups = data.map((d) => ({ race: d.race, sex: d.sex, stuEnroll: d.enrollment })) - 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)), - 0.5 - ) - const { ticks, niceMax } = niceTicks(maxRate, 4) - - return ( -
-

- Arrest rate by student group — 2021–22 (per 1,000) -

- {!allGroupsHaveDraws && } - -
- {SEX_PANELS.map(({ sex, label }) => ( - d.sex === sex)} - drawsByGroup={drawsByGroup} - ticks={ticks} - niceMax={niceMax} - /> - ))} -
- - data.some((d) => d.race === race)).map((race) => ({ - shape: 'swatch', color: raceColor(race), label: SHORT_RACE_LABEL[race], - })), - { shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'Observed' }, - ]} /> - -

- {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. -

-
- ) -} - -function SexPanel({ label, rows, drawsByGroup, ticks, niceMax }) { - const width = 300 - const margin = { top: 30, right: 16, bottom: 34, left: 66 } - const innerWidth = width - margin.left - margin.right - const byRace = RACE_ORDER.map((race) => rows.find((d) => d.race === race)).filter(Boolean) - const bodyHeight = byRace.length * ROW_HEIGHT - const height = margin.top + bodyHeight + margin.bottom - const xScale = (val) => margin.left + (val / niceMax) * innerWidth - - return ( -
- - - {label} - - - - - {ticks.map((val) => { - const x = xScale(val) - return ( - - - - {val} - - - ) - })} - - {byRace.map((d, i) => { - const rowY = margin.top + i * ROW_HEIGHT - const midY = rowY + ROW_HEIGHT / 2 - const boxTop = midY - ROW_HEIGHT * 0.26 - const boxBottom = midY + ROW_HEIGHT * 0.26 - const color = raceColor(d.race) - const draws = drawsByGroup?.[groupKey(d.race, d.sex)] - const box = buildBox(d, draws) - const observedX = xScale(d.observedRate) - - return ( - - - - - - - - - {SHORT_RACE_LABEL[d.race] || d.race} - - - ) - })} - - - Rate per 1,000 students - - -
- ) -} diff --git a/src/charts/RateDensityPanel.jsx b/src/charts/RateDensityPanel.jsx new file mode 100644 index 0000000..c45692b --- /dev/null +++ b/src/charts/RateDensityPanel.jsx @@ -0,0 +1,532 @@ +import { useMemo } from 'react' +import { area, curveMonotoneX, curveStep } from 'd3-shape' +import { scaleLinear } from 'd3-scale' +import { MODEL_QUADRANTS } from '../hooks/useApi.js' +import { raceColor } from '../utils/colors.js' +import { agrestiCoull } from '../utils/agrestiCoull.js' +import { densityProfile } from '../utils/densityProfile.js' +import { displayDraws } from '../utils/districtGroups.js' +import { computeRateDomain } from '../utils/rateDomain.js' +import { toRates } from '../utils/pooling.js' +import { densityCurve, fitSkewedInterval } from '../utils/distributionApprox.js' +import ApproxNote from '../components/ApproxNote.jsx' + +/** + * Chart A — "Arrest rate probability density". + * + * Port of the white paper's Fig 6 (`wp_fig_group_density` in + * crdc-arrests/R/paper_figures.R): each selected student group's posterior + * predictive arrest rate as a filled density, with that group's frequentist + * Agresti–Coull interval on a rail beneath, so model and observation can be + * read against each other. + * + * Layout follows the palette contract in utils/colors.js: **race is hue, sex is + * position**. Female and Male are two stacked sub-panels sharing one x-axis, + * never two hues; when sex pooling is on there is a single panel and the sex + * dimension disappears entirely rather than being recoloured. + * + * React owns the DOM here — d3 supplies scales and path generators only. No + * selections, no imperative mutation. + */ + +const AGRESTI_COULL_LEVEL = 0.95 +const FILL_OPACITY = 0.4 +const STROKE_WIDTH = 2 + +const CHART_WIDTH = 760 +const MARGIN = { top: 22, right: 18, bottom: 34, left: 16 } +const ROW_HEIGHT = 118 +const COMPARE_ROW_HEIGHT = 74 +const RAIL_HEIGHT = 26 + +export default function RateDensityPanel({ + groups, + selectedKeys, + enrollByGroup, + byModel, + status, + nDraws, + selectedModel, + onSelectModel, + compareAll, + onCompareAllChange, + pooled, +}) { + const activeModels = useMemo( + () => (compareAll ? MODEL_QUADRANTS.map((q) => q.model) : [selectedModel]), + [compareAll, selectedModel], + ) + + const selected = useMemo( + () => groups.filter((g) => selectedKeys.includes(g.key)), + [groups, selectedKeys], + ) + + // Agresti–Coull is computed from observed counts, so it is identical across + // model specifications — the same rail is drawn on every model row, which is + // exactly what makes the rows comparable. + const acByKey = useMemo(() => { + const out = {} + for (const g of selected) { + if (!(g.enroll > 0)) continue + const ac = agrestiCoull(g.observed, g.enroll, AGRESTI_COULL_LEVEL) + out[g.key] = { + // The R original can return a negative lower bound for a single event; + // a negative arrest count is not drawable, so clamp here rather than in + // the port (see utils/agrestiCoull.js). + lower: Math.max(0, (ac.lower / g.enroll) * 1000), + upper: (ac.upper / g.enroll) * 1000, + point: (g.observed / g.enroll) * 1000, + } + } + return out + }, [selected]) + + // Draws for every active model, moved into display (pooled or unpooled) space. + const drawsByModel = useMemo(() => { + const out = {} + for (const model of activeModels) { + out[model] = displayDraws(byModel?.[model]?.counts, enrollByGroup, pooled) + } + return out + }, [activeModels, byModel, enrollByGroup, pooled]) + + const domain = useMemo(() => { + const rateArrays = [] + for (const model of activeModels) { + const { counts, enroll } = drawsByModel[model] || {} + for (const g of selected) rateArrays.push(toRates(counts?.[g.key], enroll?.[g.key])) + } + return computeRateDomain(rateArrays, Object.values(acByKey).map((a) => a.upper)) + }, [activeModels, drawsByModel, selected, acByKey]) + + // One entry per (model, group): the shape to draw and where it came from. + const rowsByModel = useMemo(() => { + const out = {} + for (const model of activeModels) { + const { counts, enroll } = drawsByModel[model] || {} + out[model] = selected.map((g) => ({ + group: g, + ...profileFor(g, counts?.[g.key], enroll?.[g.key], domain.niceMax), + })) + } + return out + }, [activeModels, drawsByModel, selected, domain.niceMax]) + + const anyApproximated = Object.values(rowsByModel) + .flat() + .some((r) => r.source !== 'draws') + const loading = status === 'loading' + + const sexPanels = pooled + ? [{ sex: null, label: null }] + : [{ sex: 'F', label: 'Female students' }, { sex: 'M', label: 'Male students' }] + + return ( +
+
+
+

Arrest rate probability density

+

+ Each curve is one student group’s modelled arrest rate. The bar beneath each panel + is what was actually reported, with its {Math.round(AGRESTI_COULL_LEVEL * 100)}% + Agresti–Coull interval. +

+
+ +
+ + {anyApproximated && } + + {selected.length === 0 ? ( +

+ No student groups are selected. Check a group in the table above to show its distribution. +

+ ) : loading ? ( +

+ {compareAll + ? 'Loading posterior draws for all four specifications…' + : 'Loading posterior draws…'} +

+ ) : ( + sexPanels.map((panel) => ( + + )) + )} + + {domain.clipped && ( +

+ The axis stops at {domain.niceMax} per 1,000; one or more groups extend beyond it. Those + curves and intervals are cut off at the right edge, marked ›. +

+ )} + +

+ Densities are drawn from {nDraws > 0 ? nDraws.toLocaleString() : '500'} posterior predictive + draws per group. Point ranges are the observed rate with its{' '} + {Math.round(AGRESTI_COULL_LEVEL * 100)}% Agresti–Coull interval. Source: CRDC School + Arrest Rate API (Knowles & Miller 2025), 2021–22 Civil Rights Data Collection. +

+
+ ) +} + +/** + * Picks the shape for one group: real draws when we have them, the fitted + * summary-interval approximation when we don't, and nothing at all for a pooled + * group with no draws — there is no honest pooled shape to fit (see + * `buildDisplayGroups`). + */ +function profileFor(group, counts, enroll, niceMax) { + const domain = { min: 0, max: niceMax, n: 60 } + const fromDraws = densityProfile(counts, enroll, domain) + if (fromDraws) return { profile: fromDraws, source: 'draws' } + + if (!group.modeled || !(group.modeled.upper > 0)) return { profile: null, source: 'none' } + + const fit = fitSkewedInterval({ ...group.modeled, intervalMass: 0.95 }) + const points = densityCurve(fit, domain) + return { + profile: { kind: 'kde', points, maxY: Math.max(...points.map((p) => p.y)), step: 0 }, + source: 'approx', + } +} + +function SexPanel({ label, sex, domain, rowsByModel, activeModels, acByKey, compareAll }) { + const rows = activeModels.map((model) => ({ + model, + label: MODEL_QUADRANTS.find((q) => q.model === model)?.label || model, + entries: (rowsByModel[model] || []).filter((r) => (sex ? r.group.sex === sex : true)), + })) + + const hasAnything = rows.some((r) => r.entries.some((e) => e.profile)) + const rowHeight = compareAll ? COMPARE_ROW_HEIGHT : ROW_HEIGHT + const innerWidth = CHART_WIDTH - MARGIN.left - MARGIN.right + const bandHeight = rowHeight + RAIL_HEIGHT + const height = MARGIN.top + rows.length * bandHeight + MARGIN.bottom + + const x = scaleLinear().domain([0, domain.niceMax]).range([MARGIN.left, MARGIN.left + innerWidth]) + + return ( +
+ {label &&
{label}
} + {!hasAnything ? ( +

No modelled distribution is available for these groups.

+ ) : ( +
+ + {domain.ticks.map((t) => ( + + ))} + + {rows.map((row, i) => ( + + ))} + + {domain.ticks.map((t) => ( + + {t} + + ))} + + Arrests per 1,000 students + + +
+ )} +
+ ) +} + +function ModelRow({ row, compareAll, x, top, rowHeight, acByKey, niceMax }) { + const baselineY = top + rowHeight + const peakHeight = rowHeight * 0.86 + const drawable = row.entries.filter((e) => e.profile) + + return ( + + {compareAll && ( + + {row.label} + + )} + + {drawable.map((entry, i) => ( + + ))} + + + + {row.entries.map((entry, i) => { + const ac = acByKey[entry.group.key] + if (!ac) return null + return ( + + ) + })} + + ) +} + +function GroupArea({ entry, x, baselineY, peakHeight, labelIndex, compact, niceMax }) { + const { profile, group } = entry + const color = raceColor(group.race) + const y = scaleLinear().domain([0, profile.maxY || 1]).range([baselineY, baselineY - peakHeight]) + + const points = + profile.kind === 'mass' + ? padMassPoints(profile.points, profile.step, niceMax) + : profile.points + + const areaGen = area() + .x((p) => x(Math.min(p.x, niceMax))) + .y0(baselineY) + .y1((p) => y(p.y)) + // A staircase for discrete mass, a smooth curve for a KDE. curveStep keeps + // the mass profile honest — it says "these values and no others" — while + // still visually rhyming with the filled areas beside it. + .curve(profile.kind === 'mass' ? curveStep : curveMonotoneX) + + const peak = profile.points.reduce((best, p) => (p.y > best.y ? p : best), profile.points[0]) + const peakX = x(Math.min(peak.x, niceMax)) + const labelY = Math.max(baselineY - peakHeight - 2, y(peak.y) - 4 - (labelIndex % 2) * 11) + + return ( + + + + {/* Direct-labelled at the peak so there is no legend to hunt through. */} + x(niceMax) * 0.8 ? 'end' : 'middle'} + fontSize={compact ? '0.58rem' : '0.65rem'} + fontWeight={700} + fill={color} + stroke="var(--cv-paper)" + strokeWidth={3} + paintOrder="stroke" + > + {group.shortLabel} + + + ) +} + +/** + * A staircase needs a floor to close against on both sides: without the zero + * pads, curveStep leaves the first and last bars open and the fill bleeds to + * the panel edge. + */ +function padMassPoints(points, step, niceMax) { + const half = Math.max(step, 1e-6) / 2 + const first = points[0] + const last = points[points.length - 1] + return [ + { x: Math.max(0, first.x - half), y: 0 }, + ...points, + { x: Math.min(last.x + half, niceMax), y: 0 }, + ] +} + +function PointRange({ ac, color, x, y, niceMax }) { + const lo = Math.min(ac.lower, niceMax) + const hi = Math.min(ac.upper, niceMax) + const point = Math.min(ac.point, niceMax) + const clipped = ac.upper > niceMax + return ( + + + + {clipped && ( + + › + + )} + + ) +} + +function SpecControls({ selectedModel, onSelectModel, compareAll, onCompareAllChange, loading }) { + return ( +
+
+ {MODEL_QUADRANTS.map((q) => { + const active = !compareAll && q.model === selectedModel + return ( + + ) + })} +
+ +
+ ) +} + +function Spinner() { + return ( + + ) +} + +const cardTitle = { fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' } +const headerStyle = { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'flex-start', + flexWrap: 'wrap', + gap: 'var(--space-2)', +} +const subtitleStyle = { fontSize: '0.8rem', color: 'var(--cv-ink-3)', margin: 0, maxWidth: '34rem' } +const panelLabelStyle = { + fontSize: '0.75rem', + fontWeight: 700, + textTransform: 'uppercase', + letterSpacing: '0.06em', + color: 'var(--cv-ink-3)', + marginBottom: '0.1rem', +} +const emptyStyle = { color: 'var(--cv-ink-3)', fontSize: '0.85rem', padding: 'var(--space-2) 0' } +const noteStyle = { fontSize: '0.76rem', fontStyle: 'italic', color: 'var(--cv-ink-3)', margin: '0 0 0.4rem' } +const captionStyle = { fontSize: '0.74rem', color: 'var(--cv-ink-3)', margin: '0.4rem 0 0', lineHeight: 1.5 } +const segmentedStyle = { + display: 'inline-flex', + border: '1px solid var(--cv-rule)', + borderRadius: 'var(--radius-md)', + overflow: 'hidden', +} +const segmentStyle = { + padding: '0.25rem 0.55rem', + border: 'none', + borderRight: '1px solid var(--cv-rule)', + fontFamily: 'var(--font-sans)', + fontSize: '0.72rem', + fontWeight: 600, +} +const switchLabel = { + display: 'inline-flex', + alignItems: 'center', + gap: '0.4rem', + fontSize: '0.76rem', + color: 'var(--cv-ink-2)', + cursor: 'pointer', +} diff --git a/src/charts/RateDensityRidgeline.jsx b/src/charts/RateDensityRidgeline.jsx deleted file mode 100644 index 9e79910..0000000 --- a/src/charts/RateDensityRidgeline.jsx +++ /dev/null @@ -1,204 +0,0 @@ -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' -import ChartLegend from '../components/ChartLegend.jsx' -import ApproxNote from '../components/ApproxNote.jsx' - -/** - * Modeled posterior density per race×sex group, for one selected model - * (dropdown, default three-year + referral rate), split into Female/Male - * columns. Each ridge is drawn from that group's 500 real posterior draws - * (Gaussian KDE) when available, falling back per-group to the analytic - * fitSkewedInterval/densityCurve approximation otherwise. - */ - -const RACE_ORDER = ['WH', 'BL', 'HI', 'AM'] -const SEX_COLUMNS = [{ sex: 'F', label: 'Female' }, { sex: 'M', label: 'Male' }] -const DEFAULT_MODEL = 'unified_m4_mod' // Three-year + referral rate - -function buildGroupRow(row, draws) { - const enroll = row.stu_enroll || 0 - const observedRate = enroll > 0 ? ((row.observed_arrests || 0) / enroll) * 1000 : 0 - const rateMedian = (row.rate_median || 0) * 1000 - const rateLower = (row.rate_lower || 0) * 1000 - const rateUpper = (row.rate_upper || 0) * 1000 - return { - race: row.race, - sex: row.sex, - observedRate, - draws, - fit: fitSkewedInterval({ median: rateMedian, lower: rateLower, upper: rateUpper }), - } -} - -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 { 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 = ( - - ) - - return ( -
-
-
-

- Predicted arrest rates by student group -

- {!allGroupsHaveDraws && } -
- {modelSelect} -
- - {rows.length === 0 ? ( -

No model data available.

- ) : ( - <> - - ({ shape: 'swatch', color: raceColor(race), label: RACE_LABELS[race] })), - { shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'Observed' }, - ]} /> - - )} -
- ) -} - -function RidgeColumns({ rows, drawsByGroup }) { - // Shared x-domain across both columns, so Female/Male are directly comparable. - const allUpper = rows.map((r) => (r.rate_upper || 0) * 1000) - const allObserved = rows - .filter((r) => (r.stu_enroll || 0) > 0) - .map((r) => ((r.observed_arrests || 0) / r.stu_enroll) * 1000) - const rawMax = Math.min(Math.max(...allUpper, ...allObserved, 1) * 1.15, 30) - const { ticks, niceMax } = niceTicks(rawMax, 5) - - return ( -
- {SEX_COLUMNS.map(({ sex, label }) => ( - r.sex === sex)} - drawsByGroup={drawsByGroup} - ticks={ticks} - maxRate={niceMax} - /> - ))} -
- ) -} - -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?.[groupKey(row.race, row.sex)])) - - const width = 300 - const rowHeight = 58 - const margin = { top: 26, right: 16, bottom: 26, left: 56 } - const innerWidth = width - margin.left - margin.right - const height = margin.top + Math.max(groups.length, 1) * rowHeight + margin.bottom - - const xScale = (val) => margin.left + (val / maxRate) * innerWidth - - const curves = groups.map((g) => - g.draws && g.draws.length > 0 - ? kdeCurve(g.draws, { min: 0, max: maxRate, n: 60 }) - : densityCurve(g.fit, { min: 0, max: maxRate, n: 60 }), - ) - const maxPdf = Math.max(...curves.flatMap((c) => c.map((p) => p.y)), 1e-9) - const peakHeight = rowHeight * 0.82 - - return ( -
- - - {label} - - - {groups.length === 0 ? ( - - No data for this group - - ) : ( - <> - {ticks.map((val) => { - const x = xScale(val) - return ( - - - - {val} - - - ) - })} - - {groups.map((g, i) => { - const rowTop = margin.top + i * rowHeight - const baselineY = rowTop + rowHeight * 0.9 - const curve = curves[i] - const color = raceColor(g.race) - - const topPath = curve - .map((p, j) => `${j === 0 ? 'M' : 'L'}${xScale(p.x)},${baselineY - (p.y / maxPdf) * peakHeight}`) - .join(' ') - const areaPath = `${topPath} L${xScale(maxRate)},${baselineY} L${xScale(0)},${baselineY} Z` - - const obsX = xScale(Math.min(g.observedRate, maxRate)) - const obsY = baselineY - peakHeight * 0.15 - - return ( - - - {SHORT_RACE_LABEL[g.race] || g.race} - - - - - ) - })} - - - Arrests per 1,000 students - - - )} - -
- ) -} diff --git a/src/components/ChartPanel.jsx b/src/components/ChartPanel.jsx index 0b12deb..4f6fa8f 100644 --- a/src/components/ChartPanel.jsx +++ b/src/components/ChartPanel.jsx @@ -1,58 +1,163 @@ -import { useState, useEffect } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import * as api from '../hooks/useApi.js' -import { groupLabel } from '../utils/colors.js' +import { MODEL_QUADRANTS } from '../hooks/useApi.js' +import { useDrawDistribution } from '../hooks/useDrawDistribution.js' +import { + buildDisplayGroups, + defaultDiffPair, + defaultSelectedKeys, + enrollByGroupKey, +} from '../utils/districtGroups.js' +import { shouldPoolBySex } from '../utils/pooling.js' import ArrestsOverTime from '../charts/ArrestsOverTime.jsx' -import RateByGroupBar from '../charts/RateByGroupBar.jsx' -import RateDensityRidgeline from '../charts/RateDensityRidgeline.jsx' +import GroupDifference from '../charts/GroupDifference.jsx' +import RateDensityPanel from '../charts/RateDensityPanel.jsx' +import DistrictSummaryTable from './DistrictSummaryTable.jsx' const ALL_WAVES = ['15-16', '17-18', '21-22'] -const QUADRANT_MODELS = ['unified_m1_mod', 'unified_m2_mod', 'unified_m3_mod', 'unified_m4_mod'] -const WAVE_MODEL = 'unified_m3_mod' // three-year, no covariate — powers Charts 1 & 2 +const WAVE_MODEL = 'unified_m3_mod' // three-year, no covariate — powers the time series +const DEFAULT_SPEC = 'unified_m4_mod' // three-year + referral rate +const CURRENT_WAVE = '21-22' +const QUADRANT_MODELS = MODEL_QUADRANTS.map((q) => q.model) /** - * ChartPanel — 3 charts: arrests over time (observed vs. modeled), arrest - * rate by student group (Female/Male panels), and the model-selectable - * posterior density ridge chart. + * 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 + * (difference chart) — plus the district's arrest history. + * + * This component owns every piece of cross-chart state: the selected model + * specification, whether sexes are pooled, which groups are checked, and which + * pair is being differenced. The charts are given values and callbacks and hold + * none of it, so the table's checkboxes and the density panel can't drift apart. */ export default function ChartPanel({ district, state }) { - const [data, setData] = useState(null) // all fetched estimate rows keyed by year/model + const [waveData, setWaveData] = useState(null) + const [summaryByModel, setSummaryByModel] = useState({}) const [loading, setLoading] = useState(true) + const [selectedModel, setSelectedModel] = useState(DEFAULT_SPEC) + const [compareAll, setCompareAll] = useState(false) + const [poolOverride, setPoolOverride] = useState(null) // null = follow the rule + // Selections are stored with the pooling mode they were made in: pooled keys + // ('BL') and unpooled keys ('BL_F') are different namespaces, so a selection + // made in one mode must not be reapplied in the other. + const [selection, setSelection] = useState(null) + const [pairSelection, setPairSelection] = useState(null) + + // ——— Fetch: the three waves for the time series ——— useEffect(() => { - async function fetchData() { + let cancelled = false + setLoading(true) + setWaveData(null) + setSummaryByModel({}) + setPoolOverride(null) + setSelection(null) + setPairSelection(null) + setCompareAll(false) + + async function run() { + const waves = {} + await Promise.all( + ALL_WAVES.map(async (year) => { + try { + waves[year] = await api.fetchDistrictEstimates(district.leaid, { model: WAVE_MODEL, year }) + } catch (err) { + console.error(`ChartPanel: wave ${year} fetch failed:`, err) + } + }), + ) + if (cancelled) return + setWaveData(waves) + setLoading(false) + } + + run() + return () => { + cancelled = true + } + }, [district.leaid]) + + // ——— Fetch: the current wave's summary for whichever spec is selected ——— + // Only the selected specification is fetched. Enrollment and observed arrest + // counts are district facts, not model outputs, and the modelled distributions + // now come from real draws rather than from these rows — so prefetching all + // four specs' summaries would be four requests for data three of which are + // never read. + useEffect(() => { + let cancelled = false + if (summaryByModel[selectedModel]) return + + async function run() { try { - // Chart 1 + 2: all 3 waves × three-year model (unified_m3_mod) - const waveData = {} - await Promise.all(ALL_WAVES.map(async (year) => { - try { waveData[year] = await api.fetchDistrictEstimates(district.leaid, { model: WAVE_MODEL, year }) } catch (e) {} - })) - - // Chart 3: all 4 quadrant models, so the dropdown can switch between them - const quadData = {} - await Promise.all(QUADRANT_MODELS.map(async (model) => { - try { quadData[model] = await api.fetchDistrictEstimates(district.leaid, { model, year: '21-22' }) } catch (e) {} - })) - - setData({ waveData, quadData }) + const rows = await api.fetchDistrictEstimates(district.leaid, { + model: selectedModel, + year: CURRENT_WAVE, + }) + if (!cancelled) setSummaryByModel((prev) => ({ ...prev, [selectedModel]: rows })) } catch (err) { - console.error('ChartPanel data fetch failed:', err) - } finally { - setLoading(false) + console.error(`ChartPanel: summary fetch failed for ${selectedModel}:`, err) + if (!cancelled) setSummaryByModel((prev) => ({ ...prev, [selectedModel]: [] })) } } - fetchData() - }, [district.leaid]) + run() + return () => { + cancelled = true + } + }, [district.leaid, selectedModel, summaryByModel]) - if (loading || !data) { - return - } + const rows = useMemo(() => summaryByModel[selectedModel] || [], [summaryByModel, selectedModel]) - const mostRecent = data.waveData['21-22'] || [] + const autoPooled = useMemo(() => shouldPoolBySex(rows), [rows]) + const pooled = poolOverride ?? autoPooled + + const groups = useMemo(() => buildDisplayGroups(rows, pooled), [rows, pooled]) + const enrollByGroup = useMemo(() => enrollByGroupKey(rows), [rows]) + + const defaultKeys = useMemo(() => defaultSelectedKeys(groups), [groups]) + const selectedKeys = selection?.pooled === pooled ? selection.keys : defaultKeys + + const defaultPair = useMemo(() => defaultDiffPair(groups), [groups]) + const pair = pairSelection?.pooled === pooled ? pairSelection.pair : defaultPair + + const selectionIsEnrollmentFallback = + groups.length > 0 && groups.every((g) => g.observed === 0) && selection === null + + const handleToggleGroup = useCallback( + (key) => { + const next = selectedKeys.includes(key) + ? selectedKeys.filter((k) => k !== key) + : [...selectedKeys, key] + setSelection({ pooled, keys: next }) + }, + [selectedKeys, pooled], + ) + + const handlePooledChange = useCallback((next) => { + setPoolOverride(next) + // Both selections live in the other namespace now — drop them and let the + // defaults recompute for the new group set. + setSelection(null) + setPairSelection(null) + }, []) + + const handlePairChange = useCallback((next) => setPairSelection({ pooled, pair: next }), [pooled]) + + const drawModels = useMemo( + () => (compareAll ? QUADRANT_MODELS : [selectedModel]), + [compareAll, selectedModel], + ) + const { status, byModel, nDraws } = useDrawDistribution({ + leaid: district.leaid, + state, + models: drawModels, + year: CURRENT_WAVE, + }) + + if (loading || !waveData) return - // Chart 1: Arrests over time by wave — observed total + modeled (three-year model) point-range const timeSeriesData = ALL_WAVES.map((year) => { - const yearRows = data.waveData[year] || [] + const yearRows = waveData[year] || [] return { year, label: `20${year.replace('-', '-')}`, @@ -64,20 +169,8 @@ export default function ChartPanel({ district, state }) { } }) - // Chart 2: rate by student group (most recent wave, per 1k), with modeled + observed + interval - const rateByGroup = mostRecent.map((r) => ({ - race: r.race, sex: r.sex, label: groupLabel(r.race, r.sex), - observedRate: (r.observed_arrests || 0) / ((r.stu_enroll || 1) / 1000), - modeledMedian: (r.count_median || 0) / ((r.stu_enroll || 1) / 1000), - rateLower: (r.rate_lower || 0) * 1000, - rateUpper: (r.rate_upper || 0) * 1000, - observedArrests: r.observed_arrests || 0, - enrollment: r.stu_enroll || 0, - })) - return (
- {/* Chart panel header */}
District estimates — Bayesian model comparison

@@ -85,29 +178,68 @@ export default function ChartPanel({ district, state }) {

-
+
+ + + + + + - - - -
- {/* Methodology footer */} -
+
Methodology

- Estimates are from the CRDC School Arrest Rate API (Knowles & Miller 2025). Data shown spans three waves of - the Civil Rights Data Collection (2015–16, 2017–18, 2021–22) and lets you explore four Bayesian model - specifications: one-year vs. three-year models with and without referral-rate covariates. All rates are - per 1,000 students. + Estimates are from the CRDC School Arrest Rate API (Knowles & Miller 2025). Data shown + spans three waves of the Civil Rights Data Collection (2015–16, 2017–18, 2021–22) and lets + you explore four Bayesian model specifications: one-year vs. three-year models with and + without referral-rate covariates. All rates are per 1,000 students. Modelled distributions + are drawn from the published posterior predictive draws, fetched in the browser.

diff --git a/src/components/DistrictSummaryTable.jsx b/src/components/DistrictSummaryTable.jsx new file mode 100644 index 0000000..8bf7c53 --- /dev/null +++ b/src/components/DistrictSummaryTable.jsx @@ -0,0 +1,234 @@ +import { raceColor } from '../utils/colors.js' +import { POOL_BY_SEX_ARREST_THRESHOLD } from '../utils/pooling.js' + +/** + * The results page's first block: what was actually observed, before any + * modelling. One row per student group plus a district total. + * + * The table doubles as the legend and the control for the density panel — each + * row carries the checkbox that adds or removes that group's curve, and the + * colour swatch is the same hue the curve is drawn in. That's deliberate: + * a separate legend plus a separate group picker would make the reader hold + * three mappings in their head instead of one. + * + * @param {{ + * groups: Array<{key:string, race:string, sex:string|null, label:string, + * enroll:number, observed:number, rate:number}>, + * selectedKeys: string[], + * onToggleGroup: (key: string) => void, + * pooled: boolean, + * autoPooled: boolean, + * onPooledChange: (pooled: boolean) => void, + * selectionIsEnrollmentFallback: boolean, + * }} props + */ +export default function DistrictSummaryTable({ + groups, + selectedKeys, + onToggleGroup, + pooled, + autoPooled, + onPooledChange, + selectionIsEnrollmentFallback, +}) { + if (!groups?.length) { + return ( +
+

Reported arrests and enrollment

+

+ No student-group data is available for this district in 2021–22. +

+
+ ) + } + + const selected = new Set(selectedKeys) + const totalEnroll = groups.reduce((sum, g) => sum + g.enroll, 0) + const totalObserved = groups.reduce((sum, g) => sum + g.observed, 0) + const totalRate = totalEnroll > 0 ? (totalObserved / totalEnroll) * 1000 : 0 + + return ( +
+

Reported arrests and enrollment, 2021–22

+

+ Check a group to show its modelled arrest-rate distribution in the chart below. +

+ + {pooled && ( + + )} + + {selectionIsEnrollmentFallback && ( +

+ No group in this district reported an arrest in 2021–22, so the two largest groups by + enrollment are shown by default. +

+ )} + +
+ + + + + + + + + + + + {groups.map((g) => ( + onToggleGroup(g.key)} + /> + ))} + + + + + + + + + +
+ Students counted are those in the four modelled race groups (American Indian / Alaska + Native, Black, Hispanic, White) — not the district’s total enrollment, which also + includes groups this model does not estimate. +
Student groupStudentsObserved arrestsRate per 1,000
+ All modelled groups + + {totalEnroll.toLocaleString()} + + {totalObserved.toLocaleString()} + + {formatRate(totalRate)} +
+
+ + {!pooled && ( + + )} +
+ ) +} + +function GroupRow({ group, checked, onToggle }) { + const zero = group.observed === 0 + const inputId = `group-toggle-${group.key}` + return ( + + + + + {group.enroll.toLocaleString()} + {group.observed.toLocaleString()} + {formatRate(group.rate)} + + ) +} + +function PoolingBanner({ autoPooled, totalObserved, onPooledChange }) { + return ( +
+

+ {autoPooled ? ( + <> + This district reported {totalObserved.toLocaleString()} arrests in total — fewer than{' '} + {POOL_BY_SEX_ARREST_THRESHOLD} — so Female and Male students are combined within each + race to give each estimate more data to stand on. + + ) : ( + <>Female and Male students are combined within each race. + )} +

+ +
+ ) +} + +function formatRate(rate) { + if (!(rate > 0)) return '0.0' + return rate.toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 }) +} + +const cardTitle = { fontSize: '0.85rem', marginBottom: 'var(--space-1)', color: 'var(--cv-ink-2)' } + +const tableStyle = { + width: '100%', + borderCollapse: 'collapse', + fontSize: '0.85rem', + fontVariantNumeric: 'tabular-nums', +} + +const thStyle = { + textAlign: 'right', + padding: '0.35rem 0.5rem', + borderBottom: '1px solid var(--cv-rule-strong)', + fontSize: '0.72rem', + fontWeight: 600, + textTransform: 'uppercase', + letterSpacing: '0.06em', + color: 'var(--cv-ink-3)', + whiteSpace: 'nowrap', +} + +const tdStyle = { + padding: '0.35rem 0.5rem', + borderBottom: '1px solid var(--cv-rule)', +} + +const numStyle = { ...tdStyle, textAlign: 'right', whiteSpace: 'nowrap' } + +const bannerStyle = { + background: 'var(--cv-paper-2)', + border: '1px solid var(--cv-rule)', + borderLeft: '3px solid var(--cv-accent)', + borderRadius: 'var(--radius-md)', + padding: 'var(--space-1) var(--space-2)', + marginBottom: 'var(--space-2)', + display: 'flex', + flexDirection: 'column', + gap: '0.4rem', +} + +const toggleLabel = { + display: 'inline-flex', + alignItems: 'center', + gap: '0.45rem', + fontSize: '0.78rem', + color: 'var(--cv-ink-2)', + cursor: 'pointer', +} + +const noteStyle = { + fontSize: '0.78rem', + fontStyle: 'italic', + color: 'var(--cv-ink-3)', + margin: '0 0 var(--space-2)', +} diff --git a/src/hooks/useDrawDistribution.js b/src/hooks/useDrawDistribution.js index 78e08d8..7ed4924 100644 --- a/src/hooks/useDrawDistribution.js +++ b/src/hooks/useDrawDistribution.js @@ -1,19 +1,68 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { getDb } from '../utils/duckdbClient.js' -import { groupKey } from '../utils/drawGroups.js' +import { groupKey, isCompleteDrawSet } from '../utils/drawGroups.js' -const HF_BASE = 'https://huggingface.co/datasets/civilytics/crdc-school-arrest-rates/resolve/main/parquet' +const HF_DATASET = 'civilytics/crdc-school-arrest-rates' +const HF_BASE = `https://huggingface.co/datasets/${HF_DATASET}/resolve/main/parquet` +const HF_TREE = `https://huggingface.co/api/datasets/${HF_DATASET}/tree/main/parquet` -// Module-level cache: one registered duckdb-wasm file buffer per +// Module-level cache: the registered duckdb-wasm file buffers for one // (model, year, state) shard, shared across every component instance and // district navigated to in this browser session. See Global Constraints — -// in-memory only, no persistence across page loads. +// in-memory only, no persistence across page loads. Four models is simply four +// cache entries; nothing else about this cache changes when comparing specs. const shardCache = new Map() +// Runaway guard on a malformed directory listing, not an expected limit. +const MAX_SHARD_PARTS = 64 + function shardKey(model, year, state) { return `${model}__${year}__${state}` } +function shardDir(model, year, state) { + return `model_id=${model}/YEAR=${year}/LEA_STATE=${state}` +} + +/** + * Lists the parquet parts published for one (model, year, state). + * + * A state's draws are split across `data_0.parquet`, `data_1.parquet`, … and + * the part count varies by state: Nevada is one file, California is eight + * (6.2MB in total, of which data_0 is 37KB). Reading only data_0 covers 11 of + * California's 1,715 districts and makes every other CA district look absent + * from the published data, silently falling back to the approximation. + * + * The directory listing is used rather than probing `data_N` until a 404 + * because a 404 is logged as a console error by the browser's network layer no + * matter how cleanly the fetch handles it — and a red error on every load is + * indistinguishable from a real one. Probing remains the fallback if the + * listing API is unavailable or changes shape. + */ +async function listShardParts(model, year, state) { + const dir = shardDir(model, year, state) + try { + const res = await fetch(`${HF_TREE}/${dir}`) + 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) + throw new Error('tree listing contained no parquet parts') + } catch (err) { + console.warn(`useDrawDistribution: falling back to sequential part probing for ${dir}:`, err.message) + const parts = [] + for (let part = 0; part < MAX_SHARD_PARTS; part++) { + const res = await fetch(`${HF_BASE}/${dir}/data_${part}.parquet`, { method: 'HEAD' }) + if (!res.ok) break + parts.push(part) + } + return parts + } +} + function ensureShardRegistered(db, model, year, state) { const key = shardKey(model, year, state) if (!shardCache.has(key)) { @@ -21,13 +70,24 @@ function ensureShardRegistered(db, model, year, state) { key, (async () => { try { - const url = `${HF_BASE}/model_id=${model}/YEAR=${year}/LEA_STATE=${state}/data_0.parquet` - const res = await fetch(url) - if (!res.ok) throw new Error(`Failed to fetch draw shard: HTTP ${res.status}`) - const buffer = new Uint8Array(await res.arrayBuffer()) - const fileName = `${key}.parquet` - await db.registerFileBuffer(fileName, buffer) - return fileName + const dir = shardDir(model, year, state) + const parts = await listShardParts(model, year, state) + if (parts.length === 0) { + throw new Error(`No draw shard published for ${state}/${year}/${model}`) + } + // Parts are independent files, so fetch them together rather than + // walking them one at a time — California is eight round trips. + const fileNames = await Promise.all( + parts.map(async (part) => { + const res = await fetch(`${HF_BASE}/${dir}/data_${part}.parquet`) + if (!res.ok) throw new Error(`Failed to fetch draw shard part ${part}: HTTP ${res.status}`) + const buffer = new Uint8Array(await res.arrayBuffer()) + const fileName = `${key}__${part}.parquet` + await db.registerFileBuffer(fileName, buffer) + return fileName + }), + ) + return fileNames } catch (err) { // Don't let a transient failure (network blip, HF outage) poison the // cache forever — remove the rejected entry so the next caller for @@ -42,100 +102,145 @@ function ensureShardRegistered(db, model, year, state) { } /** - * Fetches real posterior draws for one district/model/year from the Hugging - * 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" (see `groupKey` in utils/drawGroups.js). + * Reads one model's shard and returns that district's predicted counts keyed by + * group and indexed by draw. * - * `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 - * @returns {{status: 'loading'|'ready'|'error', drawsByGroup: Record | null}} + * Indexing by `draw_id - 1` rather than by push order means DuckDB's row + * ordering is irrelevant, and it makes a missing draw a hole instead of a + * silently shorter array — which `isCompleteDrawSet` then rejects. */ -export function useDrawDistribution({ leaid, state, model, year, groups }) { - const [status, setStatus] = useState('loading') - const [drawsByGroup, setDrawsByGroup] = useState(null) +async function fetchModelCounts(db, { leaid, state, model, year }) { + const fileNames = await ensureShardRegistered(db, model, year, state) + let conn + try { + conn = await db.connect() + // read_parquet over the full part list — a district lives in exactly one + // part, and which one is not predictable from its LEAID. + const fileList = fileNames.map((f) => `'${f}'`).join(', ') + const stmt = await conn.prepare( + `SELECT RACE, SEX, draw_id, pred FROM read_parquet([${fileList}]) WHERE LEAID = ?`, + ) + const table = await stmt.query(leaid) + await stmt.close() + const rows = table.toArray().map((r) => r.toJSON()) - // groups is typically a fresh array literal every render; derive a stable + const raw = {} + let nDraws = 0 + for (const row of rows) { + const drawId = Number(row.draw_id) + const pred = Number(row.pred) + if (!Number.isFinite(drawId) || drawId < 1 || !Number.isFinite(pred)) continue + const key = groupKey(row.RACE, row.SEX) + ;(raw[key] ??= [])[drawId - 1] = pred + if (drawId > nDraws) nDraws = drawId + } + + // A group present for only part of the draw set must fall back, not render + // a short draw set: its density and interval would be computed off a + // biased subsample and look identical on screen to a complete one. + const counts = {} + for (const [key, arr] of Object.entries(raw)) { + if (isCompleteDrawSet(arr, nDraws)) counts[key] = arr + else 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 + } finally { + if (conn) await conn.close() + } +} + +/** + * Fetches real posterior predictive draws for one district from the Hugging + * Face parquet dataset via duckdb-wasm, for one or more model specifications at + * once, and returns **predicted counts indexed by draw** — not rates. + * + * Counts rather than rates is what unlocks the rest of the app: sex pooling has + * to sum numerators and denominators separately (see `utils/pooling.js`), and + * a between-group difference has to be taken at a common draw index. Callers + * divide by their own enrollment, which is why this hook no longer takes a + * `groups` argument at all. + * + * A caveat worth carrying into any caption: `draw_id` is renumbered 1–500 per + * write batch upstream, and a district's groups land in different batches, so + * draws are **not** paired parameter draws across groups — the pairing is + * effectively independent, dominated by `posterior_predict` observation noise. + * Published Fig 7 has the same property. Say "posterior predictive draws". + * + * `status` is an ANY-model, ANY-group signal: 'ready' means at least one model + * returned at least one complete group. A caller claiming "these are all real + * draws" must check its own rendered groups with `hasDrawsForAll`. + * + * @param {{leaid: string, state: string, models: string[], year: string}} params + * @returns {{status: 'loading'|'ready'|'error', + * byModel: Record, nDraws: number} | null> | null, + * nDraws: number}} `nDraws` is the largest draw count among models that came + * back; per-model counts live in `byModel[model].nDraws`. + */ +export function useDrawDistribution({ leaid, state, models, year }) { + const [status, setStatus] = useState('loading') + const [byModel, setByModel] = useState(null) + + // `models` is typically a fresh array literal every render; derive a stable // primitive so the effect only re-runs when its actual content changes. - const groupsSignature = (groups || []).map((g) => `${g.race}:${g.sex}:${g.stuEnroll}`).join(',') + const modelsSignature = (models || []).filter(Boolean).join(',') useEffect(() => { 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. + // varies its model selection across renders and lands on a model whose + // fetch fails would keep reporting 'ready' and keep handing back the + // *previous* model's real draws — keyed by the same group strings — under + // the newly selected model's label, silently mixing two models' data. setStatus('loading') - setDrawsByGroup(null) + setByModel(null) + + const modelList = modelsSignature ? modelsSignature.split(',') : [] // 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 + if (!leaid || !state || !year || modelList.length === 0) return async function run() { - let conn try { const db = await getDb() - const fileName = await ensureShardRegistered(db, model, year, state) - conn = await db.connect() - const stmt = await conn.prepare(`SELECT RACE, SEX, pred FROM read_parquet('${fileName}') WHERE LEAID = ?`) - const table = await stmt.query(leaid) - await stmt.close() - const rows = table.toArray().map((r) => r.toJSON()) + // Per-model try/catch: one shard 404ing (or one model missing for this + // state) must not blank out the models that did load. + const results = await Promise.all( + modelList.map(async (model) => { + try { + return [model, await fetchModelCounts(db, { leaid, state, model, year })] + } catch (err) { + console.error(`useDrawDistribution: model ${model} failed:`, err) + return [model, null] + } + }), + ) - const enrollByGroup = {} - for (const g of groups) enrollByGroup[groupKey(g.race, g.sex)] = g.stuEnroll || 0 - - const byGroup = {} - for (const row of rows) { - const key = groupKey(row.RACE, row.SEX) - const enroll = enrollByGroup[key] - if (!enroll) continue - const rate = (Number(row.pred) / enroll) * 1000 - ;(byGroup[key] ??= []).push(rate) + if (cancelled) return + const next = Object.fromEntries(results) + const anyReady = Object.values(next).some((v) => v !== null) + if (anyReady) { + setByModel(next) + setStatus('ready') + } else { + console.warn('useDrawDistribution: no usable draws for', { leaid, state, year, models: modelList }) + setByModel(null) + setStatus('error') } - - // A successful query with zero matching rows (this district isn't in - // the draws shard, or none of its rows matched a known group) is not - // "ready" — there's no real data to show, so treat it like a failure - // and let the caller fall back, rather than silently claiming real - // draws while every group actually uses the fitted approximation. - const hasDraws = Object.values(byGroup).some((draws) => draws.length > 0) - - if (!cancelled) { - if (hasDraws) { - setDrawsByGroup(byGroup) - setStatus('ready') - } else { - console.warn('useDrawDistribution: query succeeded but returned no usable draws for', { leaid, model, year, state }) - setDrawsByGroup(null) - setStatus('error') - } - } } catch (err) { + // Only reached when the shared duckdb engine itself fails to load. console.error('useDrawDistribution failed:', err) if (!cancelled) { - // Belt-and-suspenders alongside the setDrawsByGroup(null) at the - // top of this effect: a failed fetch/query must never leave a - // *previous* model's real draws in place under the newly-selected - // model's status/summary stats. - setDrawsByGroup(null) + // Belt-and-suspenders alongside the setByModel(null) at the top of + // this effect: a failed load must never leave a *previous* model's + // real draws in place under the newly-selected model's label. + setByModel(null) setStatus('error') } - } finally { - if (conn) await conn.close() } } @@ -143,8 +248,12 @@ export function useDrawDistribution({ leaid, state, model, year, groups }) { return () => { cancelled = true } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [leaid, state, model, year, groupsSignature]) + }, [leaid, state, year, modelsSignature]) - return { status, drawsByGroup } + const nDraws = useMemo( + () => Math.max(0, ...Object.values(byModel || {}).map((v) => v?.nDraws || 0)), + [byModel], + ) + + return { status, byModel, nDraws } } diff --git a/src/styles/tokens.css b/src/styles/tokens.css index c3ee0f1..c6d5331 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -270,3 +270,9 @@ code, .mono { font-family: var(--font-mono); font-feature-settings: "tnum" 1, "z /* Responsive */ @media (max-width: 680px) { .cv-footer { flex-direction: column; text-align: center; } } + +/* Spinner used while opt-in multi-shard draw fetches are in flight */ +@keyframes cv-spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { + @keyframes cv-spin { to { transform: none; } } +} diff --git a/src/utils/agrestiCoull.js b/src/utils/agrestiCoull.js new file mode 100644 index 0000000..7cc83f0 --- /dev/null +++ b/src/utils/agrestiCoull.js @@ -0,0 +1,66 @@ +/** + * Agresti–Coull approximate interval for a rare-event count. + * + * Direct port of `agresti_coull()` in crdc-arrests/R/paper_figures.R:219-237 — + * the frequentist point range drawn beside the posterior densities in the white + * paper's Figs 6 and 7. Keeping this a faithful port is the whole point: the + * app's error bars have to be the *same* interval the paper published, so the + * two can be compared directly. + * + * Two consequences of that faithfulness, both deliberate: + * + * 1. The bounds are on the **count** scale, not the proportion scale — the R + * function multiplies back up by the adjusted denominator. Divide by + * enrollment yourself to plot per-1,000. + * 2. `lower` can be **negative** for very small numerators (e.g. 1 arrest in 53 + * students gives -0.32). Clamp for display at the call site; do not clamp + * here, or this stops matching the published figures. + * + * The R original returns an unnamed vector in the order + * `c(ci_upper, ci_lower, sd, phat_se, phat)` — upper *first*, which is easy to + * transcribe backwards. This returns a named object instead. + */ + +import { probit } from './distributionApprox.js' + +/** + * @param {number} numerator - observed events (arrests) + * @param {number} denominator - trials (students enrolled) + * @param {number} [confidenceLevel=0.95] - e.g. 0.95 for a 95% interval + * @returns {{upper: number, lower: number, sd: number, se: number, phat: number}} + * `upper`/`lower`/`sd` are counts. In the zero-numerator branch the R + * original also reports `phat` as a count (the interval midpoint) rather than + * a proportion; that quirk is preserved. + */ +export function agrestiCoull(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 { + upper: (phat + adjStar * se) * denomStar, + lower: (phat - adjStar * se) * denomStar, + sd: se * denomStar, + se, + phat, + } + } + + // Zero events: the rule of three. The R original writes the bound as + // denominator * (-log(1 - level) / denominator), where the denominator + // cancels — so the upper bound is -log(1 - level) ≈ 3 at 95% regardless of + // how many students were enrolled. Kept in the cancelled form so the value + // is identical rather than merely close. + const upper = -Math.log(1 - confidenceLevel) + const midpoint = (upper + 0) / 2 + return { + upper, + lower: 0, + sd: midpoint / confidenceLevel, + se: 0, + phat: midpoint, + } +} diff --git a/src/utils/agrestiCoull.test.js b/src/utils/agrestiCoull.test.js new file mode 100644 index 0000000..d1c477b --- /dev/null +++ b/src/utils/agrestiCoull.test.js @@ -0,0 +1,109 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { agrestiCoull } from './agrestiCoull.js' + +/** + * Reference values produced by the R original, `agresti_coull()` in + * crdc-arrests/R/paper_figures.R:219-237, printed at 15 significant digits: + * + * agresti_coull(15, 499, 0.95) c(24.89431440404100, 9.02561356503911, + * 4.04821235598516, 0.00804941727469993, + * 0.03372299056239180) + * agresti_coull(0, 53, 0.95) c(2.99573227355399, 0, 1.57670119660736, + * 0, 1.49786613677699) + * agresti_coull(1, 53, 0.95) c(6.24314599198645, -0.32321802290634, + * 1.67512364173205, 0.02942947578293, + * 0.05200224403214) + * agresti_coull(100, 148928, 0.95) c(121.743969120453, 82.1759588486273, + * 10.0940656522091, 6.77763749845643e-05, + * 6.84607866694077e-04) + * agresti_coull(3, 200, 0.90) c(8.14909680812749, 1.14061044577545, + * 2.13042858267619, 0.01047976610058, + * 0.02284844466400) + * + * R's qnorm is exact to double precision; this port uses Acklam's rational + * probit (relative error < 1.15e-9), so equality is asserted to 1e-7 relative. + */ +const REL_TOL = 1e-7 + +function assertClose(actual, expected, label) { + const scale = Math.max(Math.abs(expected), 1e-9) + assert.ok( + Math.abs(actual - expected) / scale < REL_TOL, + `${label}: expected ${expected}, got ${actual}`, + ) +} + +function assertMatchesR(result, [upper, lower, sd, se, phat], label) { + assertClose(result.upper, upper, `${label} upper`) + assertClose(result.lower, lower, `${label} lower`) + assertClose(result.sd, sd, `${label} sd`) + assertClose(result.se, se, `${label} se`) + assertClose(result.phat, phat, `${label} phat`) +} + +test('agrestiCoull: matches R for a typical rare-event cell', () => { + assertMatchesR( + agrestiCoull(15, 499), + [24.894314404041, 9.02561356503911, 4.04821235598516, 0.00804941727469993, 0.0337229905623918], + 'ac(15, 499, 0.95)', + ) +}) + +test('agrestiCoull: matches R for a large district cell', () => { + assertMatchesR( + agrestiCoull(100, 148928), + [121.743969120453, 82.1759588486273, 10.0940656522091, 6.77763749845643e-5, 6.84607866694077e-4], + 'ac(100, 148928, 0.95)', + ) +}) + +test('agrestiCoull: matches R at a non-default confidence level', () => { + assertMatchesR( + agrestiCoull(3, 200, 0.9), + [8.14909680812749, 1.14061044577545, 2.13042858267619, 0.0104797661005796, 0.0228484446639996], + 'ac(3, 200, 0.90)', + ) +}) + +test('agrestiCoull: zero numerator uses the rule of three', () => { + // -log(1 - 0.95) = 2.9957…, the classic "rule of three" upper bound for zero + // events. The R original writes it as denominator * (-log(1-cl)/denominator), + // which cancels — the bound does not depend on the denominator at all. + const r = agrestiCoull(0, 53) + assertMatchesR(r, [2.99573227355399, 0, 1.57670119660736, 0, 1.49786613677699], 'ac(0, 53, 0.95)') + assert.equal(r.lower, 0) +}) + +test('agrestiCoull: the zero-numerator upper bound ignores the denominator', () => { + assert.equal(agrestiCoull(0, 53).upper, agrestiCoull(0, 500000).upper) +}) + +test('agrestiCoull: keeps R\'s negative lower bound for a single event', () => { + // Faithful to the R original: with numerator = 1 the lower bound goes below + // zero. Callers clamp for display — do NOT clamp here, or this port silently + // stops matching the published figures. + const r = agrestiCoull(1, 53) + assertMatchesR( + r, + [6.24314599198645, -0.323218022906343, 1.67512364173205, 0.029429475782928, 0.0520022440321421], + 'ac(1, 53, 0.95)', + ) + assert.ok(r.lower < 0, 'lower bound should be negative for numerator = 1, n = 53') +}) + +test('agrestiCoull: bounds are on the count scale, not the proportion scale', () => { + const r = agrestiCoull(15, 499) + assert.ok(r.upper > 15 && r.lower < 15, 'the interval should bracket the observed count') +}) + +test('agrestiCoull: defaults to 95%', () => { + assert.deepEqual(agrestiCoull(15, 499), agrestiCoull(15, 499, 0.95)) +}) + +test('agrestiCoull: a wider confidence level gives a wider interval', () => { + const narrow = agrestiCoull(15, 499, 0.8) + const wide = agrestiCoull(15, 499, 0.99) + assert.ok(wide.upper > narrow.upper) + assert.ok(wide.lower < narrow.lower) +}) diff --git a/src/utils/colors.js b/src/utils/colors.js index 40ae8f8..4393030 100644 --- a/src/utils/colors.js +++ b/src/utils/colors.js @@ -31,10 +31,26 @@ export function raceColor(race) { return RACE_COLORS[race] || REFERENCE_GRAY } +// Both label helpers take an optional `sex`: omitting it names a sex-pooled +// group (race alone), mirroring `groupKey(race)` in utils/drawGroups.js. Don't +// let a missing sex fall through to "Male". export function groupLabel(race, sex) { - return `${RACE_LABELS[race] || race} ${sex === 'F' ? 'Female' : 'Male'}` + const race_ = RACE_LABELS[race] || race + if (!sex) return race_ + return `${race_} ${sex === 'F' ? 'Female' : 'Male'}` } export function shortGroupLabel(race, sex) { - return `${SHORT_RACE_LABEL[race] || race} ${sex}` + const race_ = SHORT_RACE_LABEL[race] || race + return sex ? `${race_} ${sex}` : race_ +} + +// For labels that appear mid-sentence ("…the Black male arrest rate exceeds the +// White male rate"). The race is a proper noun and keeps its capital; only the +// sex word is lowercased. Don't reach for .toLowerCase() on groupLabel() — it +// turns "Black Male" into "black male". +export function sentenceGroupLabel(race, sex) { + const race_ = RACE_LABELS[race] || race + if (!sex) return race_ + return `${race_} ${sex === 'F' ? 'female' : 'male'}` } diff --git a/src/utils/densityProfile.js b/src/utils/densityProfile.js new file mode 100644 index 0000000..fe08e0f --- /dev/null +++ b/src/utils/densityProfile.js @@ -0,0 +1,114 @@ +/** + * Chooses an honest visual profile for one group's posterior predictive draws. + * + * In a sparse district the posterior predictive is a *discrete count* + * distribution, not a smooth one. Carson City NV (3200390) has a 53-student + * AI/AN female cell where a single arrest is 18.9 per 1,000: the draws take + * four distinct values, and a Gaussian KDE renders those four spikes as a lumpy + * smear that reads as a rendering bug rather than as a finding about the data. + * + * So: few distinct values → draw the actual probability mass at each achievable + * rate. Many → the smooth KDE, delegated to `kde.js` unchanged (its bandwidth + * clamp was tuned for exactly these zero-inflated posteriors — see + * BANDWIDTH_FLOOR_DIVISOR there; do not re-derive it here). + */ + +import { kdeCurve } from './kde.js' +import { toRates } from './pooling.js' + +/** + * At or below this many distinct predicted counts, the draws are shown as + * discrete mass rather than smoothed. + * + * 12 is comfortably above the 3–5 distinct values a genuinely sparse cell + * produces and comfortably below the ~40+ a district with real arrest volume + * produces, so the switch happens well away from either regime rather than + * flickering at the boundary. + */ +export const MASS_MAX_DISTINCT = 12 + +/** + * @param {number[] | null | undefined} counts - predicted counts, indexed by draw + * @param {number | null | undefined} enroll - students in the group + * @param {{min?: number, max?: number, n?: number}} [domain] - the x-range the + * profile will be drawn over, in rate per 1,000. Passed straight to + * `kdeCurve`, whose bandwidth clamp is relative to this width. + * @returns {{kind: 'kde'|'mass', points: Array<{x: number, y: number}>, + * maxY: number, step: number} | null} + * `null` when there is nothing to draw (no draws, or no denominator). + * For `kind: 'mass'`, `y` is a probability and `step` is the spacing between + * achievable rates — the bar width for a filled staircase. For `kind: 'kde'`, + * `y` is a density and `step` is 0. + * `maxY` is the profile's own peak: because the two kinds carry different y + * units, a chart overlaying several groups must normalize each profile by its + * own `maxY` rather than by a shared maximum. + */ +export function densityProfile(counts, enroll, domain = {}) { + const rates = toRates(counts, enroll) + if (!rates.length) return null + // Counts are integers, so consecutive achievable rates are exactly one + // student-rate apart — pass that explicitly rather than inferring it from the + // gaps actually observed, which overstates the bar width when (say) only + // counts 0 and 3 appear. + return rateProfile(rates, domain, 1000 / enroll) +} + +/** + * The same choice made directly on a set of rates, for quantities that are + * already differences rather than count/denominator pairs (see + * `utils/groupDifference.js`). A difference of two discrete posteriors is + * itself discrete, and deserves the same honesty. + * + * @param {number[] | null | undefined} rates - values on the plotted scale + * @param {{min?: number, max?: number, n?: number}} [domain] + * @param {number} [explicitStep] - known spacing between achievable values; + * inferred from the smallest observed gap when omitted. + * @returns {{kind: 'kde'|'mass', points: Array<{x: number, y: number}>, + * maxY: number, step: number} | null} + */ +export function rateProfile(rates, domain = {}, explicitStep = 0) { + if (!rates?.length) return null + + const { min = 0, max, n = 60 } = domain + + // Round before tallying so two draws that differ only in floating-point noise + // count as one achievable value rather than two. + const tally = new Map() + for (const r of rates) { + const key = Math.round(r * 1e9) / 1e9 + tally.set(key, (tally.get(key) || 0) + 1) + } + + if (tally.size <= MASS_MAX_DISTINCT) { + const total = rates.length + const points = [...tally.entries()] + .map(([x, freq]) => ({ x, y: freq / total })) + .sort((a, b) => a.x - b.x) + return { + kind: 'mass', + points, + maxY: Math.max(...points.map((p) => p.y)), + step: explicitStep > 0 ? explicitStep : inferStep(points, max, min), + } + } + + const points = kdeCurve(rates, { min, max, n }) + return { + kind: 'kde', + points, + maxY: Math.max(...points.map((p) => p.y)), + step: 0, + } +} + +/** Smallest gap between achievable values; a visible default for a single point. */ +function inferStep(points, max, min) { + let smallest = Infinity + for (let i = 1; i < points.length; i++) { + const gap = points[i].x - points[i - 1].x + if (gap > 0 && gap < smallest) smallest = gap + } + if (Number.isFinite(smallest)) return smallest + const width = Number.isFinite(max) && max > min ? max - min : 1 + return width / 40 +} diff --git a/src/utils/densityProfile.test.js b/src/utils/densityProfile.test.js new file mode 100644 index 0000000..27c1142 --- /dev/null +++ b/src/utils/densityProfile.test.js @@ -0,0 +1,135 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { MASS_MAX_DISTINCT, densityProfile, rateProfile } from './densityProfile.js' +import { kdeCurve } from './kde.js' +import { toRates } from './pooling.js' + +/** counts whose distinct-value count is exactly `k` (values 0…k-1, padded). */ +function countsWithDistinct(k, total = 500) { + const out = [] + for (let i = 0; i < total; i++) out.push(i % k) + return out +} + +test('densityProfile: discrete posterior returns a mass profile', () => { + // 500 draws over 4 achievable counts is the Carson City AI/AN case: a KDE + // renders it as a lumpy smear that reads as a rendering bug. + const profile = densityProfile(countsWithDistinct(4), 53, { max: 100 }) + assert.equal(profile.kind, 'mass') +}) + +test('densityProfile: mass points are probabilities at achievable rates', () => { + const profile = densityProfile([0, 0, 0, 1], 500, { max: 10 }) + assert.equal(profile.kind, 'mass') + assert.deepEqual(profile.points, [ + { x: 0, y: 0.75 }, + { x: 2, y: 0.25 }, + ]) +}) + +test('densityProfile: mass probabilities sum to 1', () => { + const profile = densityProfile([0, 0, 1, 2, 2, 5], 1000, { max: 10 }) + const total = profile.points.reduce((sum, p) => sum + p.y, 0) + assert.ok(Math.abs(total - 1) < 1e-12, `probabilities summed to ${total}`) +}) + +test('densityProfile: mass points are sorted by rate ascending', () => { + const profile = densityProfile([5, 0, 3, 1], 1000, { max: 10 }) + const xs = profile.points.map((p) => p.x) + assert.deepEqual(xs, [...xs].sort((a, b) => a - b)) +}) + +test('densityProfile: mass carries the achievable-rate step for staircase width', () => { + // Counts are integers, so achievable rates are spaced 1000/enroll apart. + // The chart needs that width to draw a bar rather than a hairline. + const profile = densityProfile([0, 1], 250, { max: 10 }) + assert.equal(profile.step, 4) +}) + +test('densityProfile: a fully degenerate draw set is a single mass point', () => { + // 500 identical zeros is common in small districts. This is the case a + // Gaussian KDE turns into a delta spike. + const profile = densityProfile(new Array(500).fill(0), 4073, { max: 20 }) + assert.equal(profile.kind, 'mass') + assert.deepEqual(profile.points, [{ x: 0, y: 1 }]) +}) + +test('densityProfile: switches to KDE above the distinct-value cutoff', () => { + const atCutoff = densityProfile(countsWithDistinct(MASS_MAX_DISTINCT), 1000, { max: 50 }) + const aboveCutoff = densityProfile(countsWithDistinct(MASS_MAX_DISTINCT + 1), 1000, { max: 50 }) + assert.equal(atCutoff.kind, 'mass') + assert.equal(aboveCutoff.kind, 'kde') +}) + +test('densityProfile: KDE branch delegates to kdeCurve over the given domain', () => { + // The bandwidth clamp in kde.js was tuned for exactly these zero-inflated + // posteriors — this must delegate, not re-derive. + const counts = countsWithDistinct(40) + const enroll = 1000 + const profile = densityProfile(counts, enroll, { min: 0, max: 50, n: 60 }) + const expected = kdeCurve(toRates(counts, enroll), { min: 0, max: 50, n: 60 }) + assert.equal(profile.kind, 'kde') + assert.deepEqual(profile.points, expected) +}) + +test('densityProfile: reports the profile peak for per-group normalization', () => { + const profile = densityProfile([0, 0, 0, 1], 500, { max: 10 }) + assert.equal(profile.maxY, 0.75) + + const kde = densityProfile(countsWithDistinct(40), 1000, { max: 50 }) + assert.equal(kde.maxY, Math.max(...kde.points.map((p) => p.y))) +}) + +test('densityProfile: null for unusable input rather than an empty curve', () => { + assert.equal(densityProfile([], 500, { max: 10 }), null) + assert.equal(densityProfile(undefined, 500, { max: 10 }), null) + assert.equal(densityProfile([0, 1], 0, { max: 10 }), null) + assert.equal(densityProfile([0, 1], undefined, { max: 10 }), null) +}) + +test('densityProfile: does not mutate its input counts', () => { + const counts = [3, 1, 2] + densityProfile(counts, 1000, { max: 10 }) + assert.deepEqual(counts, [3, 1, 2]) +}) + +// ——— rateProfile (used for already-differenced quantities) ——— + +test('rateProfile: mass profile straight from rate values', () => { + const profile = rateProfile([-1, -1, 0, 2], { min: -5, max: 5 }) + assert.equal(profile.kind, 'mass') + assert.deepEqual(profile.points, [ + { x: -1, y: 0.5 }, + { x: 0, y: 0.25 }, + { x: 2, y: 0.25 }, + ]) +}) + +test('rateProfile: infers the staircase step from the smallest observed gap', () => { + assert.equal(rateProfile([0, 0.5, 2], { min: 0, max: 5 }).step, 0.5) +}) + +test('rateProfile: falls back to a visible step for a single achievable value', () => { + const profile = rateProfile([3, 3, 3], { min: 0, max: 40 }) + assert.ok(profile.step > 0, 'a single mass point still needs a drawable width') +}) + +test('rateProfile: an explicit step wins over the inferred one', () => { + assert.equal(rateProfile([0, 3], { min: 0, max: 5 }, 0.25).step, 0.25) +}) + +test('rateProfile: merges values differing only by floating-point noise', () => { + const profile = rateProfile([0.1 + 0.2, 0.3, 0.3], { min: 0, max: 1 }) + assert.equal(profile.points.length, 1) + assert.equal(profile.points[0].y, 1) +}) + +test('rateProfile: switches to KDE above the distinct-value cutoff', () => { + const many = Array.from({ length: 300 }, (_, i) => (i % (MASS_MAX_DISTINCT + 1)) * 0.7) + assert.equal(rateProfile(many, { min: 0, max: 10 }).kind, 'kde') +}) + +test('rateProfile: null for an empty or missing set', () => { + assert.equal(rateProfile([], { max: 5 }), null) + assert.equal(rateProfile(undefined, { max: 5 }), null) +}) diff --git a/src/utils/districtGroups.js b/src/utils/districtGroups.js new file mode 100644 index 0000000..291dca9 --- /dev/null +++ b/src/utils/districtGroups.js @@ -0,0 +1,128 @@ +/** + * Turns the API's per-race×sex estimate rows into the row set the results page + * actually displays, in one place, so the summary table, the density panel and + * the difference chart can never disagree about which groups exist, what they + * are called, or which are selected by default. + * + * "Display space" is either eight race×sex groups or — when sex pooling is on — + * four race groups. Keys come from `groupKey`, so the same map lookups work in + * both modes. + */ + +import { groupLabel, sentenceGroupLabel, shortGroupLabel } from './colors.js' +import { groupKey } from './drawGroups.js' +import { poolBySex } from './pooling.js' + +/** The four modeled race groups, in the fixed palette order (see colors.js). */ +export const RACE_ORDER = ['WH', 'BL', 'HI', 'AM'] +const SEXES = ['F', 'M'] + +/** + * @param {Array} rows - estimate rows for one district/model/year + * @param {boolean} pooled - collapse Female + Male into one row per race + * @returns {Array<{key: string, race: string, sex: string|null, label: string, + * shortLabel: string, enroll: number, observed: number, rate: number}>} + * sorted by observed arrests descending. + */ +export function buildDisplayGroups(rows, pooled) { + const usable = (rows || []).filter( + (r) => RACE_ORDER.includes(r.race) && SEXES.includes(r.sex), + ) + + const acc = new Map() + for (const r of usable) { + const sex = pooled ? null : r.sex + const key = groupKey(r.race, sex) + const prev = acc.get(key) || { key, race: r.race, sex, enroll: 0, observed: 0 } + acc.set(key, { + ...prev, + enroll: prev.enroll + (r.stu_enroll || 0), + observed: prev.observed + (r.observed_arrests || 0), + // The model's own summary interval, carried per 1,000 so a chart can fall + // back to the fitted approximation when real draws can't be fetched. + // Null when pooled: adding two groups' interval *bounds* together is not + // a pooled interval, and there is no honest way to fake one without the + // draws. A pooled group with no draws simply has no modelled shape. + modeled: pooled + ? null + : { + median: (r.rate_median || 0) * 1000, + lower: (r.rate_lower || 0) * 1000, + upper: (r.rate_upper || 0) * 1000, + }, + }) + } + + return [...acc.values()] + .map((g) => ({ + ...g, + label: groupLabel(g.race, g.sex), + shortLabel: shortGroupLabel(g.race, g.sex), + sentenceLabel: sentenceGroupLabel(g.race, g.sex), + // A rate with no denominator is not a large rate — it is no rate. Report + // 0 and let the table's enrollment column show why. + rate: g.enroll > 0 ? (g.observed / g.enroll) * 1000 : 0, + })) + .sort((a, b) => b.observed - a.observed || b.enroll - a.enroll || a.key.localeCompare(b.key)) +} + +/** + * Groups checked on first render: every group with at least one observed + * arrest. When a district reports none at all, falls back to the two largest by + * enrollment so the chart still shows something explainable rather than an + * empty panel (the caller says so in the UI). + * + * @param {ReturnType} groups + * @returns {string[]} group keys + */ +export function defaultSelectedKeys(groups) { + if (!groups?.length) return [] + const withArrests = groups.filter((g) => g.observed > 0) + if (withArrests.length > 0) return withArrests.map((g) => g.key) + return [...groups] + .sort((a, b) => b.enroll - a.enroll) + .slice(0, 2) + .map((g) => g.key) +} + +/** + * The difference chart's opening pair: the two groups with the most observed + * arrests. Null when there aren't two groups to compare. + * + * @param {ReturnType} groups + * @returns {[string, string] | null} + */ +export function defaultDiffPair(groups) { + if (!groups || groups.length < 2) return null + return [groups[0].key, groups[1].key] +} + +/** + * Enrollment for every race×sex cell, keyed for `poolBySex`/`displayDraws`. + * Always unpooled — pooling sums these itself. + * + * @param {Array} rows + * @returns {Record} + */ +export function enrollByGroupKey(rows) { + const out = {} + for (const r of rows || []) { + if (!RACE_ORDER.includes(r.race) || !SEXES.includes(r.sex)) continue + out[groupKey(r.race, r.sex)] = r.stu_enroll || 0 + } + return out +} + +/** + * Moves one model's raw race×sex draw counts into display space. + * + * @param {Record | null | undefined} counts - keyed race×sex + * @param {Record} enrollByGroup - keyed race×sex + * @param {boolean} pooled + * @returns {{counts: Record, enroll: Record}} + */ +export function displayDraws(counts, enrollByGroup, pooled) { + if (!counts) return { counts: {}, enroll: {} } + if (pooled) return poolBySex(counts, enrollByGroup) + return { counts, enroll: enrollByGroup || {} } +} diff --git a/src/utils/districtGroups.test.js b/src/utils/districtGroups.test.js new file mode 100644 index 0000000..fd7e1d8 --- /dev/null +++ b/src/utils/districtGroups.test.js @@ -0,0 +1,169 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + buildDisplayGroups, + defaultDiffPair, + defaultSelectedKeys, + displayDraws, + enrollByGroupKey, +} from './districtGroups.js' + +const row = (race, sex, stu_enroll, observed_arrests) => ({ race, sex, stu_enroll, observed_arrests }) + +const CLARK = [ + row('WH', 'F', 30000, 8), + row('WH', 'M', 31000, 20), + row('BL', 'F', 12000, 18), + row('BL', 'M', 12500, 40), + row('HI', 'F', 30000, 5), + row('HI', 'M', 31000, 8), + row('AM', 'F', 200, 0), + row('AM', 'M', 228, 1), +] + +// ——— buildDisplayGroups ——— + +test('buildDisplayGroups: one row per race×sex when not pooled', () => { + const groups = buildDisplayGroups(CLARK, false) + assert.equal(groups.length, 8) + assert.deepEqual( + groups.map((g) => g.key).sort(), + ['AM_F', 'AM_M', 'BL_F', 'BL_M', 'HI_F', 'HI_M', 'WH_F', 'WH_M'], + ) +}) + +test('buildDisplayGroups: collapses to four race rows when pooled', () => { + const groups = buildDisplayGroups(CLARK, true) + assert.equal(groups.length, 4) + const bl = groups.find((g) => g.key === 'BL') + assert.equal(bl.enroll, 24500) + assert.equal(bl.observed, 58) + assert.equal(bl.sex, null) +}) + +test('buildDisplayGroups: sorted by observed arrests descending', () => { + const observed = buildDisplayGroups(CLARK, false).map((g) => g.observed) + assert.deepEqual(observed, [...observed].sort((a, b) => b - a)) +}) + +test('buildDisplayGroups: rate is per 1,000 students', () => { + const bl = buildDisplayGroups(CLARK, false).find((g) => g.key === 'BL_M') + assert.ok(Math.abs(bl.rate - (40 / 12500) * 1000) < 1e-12) +}) + +test('buildDisplayGroups: rate is 0 rather than Infinity with no enrollment', () => { + const groups = buildDisplayGroups([row('BL', 'F', 0, 3)], false) + assert.equal(groups[0].rate, 0) +}) + +test('buildDisplayGroups: labels a pooled group by race alone', () => { + const bl = buildDisplayGroups(CLARK, true).find((g) => g.key === 'BL') + assert.equal(bl.label, 'Black') + const blf = buildDisplayGroups(CLARK, false).find((g) => g.key === 'BL_F') + assert.equal(blf.label, 'Black Female') +}) + +test('buildDisplayGroups: the sentence label keeps the race capitalized', () => { + // Guards the readout in GroupDifference: lowercasing the whole label turned + // "Black Male" into "black male" mid-sentence. + const groups = buildDisplayGroups(CLARK, false) + assert.equal(groups.find((g) => g.key === 'BL_M').sentenceLabel, 'Black male') + assert.equal(groups.find((g) => g.key === 'WH_F').sentenceLabel, 'White female') + assert.equal(buildDisplayGroups(CLARK, true).find((g) => g.key === 'HI').sentenceLabel, 'Hispanic') +}) + +test('buildDisplayGroups: carries the modeled interval per 1,000 when not pooled', () => { + const groups = buildDisplayGroups( + [{ race: 'BL', sex: 'M', stu_enroll: 1000, observed_arrests: 4, rate_median: 0.004, rate_lower: 0.001, rate_upper: 0.009 }], + false, + ) + assert.deepEqual(groups[0].modeled, { median: 4, lower: 1, upper: 9 }) +}) + +test('buildDisplayGroups: pooled groups carry no modeled interval', () => { + // Summing two groups' interval bounds is not a pooled interval — there is no + // honest fallback shape without the draws, so don't invent one. + const groups = buildDisplayGroups(CLARK, true) + assert.ok(groups.every((g) => g.modeled === null)) +}) + +test('buildDisplayGroups: ignores races and sexes outside the modeled set', () => { + const groups = buildDisplayGroups([...CLARK, row('AS', 'F', 900, 4), row('WH', 'X', 5, 5)], false) + assert.equal(groups.length, 8) + assert.ok(!groups.some((g) => g.race === 'AS')) +}) + +test('buildDisplayGroups: missing counts default to zero', () => { + const groups = buildDisplayGroups([{ race: 'BL', sex: 'F' }], false) + assert.equal(groups[0].observed, 0) + assert.equal(groups[0].enroll, 0) +}) + +test('buildDisplayGroups: empty input gives an empty list', () => { + assert.deepEqual(buildDisplayGroups([], false), []) + assert.deepEqual(buildDisplayGroups(undefined, true), []) +}) + +// ——— defaultSelectedKeys ——— + +test('defaultSelectedKeys: every group with at least one observed arrest', () => { + const groups = buildDisplayGroups(CLARK, false) + const keys = defaultSelectedKeys(groups) + assert.ok(!keys.includes('AM_F')) + assert.ok(keys.includes('AM_M')) + assert.equal(keys.length, 7) +}) + +test('defaultSelectedKeys: falls back to the two largest by enrollment', () => { + // A district with no arrests anywhere still has to show something, or the + // chart renders empty with no explanation. + const groups = buildDisplayGroups( + [row('WH', 'F', 900, 0), row('WH', 'M', 1000, 0), row('AM', 'F', 20, 0)], + false, + ) + assert.deepEqual(defaultSelectedKeys(groups).sort(), ['WH_F', 'WH_M']) +}) + +test('defaultSelectedKeys: empty for no groups', () => { + assert.deepEqual(defaultSelectedKeys([]), []) +}) + +// ——— defaultDiffPair ——— + +test('defaultDiffPair: the two groups with the most observed arrests', () => { + assert.deepEqual(defaultDiffPair(buildDisplayGroups(CLARK, false)), ['BL_M', 'WH_M']) +}) + +test('defaultDiffPair: null when fewer than two groups exist', () => { + assert.equal(defaultDiffPair(buildDisplayGroups([row('BL', 'F', 10, 1)], false)), null) + assert.equal(defaultDiffPair([]), null) +}) + +// ——— enrollByGroupKey ——— + +test('enrollByGroupKey: maps race×sex keys to enrollment', () => { + const map = enrollByGroupKey(CLARK) + assert.equal(map.BL_M, 12500) + assert.equal(Object.keys(map).length, 8) +}) + +// ——— displayDraws ——— + +test('displayDraws: passes raw counts straight through when not pooled', () => { + const counts = { BL_F: [1, 2], BL_M: [3, 4] } + const out = displayDraws(counts, { BL_F: 100, BL_M: 200 }, false) + assert.deepEqual(out.counts, counts) + assert.deepEqual(out.enroll, { BL_F: 100, BL_M: 200 }) +}) + +test('displayDraws: pools by sex when pooling is on', () => { + const out = displayDraws({ BL_F: [1, 2], BL_M: [3, 4] }, { BL_F: 100, BL_M: 200 }, true) + assert.deepEqual(out.counts, { BL: [4, 6] }) + assert.deepEqual(out.enroll, { BL: 300 }) +}) + +test('displayDraws: empty structures for missing counts', () => { + const out = displayDraws(null, { BL_F: 100 }, false) + assert.deepEqual(out.counts, {}) + assert.deepEqual(out.enroll, {}) +}) diff --git a/src/utils/drawGroups.js b/src/utils/drawGroups.js index f7a6966..de3bae2 100644 --- a/src/utils/drawGroups.js +++ b/src/utils/drawGroups.js @@ -1,33 +1,72 @@ /** - * Shared key format and coverage check for the per-group posterior draws + * Shared key format and coverage checks 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. + * + * Draw arrays are *counts indexed by draw* — `counts[draw_id - 1]` — not rates + * and not push-ordered. That makes row order out of DuckDB irrelevant, and it + * makes a missing draw a hole rather than a silently shorter array, which is + * why `isCompleteDrawSet` checks every index instead of trusting `length`. */ -/** Canonical key for one race×sex group in a `drawsByGroup` map. */ +/** + * Canonical key for one group in a draws map. + * + * With a sex, this is one race×sex cell (`'BL_F'`). Without one — the pooled + * mode sparse districts fall back to — it is the race alone (`'BL'`). The two + * shapes can never collide, so a single map type serves both modes. + * + * @param {string} race + * @param {string} [sex] - omit (or pass null/'') for a sex-pooled group + * @returns {string} + */ export function groupKey(race, sex) { - return `${race}_${sex}` + return sex ? `${race}_${sex}` : `${race}` } /** - * True only when *every* group in `groups` has a non-empty draw array. + * True when `counts` holds a finite value at every draw index 0…nDraws-1. + * + * A partial group must fall back rather than render a short draw set: a group + * present for only 300 of 500 draws would otherwise get a density and an + * interval computed off a biased subsample, indistinguishable on screen from + * a complete one. + * + * @param {number[] | null | undefined} counts + * @param {number} nDraws + * @returns {boolean} + */ +export function isCompleteDrawSet(counts, nDraws) { + if (!Array.isArray(counts) || !(nDraws > 0) || counts.length !== nDraws) return false + for (let i = 0; i < nDraws; i++) { + if (!Number.isFinite(counts[i])) return false + } + return true +} + +/** + * True only when *every* group in `groups` has a complete 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. + * complete coverage instead — a group can be missing 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. + * @param {Array<{race: string, sex?: string}>} groups - the groups a chart is + * actually rendering, not everything the API returned. Omit `sex` for pooled + * groups. * @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) + return groups.every((g) => { + const counts = drawsByGroup[groupKey(g.race, g.sex)] + return isCompleteDrawSet(counts, counts?.length ?? 0) + }) } diff --git a/src/utils/drawGroups.test.js b/src/utils/drawGroups.test.js index 377f2a3..1be6395 100644 --- a/src/utils/drawGroups.test.js +++ b/src/utils/drawGroups.test.js @@ -1,11 +1,24 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { groupKey, hasDrawsForAll } from './drawGroups.js' +import { groupKey, hasDrawsForAll, isCompleteDrawSet } from './drawGroups.js' test('groupKey: joins race and sex with an underscore', () => { assert.equal(groupKey('BL', 'F'), 'BL_F') }) +test('groupKey: returns the race alone for a pooled group', () => { + // Pooled rows are keyed by race only, so the same helper builds both key + // shapes and nothing downstream has to know which mode it is in. + assert.equal(groupKey('BL'), 'BL') + assert.equal(groupKey('BL', null), 'BL') + assert.equal(groupKey('BL', ''), 'BL') +}) + +test('groupKey: a pooled key never collides with an unpooled one', () => { + assert.notEqual(groupKey('BL'), groupKey('BL', 'F')) + assert.notEqual(groupKey('BL'), groupKey('BL', 'M')) +}) + 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) @@ -39,3 +52,49 @@ test('hasDrawsForAll: ignores groups the chart is not rendering', () => { const map = { WH_F: [1], BL_F: [2], AS_F: [3] } assert.equal(hasDrawsForAll(map, [{ race: 'WH', sex: 'F' }, { race: 'BL', sex: 'F' }]), true) }) + +test('hasDrawsForAll: works on pooled (race-only) groups', () => { + const map = { WH: [1], BL: [2] } + assert.equal(hasDrawsForAll(map, [{ race: 'WH' }, { race: 'BL' }]), true) + assert.equal(hasDrawsForAll(map, [{ race: 'WH' }, { race: 'HI' }]), false) +}) + +test('hasDrawsForAll: false when a count array has a hole', () => { + // Counts are indexed by draw_id, so a group missing draw 2 leaves a hole + // rather than a short array. length alone would call this complete. + const holey = [1, 2, 3] + delete holey[1] + assert.equal(hasDrawsForAll({ BL_F: holey }, [{ race: 'BL', sex: 'F' }]), false) +}) + +// ——— isCompleteDrawSet ——— + +test('isCompleteDrawSet: true for a dense array of the expected length', () => { + assert.equal(isCompleteDrawSet([0, 1, 2], 3), true) +}) + +test('isCompleteDrawSet: false when the array is shorter than nDraws', () => { + assert.equal(isCompleteDrawSet([0, 1], 3), false) +}) + +test('isCompleteDrawSet: false when the array is longer than nDraws', () => { + assert.equal(isCompleteDrawSet([0, 1, 2, 3], 3), false) +}) + +test('isCompleteDrawSet: false when a draw index was never filled', () => { + const holey = new Array(3) + holey[0] = 1 + holey[2] = 3 + assert.equal(isCompleteDrawSet(holey, 3), false) +}) + +test('isCompleteDrawSet: false for a non-finite entry', () => { + assert.equal(isCompleteDrawSet([1, NaN, 3], 3), false) + assert.equal(isCompleteDrawSet([1, Infinity, 3], 3), false) +}) + +test('isCompleteDrawSet: false for a missing array or a zero-draw expectation', () => { + assert.equal(isCompleteDrawSet(null, 3), false) + assert.equal(isCompleteDrawSet(undefined, 3), false) + assert.equal(isCompleteDrawSet([], 0), false) +}) diff --git a/src/utils/groupDifference.js b/src/utils/groupDifference.js new file mode 100644 index 0000000..42b356d --- /dev/null +++ b/src/utils/groupDifference.js @@ -0,0 +1,57 @@ +/** + * The difference in modelled arrest rate between two student groups, taken + * draw by draw — the quantity behind the white paper's Fig 7 + * (`wp_fig_group_difference`). + * + * Read the wording carefully before writing a caption from these numbers. + * Upstream, `draw_id` is renumbered 1–500 per write batch, and a district's + * groups land in different batches, so draw *k* of group A and draw *k* of + * group B are not the same parameter draw. Measured correlation between two + * groups' `pred` in Clark County was ≈ 0.02 even within a batch — observation + * noise from `posterior_predict` dominates. The published figure has exactly + * the same property, so the app matches the paper; what neither can claim is a + * paired-parameter contrast. Always say **posterior predictive draws**, never + * "paired parameter draws". + */ + +import { quantile } from './kde.js' + +/** + * Per-draw difference in rate per 1,000: group A minus group B. + * + * Returns an empty array rather than a truncated one when the two draw sets + * disagree in length — pairing draw 3 of one group with draw 7 of another would + * fabricate a difference distribution out of unrelated draws. + * + * @param {number[] | null | undefined} countsA - predicted counts, indexed by draw + * @param {number | null | undefined} enrollA + * @param {number[] | null | undefined} countsB + * @param {number | null | undefined} enrollB + * @returns {number[]} + */ +export function differenceRates(countsA, enrollA, countsB, enrollB) { + if (!countsA?.length || !countsB?.length) return [] + if (countsA.length !== countsB.length) return [] + if (!(enrollA > 0) || !(enrollB > 0)) return [] + return countsA.map((a, i) => (a / enrollA) * 1000 - (countsB[i] / enrollB) * 1000) +} + +/** + * @param {number[] | null | undefined} deltas + * @returns {{n: number, prGreater: number, median: number, lower80: number, + * upper80: number, lower95: number, upper95: number} | null} + * `prGreater` counts draws **strictly** above zero, so a group whose every + * draw ties reads as 0%, not 100%. + */ +export function differenceSummary(deltas) { + if (!deltas?.length) return null + return { + n: deltas.length, + prGreater: deltas.filter((d) => d > 0).length / deltas.length, + median: quantile(deltas, 0.5), + lower80: quantile(deltas, 0.1), + upper80: quantile(deltas, 0.9), + lower95: quantile(deltas, 0.025), + upper95: quantile(deltas, 0.975), + } +} diff --git a/src/utils/groupDifference.test.js b/src/utils/groupDifference.test.js new file mode 100644 index 0000000..8f369dd --- /dev/null +++ b/src/utils/groupDifference.test.js @@ -0,0 +1,77 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { differenceRates, differenceSummary } from './groupDifference.js' + +// ——— differenceRates ——— + +test('differenceRates: subtracts rates at the same draw index', () => { + // A: 2 and 4 arrests in 1,000 students → 2 and 4 per 1,000 + // B: 1 and 1 arrests in 500 students → 2 and 2 per 1,000 + const deltas = differenceRates([2, 4], 1000, [1, 1], 500) + assert.deepEqual(deltas, [0, 2]) +}) + +test('differenceRates: differences can be negative', () => { + assert.deepEqual(differenceRates([0], 1000, [5], 1000), [-5]) +}) + +test('differenceRates: empty when the draw sets are different lengths', () => { + // Pairing draw 3 of one group with draw 7 of another would fabricate a + // difference distribution out of unrelated draws. + assert.deepEqual(differenceRates([1, 2, 3], 1000, [1, 2], 1000), []) +}) + +test('differenceRates: empty when either denominator is missing', () => { + assert.deepEqual(differenceRates([1], 0, [1], 1000), []) + assert.deepEqual(differenceRates([1], 1000, [1], undefined), []) +}) + +test('differenceRates: empty when either draw set is missing', () => { + assert.deepEqual(differenceRates(null, 1000, [1], 1000), []) + assert.deepEqual(differenceRates([1], 1000, [], 1000), []) +}) + +test('differenceRates: does not mutate its inputs', () => { + const a = [1, 2] + const b = [3, 4] + differenceRates(a, 1000, b, 1000) + assert.deepEqual(a, [1, 2]) + assert.deepEqual(b, [3, 4]) +}) + +// ——— differenceSummary ——— + +test('differenceSummary: Pr(delta > 0) is the share of draws strictly above zero', () => { + const summary = differenceSummary([-1, 0, 1, 2]) + assert.equal(summary.prGreater, 0.5) +}) + +test('differenceSummary: a draw of exactly zero does not count as greater', () => { + assert.equal(differenceSummary([0, 0, 0, 0]).prGreater, 0) +}) + +test('differenceSummary: reports the median and both interval widths', () => { + const deltas = Array.from({ length: 101 }, (_, i) => i) // 0…100 + const summary = differenceSummary(deltas) + assert.equal(summary.median, 50) + assert.equal(summary.lower80, 10) + assert.equal(summary.upper80, 90) + assert.equal(summary.lower95, 2.5) + assert.equal(summary.upper95, 97.5) +}) + +test('differenceSummary: the 95% interval contains the 80% interval', () => { + const deltas = Array.from({ length: 500 }, (_, i) => Math.sin(i) * 4) + const s = differenceSummary(deltas) + assert.ok(s.lower95 <= s.lower80) + assert.ok(s.upper95 >= s.upper80) +}) + +test('differenceSummary: reports the draw count it summarized', () => { + assert.equal(differenceSummary([1, 2, 3]).n, 3) +}) + +test('differenceSummary: null for an empty or missing set', () => { + assert.equal(differenceSummary([]), null) + assert.equal(differenceSummary(undefined), null) +}) diff --git a/src/utils/pooling.js b/src/utils/pooling.js new file mode 100644 index 0000000..62d45b0 --- /dev/null +++ b/src/utils/pooling.js @@ -0,0 +1,115 @@ +/** + * Sex pooling for sparse districts. + * + * This is the project's own house method applied on a different axis: + * `build_state_summary()` in `crdc-arrests/R/summarize_draws.R` pools across + * LEAs by summing `pred` and `stu_enroll` *within each draw* and only then + * summarizing across draws. Pooling Female + Male inside one district is the + * identical operation — sum the numerators draw-by-draw, sum the denominators, + * divide once. Summing rates instead would weight a 53-student cell the same as + * a 3,000-student one. + * + * Pure functions; no React, no fetching. + */ + +import { groupKey } from './drawGroups.js' + +/** + * Whole-district trigger for sex pooling: fewer than this many observed arrests + * across all eight race×sex cells. + * + * 20 is chosen so that the average cell carries at least ~2 arrests before the + * app is willing to show eight separate posteriors. Below it, most cells are + * zero-count and their posteriors are dominated by the prior and by + * observation noise, so eight ridges read as eight findings when they are + * really one. The threshold is on the *district* total rather than per cell so + * the table's row set doesn't change shape group by group. + */ +export const POOL_BY_SEX_ARREST_THRESHOLD = 20 + +/** + * True when a district is sparse enough that its eight race×sex cells should + * collapse to four race rows. + * + * Returns false for an empty row set: no rows is not "a sparse district", it is + * no district data at all, and firing the pooling banner there would explain a + * rule that never applied. + * + * @param {Array<{observed_arrests?: number|null}>} rows - estimate rows for one + * district/model/year, one per race×sex cell. + * @returns {boolean} + */ +export function shouldPoolBySex(rows) { + if (!rows?.length) return false + const total = rows.reduce((sum, r) => sum + (r.observed_arrests || 0), 0) + return total < POOL_BY_SEX_ARREST_THRESHOLD +} + +/** + * Per-1,000 rate array from an array of predicted counts. + * + * Returns an empty array when the denominator is missing or non-positive: a + * rate with no denominator is undefined, not zero, and every consumer already + * treats an empty draw array as "nothing to draw here". + * + * @param {number[] | null | undefined} counts - predicted counts, indexed by draw + * @param {number | null | undefined} enroll - students in the group + * @returns {number[]} + */ +export function toRates(counts, enroll) { + if (!counts?.length || !(enroll > 0)) return [] + return counts.map((c) => (c / enroll) * 1000) +} + +/** + * Pools Female + Male within each race, summing predicted counts at the same + * draw index and summing enrollment. + * + * Two refusals keep the arithmetic honest: + * + * - A race whose sexes have different draw-array lengths is dropped entirely. + * Element-wise addition would pair unrelated draws and truncate to the + * shorter array, producing a narrower pooled interval than the data supports. + * - Numerator and denominator are built from the same set of sexes. Adding both + * sexes' enrollment to one sex's counts would roughly halve the rate — an + * invented improvement, not a pooled estimate — and adding a sex's counts + * without its enrollment inflates it the same way. + * + * @param {Record} countsByGroup - keyed by `groupKey(race, sex)` + * @param {Record} enrollByGroup - keyed by `groupKey(race, sex)` + * @returns {{counts: Record, enroll: Record}} + * both keyed by race alone (see `groupKey(race)`). + */ +export function poolBySex(countsByGroup, enrollByGroup) { + const counts = {} + const enroll = {} + if (!countsByGroup) return { counts, enroll } + + const byRace = {} + for (const key of Object.keys(countsByGroup)) { + const [race] = key.split('_') + ;(byRace[race] ??= []).push(key) + } + + for (const [race, keys] of Object.entries(byRace)) { + // A group must bring both halves of the fraction. Counts with no matching + // enrollment would land in the numerator while contributing nothing to the + // denominator, inflating the pooled rate. + const present = keys.filter((k) => countsByGroup[k]?.length > 0 && enrollByGroup?.[k] > 0) + if (present.length === 0) continue + + const nDraws = countsByGroup[present[0]].length + if (present.some((k) => countsByGroup[k].length !== nDraws)) continue + + const summed = new Array(nDraws).fill(0) + for (const k of present) { + const arr = countsByGroup[k] + for (let i = 0; i < nDraws; i++) summed[i] += arr[i] + } + + counts[groupKey(race)] = summed + enroll[groupKey(race)] = present.reduce((sum, k) => sum + (enrollByGroup?.[k] || 0), 0) + } + + return { counts, enroll } +} diff --git a/src/utils/pooling.test.js b/src/utils/pooling.test.js new file mode 100644 index 0000000..83b95a0 --- /dev/null +++ b/src/utils/pooling.test.js @@ -0,0 +1,110 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + POOL_BY_SEX_ARREST_THRESHOLD, + poolBySex, + shouldPoolBySex, + toRates, +} from './pooling.js' + +// ——— toRates ——— + +test('toRates: converts counts to a per-1,000 rate array', () => { + assert.deepEqual(toRates([0, 1, 2], 500), [0, 2, 4]) +}) + +test('toRates: does not mutate its input', () => { + const counts = [1, 2] + toRates(counts, 1000) + assert.deepEqual(counts, [1, 2]) +}) + +test('toRates: returns an empty array for non-positive enrollment', () => { + // A rate with no denominator is not zero, it is undefined — return nothing + // rather than a column of Infinity that a chart would happily plot. + assert.deepEqual(toRates([1, 2], 0), []) + assert.deepEqual(toRates([1, 2], -5), []) + assert.deepEqual(toRates([1, 2], undefined), []) +}) + +test('toRates: returns an empty array when counts are missing', () => { + assert.deepEqual(toRates(undefined, 100), []) + assert.deepEqual(toRates([], 100), []) +}) + +// ——— shouldPoolBySex ——— + +test('shouldPoolBySex: true when total observed arrests is under the threshold', () => { + const rows = [{ observed_arrests: 4 }, { observed_arrests: 2 }] + assert.equal(shouldPoolBySex(rows), true) +}) + +test('shouldPoolBySex: false at exactly the threshold', () => { + const rows = [{ observed_arrests: POOL_BY_SEX_ARREST_THRESHOLD }] + assert.equal(shouldPoolBySex(rows), false) +}) + +test('shouldPoolBySex: treats missing observed_arrests as zero', () => { + assert.equal(shouldPoolBySex([{ observed_arrests: null }, {}]), true) +}) + +test('shouldPoolBySex: false for an empty or missing row set', () => { + // No rows is not "a sparse district" — it is no district data at all. Firing + // the pooling banner there would explain a rule that never applied. + assert.equal(shouldPoolBySex([]), false) + assert.equal(shouldPoolBySex(undefined), false) +}) + +// ——— poolBySex ——— + +test('poolBySex: sums counts draw-by-draw and enrollment across F and M', () => { + const counts = { BL_F: [1, 2, 3], BL_M: [10, 20, 30] } + const enroll = { BL_F: 100, BL_M: 300 } + const pooled = poolBySex(counts, enroll) + assert.deepEqual(pooled.counts, { BL: [11, 22, 33] }) + assert.deepEqual(pooled.enroll, { BL: 400 }) +}) + +test('poolBySex: keys the result by race alone', () => { + const pooled = poolBySex( + { WH_F: [1], WH_M: [1], HI_F: [2], HI_M: [2] }, + { WH_F: 10, WH_M: 10, HI_F: 20, HI_M: 20 }, + ) + assert.deepEqual(Object.keys(pooled.counts).sort(), ['HI', 'WH']) +}) + +test('poolBySex: pools a race present for only one sex', () => { + const pooled = poolBySex({ AM_F: [1, 1] }, { AM_F: 53, AM_M: 61 }) + // Only F has draws, so only F's enrollment may go in the denominator — + // summing both sexes' enrollment against one sex's counts would halve the rate. + assert.deepEqual(pooled.counts, { AM: [1, 1] }) + assert.deepEqual(pooled.enroll, { AM: 53 }) +}) + +test('poolBySex: drops a race whose sexes have mismatched draw counts', () => { + // Adding a 500-draw array to a 400-draw array element-wise would silently + // pair unrelated draws and truncate; refuse instead. + const pooled = poolBySex({ BL_F: [1, 2, 3], BL_M: [1, 2] }, { BL_F: 10, BL_M: 10 }) + assert.deepEqual(pooled.counts, {}) + assert.deepEqual(pooled.enroll, {}) +}) + +test('poolBySex: ignores a group with no enrollment entry', () => { + const pooled = poolBySex({ BL_F: [1], BL_M: [2] }, { BL_F: 100 }) + assert.deepEqual(pooled.counts, { BL: [1] }) + assert.deepEqual(pooled.enroll, { BL: 100 }) +}) + +test('poolBySex: returns empty structures for empty input', () => { + const pooled = poolBySex({}, {}) + assert.deepEqual(pooled.counts, {}) + assert.deepEqual(pooled.enroll, {}) +}) + +test('poolBySex: does not mutate its inputs', () => { + const counts = { BL_F: [1], BL_M: [2] } + const enroll = { BL_F: 10, BL_M: 20 } + poolBySex(counts, enroll) + assert.deepEqual(counts, { BL_F: [1], BL_M: [2] }) + assert.deepEqual(enroll, { BL_F: 10, BL_M: 20 }) +}) diff --git a/src/utils/rateDomain.js b/src/utils/rateDomain.js new file mode 100644 index 0000000..6115f4e --- /dev/null +++ b/src/utils/rateDomain.js @@ -0,0 +1,52 @@ +/** + * Shared x-domain for the arrest-rate charts, in rate per 1,000. + * + * Every panel and every model row has to sit on the same axis or the reader + * will compare curves that aren't comparable, so the domain is computed once + * from everything that will be drawn. + */ + +import { quantile } from './kde.js' +import { niceTicks } from './niceTicks.js' + +/** + * Hard ceiling on the axis, inherited from the chart this replaces. + * + * 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 + * pixels. `computeRateDomain` reports `clipped` whenever the cap actually + * binds, so the chart can say so rather than cropping data off the edge in + * silence. + */ +export const MAX_RATE_DOMAIN = 30 + +/** + * Ignore the very top of each draw set when sizing the axis. The posterior + * predictive has a long right tail by construction; one draw in 500 should not + * decide the axis for all of them. + */ +const DOMAIN_QUANTILE = 0.995 + +const HEADROOM = 1.15 + +/** + * @param {Array} rateArrays - per-group draw rates, per 1,000 + * @param {number[]} acUpperRates - Agresti–Coull upper bounds, per 1,000 + * @returns {{ticks: number[], niceMax: number, clipped: boolean}} + * `clipped` is true when something that will be drawn extends past `niceMax`. + */ +export function computeRateDomain(rateArrays, acUpperRates) { + const candidates = [] + for (const rates of rateArrays || []) { + if (rates?.length) candidates.push(quantile(rates, DOMAIN_QUANTILE)) + } + for (const upper of acUpperRates || []) { + if (Number.isFinite(upper)) candidates.push(upper) + } + + const dataMax = candidates.length ? Math.max(...candidates) : 0 + const rawMax = Math.max(dataMax, 1) * HEADROOM + const { ticks, niceMax } = niceTicks(Math.min(rawMax, MAX_RATE_DOMAIN), 5) + + return { ticks, niceMax, clipped: dataMax > niceMax } +} diff --git a/src/utils/rateDomain.test.js b/src/utils/rateDomain.test.js new file mode 100644 index 0000000..9c4bd90 --- /dev/null +++ b/src/utils/rateDomain.test.js @@ -0,0 +1,51 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { MAX_RATE_DOMAIN, computeRateDomain } from './rateDomain.js' + +test('computeRateDomain: covers the draws and the frequentist bounds', () => { + const domain = computeRateDomain([[0, 1, 2, 3, 4]], [6]) + assert.ok(domain.niceMax >= 6, `niceMax ${domain.niceMax} should cover the AC upper bound`) + assert.equal(domain.clipped, false) +}) + +test('computeRateDomain: ticks start at zero and end at niceMax', () => { + const domain = computeRateDomain([[0, 1, 2]], [4]) + assert.equal(domain.ticks[0], 0) + assert.equal(domain.ticks[domain.ticks.length - 1], domain.niceMax) +}) + +test('computeRateDomain: one extreme draw does not blow out the axis', () => { + // A single 400-per-1,000 draw in a 53-student cell would otherwise squash + // every other group into the leftmost pixel. + const bulk = new Array(500).fill(2) + bulk[0] = 400 + const domain = computeRateDomain([bulk], [3]) + assert.ok(domain.niceMax < 10, `niceMax was ${domain.niceMax}`) +}) + +test('computeRateDomain: caps the domain and reports the clip', () => { + // 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 + // cropping data off the right edge. + const wide = new Array(500).fill(0).map((_, i) => (i < 490 ? 60 : 0)) + const domain = computeRateDomain([wide], [80]) + assert.equal(domain.niceMax, MAX_RATE_DOMAIN) + assert.equal(domain.clipped, true) +}) + +test('computeRateDomain: never returns a zero-width domain', () => { + const domain = computeRateDomain([[0, 0, 0]], [0]) + assert.ok(domain.niceMax > 0) + assert.equal(domain.clipped, false) +}) + +test('computeRateDomain: handles no data at all', () => { + const domain = computeRateDomain([], []) + assert.ok(domain.niceMax > 0) + assert.ok(domain.ticks.length > 1) +}) + +test('computeRateDomain: ignores empty rate arrays', () => { + const domain = computeRateDomain([[], [1, 2, 3]], []) + assert.ok(domain.niceMax > 0 && domain.niceMax < MAX_RATE_DOMAIN) +})