From c62d1e3068d2de5010c73a651cbcdb5e086722c2 Mon Sep 17 00:00:00 2001 From: Jared Knowles Date: Wed, 12 Aug 2026 08:40:02 -0400 Subject: [PATCH] fix: report the API's 95% intervals as 95%, not 90% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API returns 95% intervals: validate_interval() in crdc-arrests/api/R/validate.R defaults to 95L and this app never passes `interval=`. Two places claimed 90% anyway. fitSkewedInterval's `intervalMass` defaulted to 0.90, so the analytic fallback fitted 95% bounds as if they covered 90% of the mass. That divides each half-interval by 1.645 instead of 1.960 and understates sigma by ~16% — the fallback drew a distribution visibly narrower than the model's own, in the one code path where we have no draws to check it against. ArrestsOverTime's legend read "Modeled (median + 90% interval)" while plotting count_lower/count_upper, which are the same 95% bounds. Also exports probit() from distributionApprox.js so the Agresti-Coull port can reuse it rather than carrying a second qnorm implementation. --- src/charts/ArrestsOverTime.jsx | 4 +++- src/utils/distributionApprox.js | 25 +++++++++++++++++++------ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/charts/ArrestsOverTime.jsx b/src/charts/ArrestsOverTime.jsx index 3c9b90b..95cb522 100644 --- a/src/charts/ArrestsOverTime.jsx +++ b/src/charts/ArrestsOverTime.jsx @@ -129,7 +129,9 @@ export default function ArrestsOverTime({ data, modelId }) {

diff --git a/src/utils/distributionApprox.js b/src/utils/distributionApprox.js index fb50f5f..19e9901 100644 --- a/src/utils/distributionApprox.js +++ b/src/utils/distributionApprox.js @@ -42,9 +42,18 @@ function standardNormalCdf(z) { return 0.5 * (1 + erf(z / Math.SQRT2)) } -// Peter Acklam's rational approximation of the inverse standard normal CDF -// (probit), relative error < 1.15e-9. Supports arbitrary intervalMass. -function probit(p) { +/** + * Peter Acklam's rational approximation of the inverse standard normal CDF + * (probit / R's `qnorm`), relative error < 1.15e-9. Supports arbitrary + * intervalMass. + * + * Exported so `agrestiCoull.js` can reuse it — the app should carry exactly one + * probit implementation. + * + * @param {number} p - probability in (0, 1) + * @returns {number} + */ +export function probit(p) { const a = [-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02, 1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00] const b = [-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02, 6.680131188771972e+01, -1.328068155288572e+01] const c = [-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00, -2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00] @@ -70,11 +79,15 @@ function probit(p) { /** * @param {{median:number, lower:number, upper:number, intervalMass?:number, floorAtZero?:boolean}} p - * intervalMass: fraction of probability covered by [lower, upper] — the - * API's rate/count interval bounds are a 90% interval, so default 0.90. + * intervalMass: fraction of probability covered by [lower, upper]. The API's + * rate/count bounds are a **95%** interval — `validate_interval()` in + * crdc-arrests/api/R/validate.R defaults to 95 and the app never passes + * `interval=` — so the default is 0.95. Fitting 95% bounds as if they were + * 90% understates sigma by ~16% and draws a distribution narrower than the + * model's own. * @returns {{ median:number, sigmaLeft:number, sigmaRight:number, pdf:(x:number)=>number, cdf:(x:number)=>number }} */ -export function fitSkewedInterval({ median, lower, upper, intervalMass = 0.90, floorAtZero = true }) { +export function fitSkewedInterval({ median, lower, upper, intervalMass = 0.95, floorAtZero = true }) { const z = probit((1 + intervalMass) / 2) let sigmaLeft = (median - lower) / z