fix: report the API's 95% intervals as 95%, not 90%

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.
This commit is contained in:
2026-08-12 08:40:02 -04:00
parent db5111dab9
commit c62d1e3068
2 changed files with 22 additions and 7 deletions
+3 -1
View File
@@ -129,7 +129,9 @@ export default function ArrestsOverTime({ data, modelId }) {
<ChartLegend items={[
{ shape: 'diamond', color: OBSERVED_MARK_COLOR, label: 'Observed' },
{ shape: 'dot', color: MODELED_AGGREGATE_COLOR, label: 'Modeled (median + 90% interval)' },
// 95%, not 90%: these bars are the API's count_lower/count_upper, and
// validate_interval() defaults to 95 (the app never passes interval=).
{ shape: 'dot', color: MODELED_AGGREGATE_COLOR, label: 'Modeled (median + 95% interval)' },
]} />
<p style={{ fontSize: '0.75rem', color: 'var(--cv-ink-3)', marginTop: 'var(--space-1)' }}>
+19 -6
View File
@@ -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