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