Skip to main content

dml_core/
rd.rs

1//! Local polynomial regression discontinuity (RD) estimation: a port of the
2//! `rdrobust` package 2.1.1 (Calonico, Cattaneo and Titiunik, 2014;
3//! Calonico, Cattaneo and Farrell, 2020), used by [`RdFlex`](crate::RdFlex).
4//!
5//! - [`rd_bandwidths`]: the MSE- or CER-optimal bandwidths of `rdbwselect`
6//!   for the point estimate (`h`) and the bias correction (`b`).
7//! - [`rd_estimate`]: the local polynomial estimate at the cutoff with its
8//!   bias correction and the conventional, bias-corrected and robust
9//!   confidence intervals.
10//!
11//! [`RdOptions`] holds `rdrobust`'s options (polynomial orders, kernel,
12//! bandwidth selector, variance estimator, mass-point handling, ...) with its
13//! defaults; [`RdData`] the outcome, running variable and optional treatment
14//! (fuzzy design), covariates and cluster labels. With clusters the variance
15//! is cluster-robust (`rdrobust`'s `cr1`, or the leverage-adjusted `cr2` and
16//! `cr3`). [`rdbwselect`] and [`rdrobust`] are shortcuts with the default
17//! options.
18
19use ndarray::{s, Array1, Array2, ArrayView1, ArrayView2, Axis};
20
21use crate::compat::{np_mean, np_sum};
22use crate::error::{DmlError, Result};
23use crate::linalg::{cholesky_factor, spd_inverse, sym_eigen, sym_pinv};
24use crate::stats::norm_ppf;
25
26/// Kernel of the local polynomial fits.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum Kernel {
29    #[default]
30    Triangular,
31    Epanechnikov,
32    Uniform,
33}
34
35impl Kernel {
36    /// `rdrobust_kweight`.
37    fn weight(self, x: f64, c: f64, h: f64) -> f64 {
38        let u = (x - c) / h;
39        let inside = f64::from(u.abs() <= 1.0);
40        match self {
41            Kernel::Epanechnikov => (0.75 * (1.0 - u * u) * inside) / h,
42            Kernel::Uniform => (0.5 * inside) / h,
43            Kernel::Triangular => ((1.0 - u.abs()) * inside) / h,
44        }
45    }
46
47    /// The constant `C_c` of the pilot bandwidth.
48    fn c_c(self) -> f64 {
49        match self {
50            Kernel::Epanechnikov => 2.34,
51            Kernel::Uniform => 1.843,
52            Kernel::Triangular => 2.576,
53        }
54    }
55}
56
57/// Bandwidth selector, as `rdrobust`'s `bwselect`.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
59pub enum BwSelect {
60    /// One common MSE-optimal bandwidth for the RD effect.
61    #[default]
62    Mserd,
63    /// Separate MSE-optimal bandwidths below and above the cutoff.
64    Msetwo,
65    /// One common MSE-optimal bandwidth for the sum of the two regression
66    /// estimates.
67    Msesum,
68    /// `min(mserd, msesum)`.
69    Msecomb1,
70    /// `median(msetwo, mserd, msesum)` on each side.
71    Msecomb2,
72    /// Coverage-error-rate optimal versions of the above.
73    Cerrd,
74    Certwo,
75    Cersum,
76    Cercomb1,
77    Cercomb2,
78}
79
80impl BwSelect {
81    fn needs_two(self) -> bool {
82        matches!(
83            self,
84            BwSelect::Msetwo | BwSelect::Certwo | BwSelect::Msecomb2 | BwSelect::Cercomb2
85        )
86    }
87
88    fn needs_sum(self) -> bool {
89        matches!(
90            self,
91            BwSelect::Msesum
92                | BwSelect::Cersum
93                | BwSelect::Msecomb1
94                | BwSelect::Msecomb2
95                | BwSelect::Cercomb1
96                | BwSelect::Cercomb2
97        )
98    }
99
100    fn needs_rd(self) -> bool {
101        matches!(
102            self,
103            BwSelect::Mserd
104                | BwSelect::Cerrd
105                | BwSelect::Msecomb1
106                | BwSelect::Msecomb2
107                | BwSelect::Cercomb1
108                | BwSelect::Cercomb2
109        )
110    }
111
112    fn is_cer(self) -> bool {
113        matches!(
114            self,
115            BwSelect::Cerrd
116                | BwSelect::Certwo
117                | BwSelect::Cersum
118                | BwSelect::Cercomb1
119                | BwSelect::Cercomb2
120        )
121    }
122}
123
124/// Variance estimator, as `rdrobust`'s `vce`. As in `rdrobust`, the
125/// cluster-robust estimators need cluster labels and the others are switched
126/// to them when labels are given: `Nn`, `Hc0` and `Hc1` to `Cr1`, `Hc2` to
127/// `Cr2` and `Hc3` to `Cr3`; without labels `Cr1`, `Cr2` and `Cr3` fall back
128/// to `Hc1`, `Hc2` and `Hc3`.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
130pub enum Vce {
131    /// Nearest-neighbour residuals (with at least `nnmatch` neighbours).
132    #[default]
133    Nn,
134    Hc0,
135    Hc1,
136    Hc2,
137    Hc3,
138    /// Cluster-robust with the degrees-of-freedom correction.
139    Cr1,
140    /// Bell and McCaffrey (2002) bias-reduced cluster-robust (CRV2).
141    Cr2,
142    /// Pustejovsky and Tipton (2018) cluster-robust (CRV3).
143    Cr3,
144}
145
146/// Treatment of repeated values of the running variable, as `rdrobust`'s
147/// `masspoints`.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
149pub enum MassPoints {
150    /// With many repeated values, enlarge the pilot bandwidths to contain at
151    /// least `bwcheck` (default 10) distinct values, and count distinct
152    /// values in the pilot rule.
153    #[default]
154    Adjust,
155    /// Count distinct values only for detection (no adjustment).
156    Check,
157    /// Ignore repeated values.
158    Off,
159}
160
161/// Options of [`rd_bandwidths`] and [`rd_estimate`], with `rdrobust`'s
162/// defaults.
163#[derive(Debug, Clone, PartialEq)]
164pub struct RdOptions {
165    /// Polynomial order of the point estimate (default 1).
166    pub p: usize,
167    /// Polynomial order of the bias correction (default `p + 1`).
168    pub q: Option<usize>,
169    pub kernel: Kernel,
170    pub bwselect: BwSelect,
171    pub vce: Vce,
172    /// Minimum number of neighbours for `Vce::Nn` (default 3).
173    pub nnmatch: usize,
174    /// Confidence level of the intervals (default 0.95).
175    pub level: f64,
176    pub masspoints: MassPoints,
177    /// Minimum number of distinct running-variable values in the pilot
178    /// bandwidths (default: none, or 10 with detected mass points).
179    pub bwcheck: Option<usize>,
180    /// Restrict bandwidths to the range of the running variable (default true).
181    pub bwrestrict: bool,
182    /// Standardise the outcome and running variable for bandwidth selection
183    /// (default true).
184    pub stdvars: bool,
185    /// Scale of the regularisation term of the bandwidth selectors (default 1).
186    pub scaleregul: f64,
187    /// Fuzzy design with the bandwidth selector of the sharp design (also
188    /// used when the treatment does not vary on one side).
189    pub sharpbw: bool,
190    /// Bias bandwidth `b = h / rho` (default: selected separately).
191    pub rho: Option<f64>,
192    /// Multiplier of the estimate (default 1).
193    pub scalepar: f64,
194    /// Drop collinear covariates (default true).
195    pub covs_drop: bool,
196}
197
198impl Default for RdOptions {
199    fn default() -> Self {
200        Self {
201            p: 1,
202            q: None,
203            kernel: Kernel::Triangular,
204            bwselect: BwSelect::Mserd,
205            vce: Vce::Nn,
206            nnmatch: 3,
207            level: 0.95,
208            masspoints: MassPoints::Adjust,
209            bwcheck: None,
210            bwrestrict: true,
211            stdvars: true,
212            scaleregul: 1.0,
213            sharpbw: false,
214            rho: None,
215            scalepar: 1.0,
216            covs_drop: true,
217        }
218    }
219}
220
221impl RdOptions {
222    fn q(&self) -> usize {
223        self.q.unwrap_or(self.p + 1)
224    }
225
226    fn validate(&self) -> Result<()> {
227        let q = self.q();
228        let bad = |msg: &str| Err(DmlError::InvalidArgument(format!("rdrobust: {msg}")));
229        if self.p > 20 || q > 20 || q <= self.p {
230            return bad("polynomial orders need p <= 20 and p < q <= 20");
231        }
232        if self.nnmatch == 0 {
233            return bad("nnmatch must be at least 1");
234        }
235        if !(self.level > 0.0 && self.level < 1.0) {
236            return bad("level must be in (0, 1)");
237        }
238        if self.bwcheck == Some(0) {
239            return bad("bwcheck must be a positive integer");
240        }
241        if self.rho.is_some_and(|r| !(r.is_finite() && r > 0.0)) {
242            return bad("rho must be positive");
243        }
244        if !(self.scaleregul.is_finite() && self.scaleregul >= 0.0) || !self.scalepar.is_finite() {
245            return bad("scaleregul and scalepar must be finite (scaleregul >= 0)");
246        }
247        Ok(())
248    }
249}
250
251/// Data of an RD design: outcome `y`, running variable `x` and cutoff, and
252/// optionally the treatment (fuzzy design), covariates and cluster labels.
253#[derive(Debug, Clone, Copy)]
254pub struct RdData<'a> {
255    pub y: ArrayView1<'a, f64>,
256    pub x: ArrayView1<'a, f64>,
257    pub cutoff: f64,
258    pub fuzzy: Option<ArrayView1<'a, f64>>,
259    pub covs: Option<ArrayView2<'a, f64>>,
260    pub cluster: Option<&'a [i64]>,
261}
262
263impl<'a> RdData<'a> {
264    /// A sharp design with cutoff 0.
265    pub fn new(y: ArrayView1<'a, f64>, x: ArrayView1<'a, f64>) -> Self {
266        Self {
267            y,
268            x,
269            cutoff: 0.0,
270            fuzzy: None,
271            covs: None,
272            cluster: None,
273        }
274    }
275
276    pub fn cutoff(mut self, c: f64) -> Self {
277        self.cutoff = c;
278        self
279    }
280
281    /// Fuzzy design with treatment `t`.
282    pub fn fuzzy(mut self, t: ArrayView1<'a, f64>) -> Self {
283        self.fuzzy = Some(t);
284        self
285    }
286
287    /// Covariates for covariate-adjusted estimation (`rdrobust`'s `covs`).
288    pub fn covariates(mut self, z: ArrayView2<'a, f64>) -> Self {
289        self.covs = Some(z);
290        self
291    }
292
293    /// Cluster labels for cluster-robust standard errors.
294    pub fn clusters(mut self, labels: &'a [i64]) -> Self {
295        self.cluster = Some(labels);
296        self
297    }
298}
299
300/// Bandwidths below (`.0`) and above (`.1`) the cutoff.
301#[derive(Debug, Clone, Copy, PartialEq)]
302pub struct RdBandwidths {
303    /// Main bandwidth.
304    pub h: (f64, f64),
305    /// Bias bandwidth.
306    pub b: (f64, f64),
307}
308
309impl RdBandwidths {
310    /// The same `h` and `b` on both sides.
311    pub fn common(h: f64, b: f64) -> Self {
312        Self {
313            h: (h, h),
314            b: (b, b),
315        }
316    }
317}
318
319/// Result of [`rd_estimate`]: rows are the conventional, bias-corrected and
320/// robust (bias-corrected with robust standard error) estimates.
321#[derive(Debug, Clone)]
322pub struct RdEstimate {
323    pub coef: [f64; 3],
324    pub se: [f64; 3],
325    /// Confidence intervals `[lower, upper]` at the options' level.
326    pub ci: [[f64; 2]; 3],
327    /// Main bandwidth `(left, right)`.
328    pub h: (f64, f64),
329    /// Bias bandwidth `(left, right)`.
330    pub b: (f64, f64),
331    /// Observations within the main bandwidth `(left, right)`.
332    pub n_h: (usize, usize),
333    /// Observations on each side of the cutoff.
334    pub n: (usize, usize),
335}
336
337// ---------------------------------------------------------------------------
338// Data preparation
339// ---------------------------------------------------------------------------
340
341/// One side of the cutoff, sorted by the running variable.
342#[derive(Clone)]
343struct Side {
344    x: Vec<f64>,
345    y: Vec<f64>,
346    t: Option<Vec<f64>>,
347    z: Option<Array2<f64>>,
348    cl: Option<Vec<i64>>,
349    dups: Vec<usize>,
350    dupsid: Vec<usize>,
351}
352
353impl Side {
354    fn new(
355        x: Vec<f64>,
356        y: Vec<f64>,
357        t: Option<Vec<f64>>,
358        z: Option<Array2<f64>>,
359        cl: Option<Vec<i64>>,
360    ) -> Self {
361        let (dups, dupsid) = duplicates(&x);
362        Self {
363            x,
364            y,
365            t,
366            z,
367            cl,
368            dups,
369            dupsid,
370        }
371    }
372
373    fn n(&self) -> usize {
374        self.x.len()
375    }
376
377    /// The observations in `keep`, with duplicates counted within them.
378    fn select(&self, keep: &[bool]) -> Side {
379        let rows: Vec<usize> = (0..self.n()).filter(|&i| keep[i]).collect();
380        let pick = |v: &[f64]| -> Vec<f64> { rows.iter().map(|&i| v[i]).collect() };
381        Side::new(
382            pick(&self.x),
383            pick(&self.y),
384            self.t.as_ref().map(|t| pick(t)),
385            self.z.as_ref().map(|z| z.select(Axis(0), &rows)),
386            self.cl
387                .as_ref()
388                .map(|c| rows.iter().map(|&i| c[i]).collect()),
389        )
390    }
391
392    fn d_t(&self) -> usize {
393        usize::from(self.t.is_some())
394    }
395
396    fn d_z(&self) -> usize {
397        self.z.as_ref().map_or(0, |z| z.ncols())
398    }
399
400    /// Outcome, treatment and covariate columns.
401    fn d(&self) -> Array2<f64> {
402        let (dt, dz) = (self.d_t(), self.d_z());
403        let mut d = Array2::zeros((self.n(), 1 + dt + dz));
404        for i in 0..self.n() {
405            d[[i, 0]] = self.y[i];
406            if let Some(t) = &self.t {
407                d[[i, 1]] = t[i];
408            }
409            if let Some(z) = &self.z {
410                for j in 0..dz {
411                    d[[i, 1 + dt + j]] = z[[i, j]];
412                }
413            }
414        }
415        d
416    }
417}
418
419/// Size of each run of equal values and the position within it, as
420/// pandas' `groupby(x).transform("sum" / "cumsum")` on sorted `x`.
421fn duplicates(x: &[f64]) -> (Vec<usize>, Vec<usize>) {
422    let n = x.len();
423    let (mut dups, mut dupsid) = (vec![0; n], vec![0; n]);
424    let mut start = 0;
425    while start < n {
426        let mut end = start + 1;
427        while end < n && x[end] == x[start] {
428            end += 1;
429        }
430        for (k, i) in (start..end).enumerate() {
431            dups[i] = end - start;
432            dupsid[i] = k + 1;
433        }
434        start = end;
435    }
436    (dups, dupsid)
437}
438
439/// `[1, u, u², …, u^p]` by successive multiplication.
440fn vander(u: &[f64], p: usize) -> Array2<f64> {
441    let mut out = Array2::zeros((u.len(), p + 1));
442    for (i, &v) in u.iter().enumerate() {
443        out[[i, 0]] = 1.0;
444        for j in 1..=p {
445            out[[i, j]] = out[[i, j - 1]] * v;
446        }
447    }
448    out
449}
450
451/// NumPy's `x ** k` for a float base and integer exponent.
452fn powk(x: f64, k: usize) -> f64 {
453    match k {
454        0 => 1.0,
455        1 => x,
456        2 => x * x,
457        _ => x.powf(k as f64),
458    }
459}
460
461fn scale_rows(m: &Array2<f64>, w: &[f64]) -> Array2<f64> {
462    let mut out = m.clone();
463    for (mut row, &wi) in out.rows_mut().into_iter().zip(w) {
464        row *= wi;
465    }
466    out
467}
468
469fn crossprod(a: &Array2<f64>, b: &Array2<f64>) -> Array2<f64> {
470    a.t().dot(b)
471}
472
473fn n_unique(x: &[f64]) -> usize {
474    let mut v = x.to_vec();
475    v.sort_by(f64::total_cmp);
476    v.dedup();
477    v.len()
478}
479
480fn factorial(k: usize) -> f64 {
481    (1..=k).product::<usize>() as f64
482}
483
484/// NumPy's `std(ddof=1)`.
485fn std_ddof1(v: &[f64]) -> f64 {
486    let mean = np_sum(v) / v.len() as f64;
487    let sq: Vec<f64> = v.iter().map(|x| (x - mean) * (x - mean)).collect();
488    (np_sum(&sq) / (v.len() - 1) as f64).sqrt()
489}
490
491/// NumPy's `var` (population variance).
492fn var(v: &[f64]) -> f64 {
493    let mean = np_mean(v);
494    let sq: Vec<f64> = v.iter().map(|x| (x - mean) * (x - mean)).collect();
495    np_mean(&sq)
496}
497
498/// R's `quantile(type = 2)` of sorted `x`.
499fn quantile_type2(x: &[f64], prob: f64) -> f64 {
500    let n = x.len();
501    let h = n as f64 * prob;
502    let j = h.floor() as usize;
503    if h == j as f64 {
504        0.5 * (x[j - 1] + x[j])
505    } else {
506        x[j]
507    }
508}
509
510/// Covariate columns to keep (`covs_drop_fun`): all of them if the QR
511/// factorisation has no diagonal entry below `1e-5`, else the columns a
512/// column-pivoted QR finds independent, in pivot order.
513fn independent_columns(z: &Array2<f64>) -> Vec<usize> {
514    const TOL: f64 = 1e-5;
515    let k = z.ncols();
516    let (diag, _) = householder_qr(z, false);
517    if diag.iter().all(|d| d.abs() > TOL) {
518        return (0..k).collect();
519    }
520    let (diag, pivot) = householder_qr(z, true);
521    pivot
522        .into_iter()
523        .zip(diag)
524        .filter(|(_, d)| d.abs() > TOL)
525        .map(|(j, _)| j)
526        .collect()
527}
528
529/// Diagonal of `R` in a Householder QR of `a` (with column pivoting by the
530/// largest remaining norm, as LAPACK's `geqp3`) and the column order.
531fn householder_qr(a: &Array2<f64>, pivoting: bool) -> (Vec<f64>, Vec<usize>) {
532    let (n, k) = a.dim();
533    let mut m = a.clone();
534    let mut order: Vec<usize> = (0..k).collect();
535    let mut diag = Vec::with_capacity(k.min(n));
536    for j in 0..k.min(n) {
537        if pivoting {
538            let norm =
539                |c: usize, m: &Array2<f64>| (j..n).map(|i| m[[i, c]] * m[[i, c]]).sum::<f64>();
540            let best = (j..k)
541                .max_by(|&a, &b| norm(a, &m).total_cmp(&norm(b, &m)))
542                .expect("columns left");
543            if best != j {
544                for i in 0..n {
545                    m.swap([i, j], [i, best]);
546                }
547                order.swap(j, best);
548            }
549        }
550        let alpha = (j..n).map(|i| m[[i, j]] * m[[i, j]]).sum::<f64>().sqrt();
551        let alpha = if m[[j, j]] > 0.0 { -alpha } else { alpha };
552        diag.push(alpha);
553        if alpha == 0.0 {
554            continue;
555        }
556        let mut v: Vec<f64> = (j..n).map(|i| m[[i, j]]).collect();
557        v[0] -= alpha;
558        let vnorm2: f64 = v.iter().map(|x| x * x).sum();
559        if vnorm2 == 0.0 {
560            continue;
561        }
562        for c in j..k {
563            let dot: f64 = (j..n).map(|i| v[i - j] * m[[i, c]]).sum();
564            let f = 2.0 * dot / vnorm2;
565            for i in j..n {
566                m[[i, c]] -= f * v[i - j];
567            }
568        }
569    }
570    (diag, order)
571}
572
573/// Sorted, validated sides of the cutoff, with collinear covariates dropped.
574fn prepare(data: &RdData<'_>, opts: &RdOptions) -> Result<(Side, Side)> {
575    let (y, x, c) = (data.y, data.x, data.cutoff);
576    let n = x.len();
577    let lengths_ok = y.len() == n
578        && data.fuzzy.is_none_or_len(n)
579        && data.covs.map_or(true, |z| z.nrows() == n)
580        && data.cluster.map_or(true, |cl| cl.len() == n);
581    if !lengths_ok {
582        return Err(DmlError::DimensionMismatch(
583            "rdrobust: y, x, fuzzy, covs and cluster need the same number of observations".into(),
584        ));
585    }
586    let finite = x.iter().chain(y.iter()).all(|v| v.is_finite())
587        && data.fuzzy.map_or(true, |t| t.iter().all(|v| v.is_finite()))
588        && data.covs.map_or(true, |z| z.iter().all(|v| v.is_finite()));
589    if !finite {
590        return Err(DmlError::InvalidArgument(
591            "rdrobust: data contain NaN or infinite values".into(),
592        ));
593    }
594    if !c.is_finite() {
595        return Err(DmlError::InvalidArgument(
596            "rdrobust: the cutoff must be finite".into(),
597        ));
598    }
599    let covs = match data.covs {
600        Some(z) if opts.covs_drop => {
601            let z = z.to_owned();
602            let keep = independent_columns(&z);
603            Some(z.select(Axis(1), &keep))
604        }
605        Some(z) => Some(z.to_owned()),
606        None => None,
607    };
608    let mut order: Vec<usize> = (0..n).collect();
609    order.sort_by(|&a, &b| x[a].total_cmp(&x[b]));
610    let (x_min, x_max) = (x[order[0]], x[order[n - 1]]);
611    if c <= x_min || c >= x_max {
612        return Err(DmlError::InvalidArgument(
613            "the cutoff must lie within the range of the running variable".into(),
614        ));
615    }
616    let side = |left: bool| -> Side {
617        let rows: Vec<usize> = order
618            .iter()
619            .copied()
620            .filter(|&i| (x[i] < c) == left)
621            .collect();
622        Side::new(
623            rows.iter().map(|&i| x[i]).collect(),
624            rows.iter().map(|&i| y[i]).collect(),
625            data.fuzzy.map(|t| rows.iter().map(|&i| t[i]).collect()),
626            covs.as_ref().map(|z| z.select(Axis(0), &rows)),
627            data.cluster.map(|cl| rows.iter().map(|&i| cl[i]).collect()),
628        )
629    };
630    Ok((side(true), side(false)))
631}
632
633/// `Option<ArrayView1>` has the expected length (or is absent).
634trait LenCheck {
635    fn is_none_or_len(&self, n: usize) -> bool;
636}
637
638impl LenCheck for Option<ArrayView1<'_, f64>> {
639    fn is_none_or_len(&self, n: usize) -> bool {
640        self.map_or(true, |t| t.len() == n)
641    }
642}
643
644// ---------------------------------------------------------------------------
645// Residuals and variance
646// ---------------------------------------------------------------------------
647
648/// `rdrobust_res`: nearest-neighbour residuals, or plug-in residuals
649/// `w · (D − fitted)` with the HC weight `w` (`d` coefficients in the fit).
650fn residuals(
651    e: &Side,
652    vce: Vce,
653    fitted: Option<&Array2<f64>>,
654    hii: Option<&Array1<f64>>,
655    nnmatch: usize,
656    d: usize,
657) -> Array2<f64> {
658    let dmat = e.d();
659    if vce == Vce::Nn {
660        return nn_residuals(e, &dmat, nnmatch);
661    }
662    let n = e.n();
663    let fitted = fitted.expect("plug-in residuals need fitted values");
664    let mut res = &dmat - fitted;
665    for i in 0..n {
666        // Cluster-robust variances use raw residuals.
667        let w = match vce {
668            Vce::Hc0 | Vce::Cr1 | Vce::Cr2 | Vce::Cr3 => 1.0,
669            Vce::Hc1 => (n as f64 / (n - d) as f64).sqrt(),
670            Vce::Hc2 => (1.0 / (1.0 - hii.expect("leverages")[i]).max(1e-8)).sqrt(),
671            Vce::Hc3 => 1.0 / (1.0 - hii.expect("leverages")[i]).max(1e-8),
672            Vce::Nn => unreachable!(),
673        };
674        res.row_mut(i).mapv_inplace(|r| w * r);
675    }
676    res
677}
678
679/// Nearest-neighbour residuals (`vce = "nn"`): each observation minus the
680/// mean of its (at least) `nnmatch` nearest neighbours, scaled by
681/// `√(J / (J + 1))`. `x` is sorted.
682fn nn_residuals(s: &Side, dmat: &Array2<f64>, nnmatch: usize) -> Array2<f64> {
683    let n = s.n();
684    let mut res = Array2::zeros(dmat.dim());
685    let tol_eps = f64::EPSILON.sqrt();
686    let (x, dups, dupsid) = (&s.x, &s.dups, &s.dupsid);
687    for pos in 0..n {
688        let p = pos as i64;
689        let mut rpos = (dups[pos] - dupsid[pos]) as i64;
690        let mut lpos = dupsid[pos] as i64 - 1;
691        let cap = nnmatch.min(n.saturating_sub(1)) as i64;
692        while lpos + rpos < cap {
693            if p - lpos - 1 < 0 {
694                rpos += dups[(p + rpos + 1) as usize] as i64;
695            } else if p + rpos + 1 >= n as i64 {
696                lpos += dups[(p - lpos - 1) as usize] as i64;
697            } else {
698                let dleft = x[pos] - x[(p - lpos - 1) as usize];
699                let dright = x[(p + rpos + 1) as usize] - x[pos];
700                let nn_tol = dleft.max(dright) * tol_eps;
701                if dleft - dright > nn_tol {
702                    rpos += dups[(p + rpos + 1) as usize] as i64;
703                } else if dright - dleft > nn_tol {
704                    lpos += dups[(p - lpos - 1) as usize] as i64;
705                } else {
706                    rpos += dups[(p + rpos + 1) as usize] as i64;
707                    lpos += dups[(p - lpos - 1) as usize] as i64;
708                }
709            }
710        }
711        let (lo, hi) = ((p - lpos) as usize, (p + rpos + 1) as usize);
712        let ji = (hi - lo - 1) as f64;
713        let sf = (ji / (ji + 1.0)).powf(0.5);
714        for col in 0..dmat.ncols() {
715            let mut sum = 0.0;
716            for j in lo..hi {
717                sum += dmat[[j, col]];
718            }
719            sum -= dmat[[pos, col]];
720            res[[pos, col]] = sf * (dmat[[pos, col]] - sum / ji);
721        }
722    }
723    res
724}
725
726/// Rows of each cluster, in the order of the labels.
727fn cluster_groups(cl: &[i64]) -> Vec<Vec<usize>> {
728    let mut order: Vec<usize> = (0..cl.len()).collect();
729    order.sort_by_key(|&i| cl[i]);
730    let mut groups: Vec<Vec<usize>> = Vec::new();
731    for i in order {
732        match groups.last_mut() {
733            Some(g) if cl[g[0]] == cl[i] => g.push(i),
734            _ => groups.push(vec![i]),
735        }
736    }
737    groups
738}
739
740/// `outer += v vᵀ`.
741fn add_outer(outer: &mut Array2<f64>, v: &Array1<f64>) {
742    let k = v.len();
743    for a in 0..k {
744        for b in 0..k {
745            outer[[a, b]] += v[a] * v[b];
746        }
747    }
748}
749
750/// Inverse of a symmetric matrix: Cholesky, else the pseudo-inverse.
751fn sym_inverse(a: &Array2<f64>) -> Array2<f64> {
752    spd_inverse(a).unwrap_or_else(|_| sym_pinv(a))
753}
754
755/// A cluster's leverage adjustment `u ↦ L_g · A u` of its score.
756type Adjust = Box<dyn Fn(&Array1<f64>) -> Array1<f64>>;
757
758/// A leverage-adjusted cluster-robust variance: `rdrobust`'s CRV2 or CRV3.
759#[derive(Debug, Clone, Copy, PartialEq, Eq)]
760enum Crv {
761    Two,
762    Three,
763}
764
765impl Crv {
766    fn of(vce: Vce) -> Option<Self> {
767        match vce {
768            Vce::Cr2 => Some(Crv::Two),
769            Vce::Cr3 => Some(Crv::Three),
770            _ => None,
771        }
772    }
773}
774
775/// What a leverage-adjusted cluster variance needs of a weighted fit.
776struct CrvParts<'a> {
777    kind: Crv,
778    inv_g: &'a Array2<f64>,
779    /// `R·√w` (its first column is `√w`).
780    sqrt_rx: &'a Array2<f64>,
781    /// The Gram matrix `RᵀWR` (CRV3); `None` inverts `inv_g`, as `rdrobust`
782    /// does where it does not pass it.
783    g: Option<Array2<f64>>,
784}
785
786impl<'a> CrvParts<'a> {
787    fn new(vce: Vce, inv_g: &'a Array2<f64>, sqrt_rx: &'a Array2<f64>, gram: bool) -> Option<Self> {
788        Crv::of(vce).map(|kind| CrvParts {
789            kind,
790            inv_g,
791            sqrt_rx,
792            g: (gram && kind == Crv::Three).then(|| crossprod(sqrt_rx, sqrt_rx)),
793        })
794    }
795}
796
797/// Meat of the sandwich (`rdrobust_vce`): `Σᵢ (sᵀrᵢ)² RXᵢ RXᵢᵀ`; with
798/// clusters the CR1 sum over clusters of `(Σᵢ∈g RXᵢ sᵀrᵢ)(…)ᵀ` times
799/// `(n − 1)/(n − k) · G/(G − 1)` (`k_df` coefficients), or with `crv` the
800/// leverage-adjusted CRV2 / CRV3 sums.
801fn vce_meat(
802    s: &[f64],
803    rx: &Array2<f64>,
804    res: &Array2<f64>,
805    cl: Option<&[i64]>,
806    k_df: usize,
807    crv: Option<&CrvParts<'_>>,
808) -> Array2<f64> {
809    if let (Some(cl), Some(crv)) = (cl, crv) {
810        return crv_meat(s, res, cl, crv);
811    }
812    let k = rx.ncols();
813    let combined: Vec<f64> = res
814        .rows()
815        .into_iter()
816        .map(|row| row.iter().zip(s).map(|(r, si)| r * si).sum())
817        .collect();
818    match cl {
819        None => {
820            let scaled = scale_rows(rx, &combined);
821            crossprod(&scaled, &scaled)
822        }
823        Some(cl) => {
824            let n = cl.len();
825            let groups = cluster_groups(cl);
826            let mut meat = Array2::<f64>::zeros((k, k));
827            for rows in &groups {
828                let mut sv = Array1::<f64>::zeros(k);
829                for &i in rows {
830                    sv.scaled_add(combined[i], &rx.row(i));
831                }
832                add_outer(&mut meat, &sv);
833            }
834            let g = groups.len();
835            let w = if g > 1 {
836                ((n - 1) as f64 / (n - k_df) as f64) * (g as f64 / (g - 1) as f64)
837            } else {
838                f64::NAN
839            };
840            meat * w
841        }
842    }
843}
844
845/// CRV2 (Bell and McCaffrey, 2002) and CRV3 (Pustejovsky and Tipton, 2018)
846/// sums of `rdrobust_vce`: each cluster's score `u_g = (R√w)_gᵀ (√w_g ∘ r_g)`
847/// is adjusted by its leverage block `L_g = (R√w)_gᵀ(R√w)_g`, with
848/// `u_g + L_g (G − L_g)⁻¹ u_g` (CRV3) or the symmetric square-root
849/// correction of CRV2.
850fn crv_meat(s: &[f64], res: &Array2<f64>, cl: &[i64], crv: &CrvParts<'_>) -> Array2<f64> {
851    let k = crv.sqrt_rx.ncols();
852    let mut meat = Array2::<f64>::zeros((k, k));
853    let g_full = match crv.kind {
854        Crv::Three => Some(crv.g.clone().unwrap_or_else(|| sym_inverse(crv.inv_g))),
855        Crv::Two => None,
856    };
857    // CRV2: the upper Cholesky factor `C` of `G⁻¹ = CᵀC`.
858    let c_half = match crv.kind {
859        Crv::Two => cholesky_factor(crv.inv_g).ok().map(|l| l.t().to_owned()),
860        Crv::Three => None,
861    };
862    for rows in cluster_groups(cl) {
863        let srx = crv.sqrt_rx.select(Axis(0), &rows);
864        let sw = srx.column(0).to_owned();
865        let l_g = crossprod(&srx, &srx);
866        let mut sv = Array1::<f64>::zeros(k);
867        // The adjustment `u ↦ L_g · A u` for this cluster.
868        let adjust: Adjust = match crv.kind {
869            Crv::Three => {
870                let m_g = sym_inverse(&(g_full.as_ref().expect("crv3") - &l_g));
871                let lm = l_g.dot(&m_g);
872                Box::new(move |u: &Array1<f64>| lm.dot(u))
873            }
874            Crv::Two => match &c_half {
875                Some(c) => {
876                    let (sigma2, v) = sym_eigen(&c.dot(&l_g).dot(&c.t()));
877                    let coef = sigma2.mapv(|s2| {
878                        let s2 = s2.max(0.0);
879                        if s2 < 1e-14 {
880                            0.0
881                        } else {
882                            (1.0 / (1.0 - s2).max(1e-8).sqrt() - 1.0) / s2
883                        }
884                    });
885                    let ltc = l_g.dot(&c.t());
886                    let c = c.clone();
887                    Box::new(move |u: &Array1<f64>| {
888                        let vt_ru = v.t().dot(&c.dot(u));
889                        ltc.dot(&v.dot(&(&coef * &vt_ru)))
890                    })
891                }
892                None => Box::new(move |u: &Array1<f64>| Array1::zeros(u.len())),
893            },
894        };
895        for (l, &sl) in s.iter().enumerate() {
896            let r: Array1<f64> = rows.iter().map(|&i| res[[i, l]]).collect();
897            let u = srx.t().dot(&(&sw * &r));
898            let score = &u + &adjust(&u);
899            sv.scaled_add(sl, &score);
900        }
901        add_outer(&mut meat, &sv);
902    }
903    meat
904}
905
906/// `rdrobust_vce_qq_cluster`: the CRV2 / CRV3 meat of the bias-corrected
907/// estimator when `h ≠ b`. The sandwich uses `Q_q` and the leverage comes
908/// from the order-`q` regression at bandwidth `b`.
909#[allow(clippy::too_many_arguments)]
910fn crv_meat_qq(
911    q_q: &Array2<f64>,
912    r_q: &Array2<f64>,
913    w_b: &[f64],
914    inv_g_q: &Array2<f64>,
915    g_q: &Array2<f64>,
916    res: &Array2<f64>,
917    cl: &[i64],
918    kind: Crv,
919    s: &[f64],
920) -> Array2<f64> {
921    let k = q_q.ncols();
922    let mut meat = Array2::<f64>::zeros((k, k));
923    for rows in cluster_groups(cl) {
924        let q_g = q_q.select(Axis(0), &rows);
925        let r_g = r_q.select(Axis(0), &rows);
926        let wb: Array1<f64> = rows.iter().map(|&i| w_b[i]).collect();
927        let sr = scale_rows(&r_g, &wb.iter().map(|w| w.sqrt()).collect::<Vec<_>>());
928        let l_g = crossprod(&sr, &sr);
929        let p_g = q_g.t().dot(&r_g);
930        // `adj(u_q)`: Λ u_q (CRV2) or (G_q − L_g)⁻¹ u_q (CRV3); None = no
931        // adjustment.
932        let lambda: Option<Array2<f64>> = match kind {
933            Crv::Two => {
934                let (sigma2, v) = sym_eigen(&l_g);
935                let sigma = sigma2.mapv(|v| v.max(0.0).sqrt());
936                let tol = sigma.iter().fold(0.0_f64, |m, &x| m.max(x)) * 1e-10;
937                let keep: Vec<usize> = (0..sigma.len()).filter(|&j| sigma[j] > tol).collect();
938                if keep.is_empty() {
939                    None
940                } else {
941                    let v_r = v.select(Axis(1), &keep);
942                    let sig: Array1<f64> = keep.iter().map(|&j| sigma[j]).collect();
943                    let d = Array2::from_diag(&sig);
944                    let t = d.dot(&v_r.t().dot(&inv_g_q.dot(&v_r))).dot(&d);
945                    let t = (&t + &t.t()) / 2.0;
946                    let (tau, w_g) = sym_eigen(&t);
947                    let gamma = tau.mapv(|t| 1.0 / (1.0 - t.clamp(0.0, 1.0 - 1e-10)).sqrt() - 1.0);
948                    let a = v_r
949                        .dot(&Array2::from_diag(&sig.mapv(|x| 1.0 / x)))
950                        .dot(&w_g);
951                    Some(a.dot(&Array2::from_diag(&gamma)).dot(&a.t()))
952                }
953            }
954            Crv::Three => {
955                Some(spd_inverse(&(g_q - &l_g)).unwrap_or_else(|_| Array2::zeros(l_g.dim())))
956            }
957        };
958        let mut sv = Array1::<f64>::zeros(k);
959        for (l, &sl) in s.iter().enumerate() {
960            let e: Array1<f64> = rows.iter().map(|&i| res[[i, l]]).collect();
961            let mut score = q_g.t().dot(&e);
962            if let Some(lam) = &lambda {
963                let u_q = r_g.t().dot(&(&wb * &e));
964                score = &score + &p_g.dot(&lam.dot(&u_q));
965            }
966            sv.scaled_add(sl, &score);
967        }
968        add_outer(&mut meat, &sv);
969    }
970    meat
971}
972
973/// Leverages `hᵢᵢ = Σⱼ (R G⁻¹)ᵢⱼ (RW)ᵢⱼ`.
974fn leverages(r: &Array2<f64>, inv_g: &Array2<f64>, rw: &Array2<f64>) -> Array1<f64> {
975    (r.dot(inv_g) * rw).sum_axis(Axis(1))
976}
977
978/// Partialling the covariates out of a weighted local fit: `γ` (rows:
979/// covariates, columns: outcome and treatment).
980fn covariate_gamma(rw: &Array2<f64>, inv_g: &Array2<f64>, e: &Side, w: &[f64]) -> Array2<f64> {
981    let (dt, dz) = (e.d_t(), e.d_z());
982    let d = e.d();
983    let u = crossprod(rw, &d);
984    let zw = scale_rows(e.z.as_ref().expect("covariates"), w);
985    let zwd = crossprod(&zw, &d);
986    let cols_z = s![.., 1 + dt..1 + dt + dz];
987    let uigu = u.slice(cols_z).t().dot(&inv_g.dot(&u));
988    let zwz = &zwd.slice(cols_z) - &uigu.slice(cols_z);
989    let zwy = &zwd.slice(s![.., ..1 + dt]) - &uigu.slice(s![.., ..1 + dt]);
990    sym_pinv(&zwz).dot(&zwy)
991}
992
993// ---------------------------------------------------------------------------
994// Bandwidth selection (rdbwselect)
995// ---------------------------------------------------------------------------
996
997/// `(V, B, R, rate)` of one bandwidth step on one side, NaN when a pilot
998/// fit is not identified.
999type BwParts = (f64, f64, f64, f64);
1000
1001/// The (standardised) data and settings of a bandwidth computation.
1002struct BwCtx<'o> {
1003    opts: &'o RdOptions,
1004    vce: Vce,
1005    c: f64,
1006}
1007
1008impl BwCtx<'_> {
1009    /// Plug-in or NN residuals of a pilot fit.
1010    fn pilot_residuals(
1011        &self,
1012        e: &Side,
1013        r: &Array2<f64>,
1014        inv_g: &Array2<f64>,
1015        rw: &Array2<f64>,
1016        beta: &Array2<f64>,
1017        d: usize,
1018    ) -> Array2<f64> {
1019        match self.vce {
1020            Vce::Nn => residuals(e, Vce::Nn, None, None, self.opts.nnmatch, d),
1021            v => {
1022                let fitted = r.dot(beta);
1023                let hii = matches!(v, Vce::Hc2 | Vce::Hc3).then(|| leverages(r, inv_g, rw));
1024                residuals(e, v, Some(&fitted), hii.as_ref(), self.opts.nnmatch, d)
1025            }
1026        }
1027    }
1028
1029    /// Pilot fit of `rdrobust_bw` (`_rdrobust_bw_Vfit`): `(V_V, BConst, s)`.
1030    fn vfit(&self, side: &Side, o: usize, nu: usize, h_v: f64) -> Option<(f64, f64, Vec<f64>)> {
1031        let (c, kernel) = (self.c, self.opts.kernel);
1032        let keep: Vec<bool> = side
1033            .x
1034            .iter()
1035            .map(|&x| kernel.weight(x, c, h_v) > 0.0)
1036            .collect();
1037        let e = side.select(&keep);
1038        if n_unique(&e.x) < o + 1 {
1039            return None;
1040        }
1041        let ew: Vec<f64> = e.x.iter().map(|&x| kernel.weight(x, c, h_v)).collect();
1042        let r = vander(&e.x.iter().map(|x| x - c).collect::<Vec<_>>(), o);
1043        let rws = scale_rows(&r, &ew.iter().map(|w| w.sqrt()).collect::<Vec<_>>());
1044        let inv_g = spd_inverse(&crossprod(&rws, &rws)).ok()?;
1045        let rw = scale_rows(&r, &ew);
1046        let beta = inv_g.dot(&crossprod(&rw, &e.d()));
1047        let fact = factorial(nu);
1048        let dt = e.d_t();
1049        let s = match (e.z.is_some(), e.t.is_some()) {
1050            (false, false) => vec![1.0],
1051            (false, true) => {
1052                let (tau_y, tau_t) = (fact * beta[[nu, 0]], fact * beta[[nu, 1]]);
1053                vec![1.0 / tau_t, -(tau_y / (tau_t * tau_t))]
1054            }
1055            (true, has_t) => {
1056                let gamma = covariate_gamma(&rw, &inv_g, &e, &ew);
1057                let dz = e.d_z();
1058                let s_y: Vec<f64> = std::iter::once(1.0)
1059                    .chain(gamma.column(0).iter().map(|g| -g))
1060                    .collect();
1061                if has_t {
1062                    let s_t: Vec<f64> = std::iter::once(1.0)
1063                        .chain(gamma.column(1).iter().map(|g| -g))
1064                        .collect();
1065                    let pick = |col: usize| -> Vec<f64> {
1066                        std::iter::once(beta[[nu, col]])
1067                            .chain((0..dz).map(|j| beta[[nu, 1 + dt + j]]))
1068                            .collect()
1069                    };
1070                    let dot =
1071                        |a: &[f64], b: &[f64]| a.iter().zip(b).map(|(x, y)| x * y).sum::<f64>();
1072                    let tau_y = fact * dot(&s_y, &pick(0));
1073                    let tau_t = fact * dot(&s_t, &pick(1));
1074                    let mut s = vec![1.0 / tau_t, -(tau_y / (tau_t * tau_t))];
1075                    s.extend((0..dz).map(|j| {
1076                        -(1.0 / tau_t) * gamma[[j, 0]] + (tau_y / (tau_t * tau_t)) * gamma[[j, 1]]
1077                    }));
1078                    s
1079                } else {
1080                    s_y
1081                }
1082            }
1083        };
1084        let res = self.pilot_residuals(&e, &r, &inv_g, &rw, &beta, o + 1);
1085        let crv = CrvParts::new(self.vce, &inv_g, &rws, true);
1086        let meat = vce_meat(&s, &rw, &res, e.cl.as_deref(), rw.ncols(), crv.as_ref());
1087        let v_v = inv_g.dot(&meat).dot(&inv_g)[[nu, nu]];
1088        let pw: Array1<f64> = e.x.iter().map(|x| powk((x - c) / h_v, o + 1)).collect();
1089        let ig_v = inv_g.dot(&rw.t().dot(&pw));
1090        // BConst = (h^j · (G⁻¹v)_j)[nu]
1091        let b_const = powk(h_v, nu) * ig_v[nu];
1092        Some((v_v, b_const, s))
1093    }
1094
1095    /// `rdrobust_bw`.
1096    #[allow(clippy::too_many_arguments)]
1097    fn bw(
1098        &self,
1099        side: &Side,
1100        o: usize,
1101        nu: usize,
1102        o_b: usize,
1103        h_v: f64,
1104        h_b: f64,
1105        scale: f64,
1106    ) -> BwParts {
1107        let (c, kernel) = (self.c, self.opts.kernel);
1108        let nan = (f64::NAN, f64::NAN, f64::NAN, 1.0 / (2 * o + 3) as f64);
1109        let Some((v_v, b_const, s)) = self.vfit(side, o, nu, h_v) else {
1110            return nan;
1111        };
1112        if !v_v.is_finite() || !(h_b.is_finite() && h_b > 0.0) {
1113            return nan;
1114        }
1115        let keep: Vec<bool> = side
1116            .x
1117            .iter()
1118            .map(|&x| kernel.weight(x, c, h_b) > 0.0)
1119            .collect();
1120        let e = side.select(&keep);
1121        if n_unique(&e.x) < o_b + 1 {
1122            return nan;
1123        }
1124        let ew: Vec<f64> = e.x.iter().map(|&x| kernel.weight(x, c, h_b)).collect();
1125        let r = vander(&e.x.iter().map(|x| x - c).collect::<Vec<_>>(), o_b);
1126        let rws = scale_rows(&r, &ew.iter().map(|w| w.sqrt()).collect::<Vec<_>>());
1127        let Ok(inv_g) = spd_inverse(&crossprod(&rws, &rws)) else {
1128            return nan;
1129        };
1130        let rw = scale_rows(&r, &ew);
1131        let beta = inv_g.dot(&crossprod(&rw, &e.d()));
1132        let mut bw_reg = 0.0;
1133        if scale > 0.0 {
1134            let res = self.pilot_residuals(&e, &r, &inv_g, &rw, &beta, o_b + 1);
1135            let crv = CrvParts::new(self.vce, &inv_g, &rws, true);
1136            let meat = vce_meat(&s, &rw, &res, e.cl.as_deref(), rw.ncols(), crv.as_ref());
1137            let v_b = inv_g.dot(&meat).dot(&inv_g)[[o_b, o_b]];
1138            bw_reg = 3.0 * b_const * b_const * v_b;
1139        }
1140        let last = beta.row(o_b);
1141        let s_beta: f64 = s.iter().zip(last.iter()).map(|(a, b)| a * b).sum();
1142        let b = (2.0 * (o + 1 - nu) as f64).sqrt() * b_const * s_beta;
1143        let v = (2 * nu + 1) as f64 * h_v.powf((2 * nu + 1) as f64) * v_v;
1144        let r = scale * (2 * (o + 1 - nu)) as f64 * bw_reg;
1145        (v, b, r, 1.0 / (2 * o + 3) as f64)
1146    }
1147}
1148
1149/// The inline bandwidth selection of `rdrobust` (`_rdbwselect_compute`).
1150fn select_bandwidths(left: &Side, right: &Side, c: f64, opts: &RdOptions) -> Result<RdBandwidths> {
1151    let (p, q) = (opts.p, opts.q());
1152    let x_all: Vec<f64> = left.x.iter().chain(&right.x).copied().collect();
1153    let y_all: Vec<f64> = left.y.iter().chain(&right.y).copied().collect();
1154    let n = x_all.len();
1155    let x_iq = quantile_type2(&x_all, 0.75) - quantile_type2(&x_all, 0.25);
1156    let (x_sd, y_sd, bwp) = if opts.stdvars {
1157        let x_sd = std_ddof1(&x_all);
1158        (x_sd, std_ddof1(&y_all), 1.0_f64.min((x_iq / x_sd) / 1.349))
1159    } else {
1160        (1.0, 1.0, std_ddof1(&x_all).min(x_iq / 1.349))
1161    };
1162    // A first stage without variation on a side falls back to a sharp bandwidth.
1163    let no_variation = |t: &Option<Vec<f64>>| t.as_ref().is_some_and(|t| var(t) == 0.0);
1164    let sharp_bw = opts.sharpbw || no_variation(&left.t) || no_variation(&right.t);
1165    let standardise = |s: &Side| -> Side {
1166        Side::new(
1167            s.x.iter().map(|x| x / x_sd).collect(),
1168            s.y.iter().map(|y| y / y_sd).collect(),
1169            if sharp_bw { None } else { s.t.clone() },
1170            s.z.clone(),
1171            s.cl.clone(),
1172        )
1173    };
1174    let (l, r) = (standardise(left), standardise(right));
1175    let c = c / x_sd;
1176    let (x_min, x_max) = (l.x[0], r.x[r.n() - 1]);
1177    let range_l = (c - l.x[0]).abs();
1178    let range_r = (c - r.x[r.n() - 1]).abs();
1179
1180    let mut uniq_l = l.x.clone();
1181    uniq_l.dedup();
1182    uniq_l.reverse();
1183    let mut uniq_r = r.x.clone();
1184    uniq_r.dedup();
1185    let inspect = opts.masspoints != MassPoints::Off;
1186    let (m_l, m_r) = if inspect || opts.bwcheck.is_some() {
1187        (uniq_l.len(), uniq_r.len())
1188    } else {
1189        (l.n(), r.n())
1190    };
1191    let mut bwcheck = opts.bwcheck;
1192    if inspect {
1193        let mass = |m: usize, n: usize| 1.0 - m as f64 / n as f64;
1194        let detected = mass(m_l, l.n()) >= 0.2 || mass(m_r, r.n()) >= 0.2;
1195        if detected && bwcheck.is_none() && opts.masspoints == MassPoints::Adjust {
1196            bwcheck = Some(10);
1197        }
1198    }
1199    let m_total = if inspect { m_l + m_r } else { n };
1200
1201    let c_c = opts.kernel.c_c();
1202    let mut c_bw = c_c * bwp * (n as f64).powf(-1.0 / 5.0);
1203    if opts.masspoints == MassPoints::Adjust {
1204        c_bw = c_c * bwp * (m_total as f64).powf(-1.0 / 5.0);
1205    }
1206    let (bw_max_l, bw_max_r) = ((c - x_min).abs(), (c - x_max).abs());
1207    let bw_max = bw_max_l.max(bw_max_r);
1208    if opts.bwrestrict {
1209        c_bw = c_bw.min(bw_max);
1210    }
1211    let pad = 1.0 + f64::EPSILON.sqrt();
1212    let bw_min = bwcheck.map(|k| {
1213        (
1214            (uniq_l[k.min(m_l) - 1] - c).abs() * pad,
1215            (uniq_r[k.min(m_r) - 1] - c).abs() * pad,
1216        )
1217    });
1218    if let Some((lo_l, lo_r)) = bw_min {
1219        c_bw = c_bw.max(lo_l).max(lo_r);
1220    }
1221    let restrict = |v: f64, max: f64| if opts.bwrestrict { v.min(max) } else { v };
1222    let floor = |v: f64| match bw_min {
1223        Some((lo_l, lo_r)) => v.max(lo_l).max(lo_r),
1224        None => v,
1225    };
1226
1227    let ctx = BwCtx {
1228        opts,
1229        vce: effective_vce(opts.vce, left.cl.is_some()),
1230        c,
1231    };
1232    let reg = opts.scaleregul;
1233    let c_d_l = ctx.bw(&l, q + 1, q + 1, q + 2, c_bw, range_l * pad, 0.0);
1234    let c_d_r = ctx.bw(&r, q + 1, q + 1, q + 2, c_bw, range_r * pad, 0.0);
1235
1236    let (mut two, mut sum, mut rd) = (None, None, None);
1237    let sel = opts.bwselect;
1238    if sel.needs_two() {
1239        let side = |s: &Side, cd: BwParts, max: f64, lo: Option<f64>| -> (f64, f64) {
1240            let mut d_bw = restrict((cd.0 / (cd.1 * cd.1)).powf(cd.3), max);
1241            if let Some(lo) = lo {
1242                d_bw = d_bw.max(lo);
1243            }
1244            let cb = ctx.bw(s, q, p + 1, q + 1, c_bw, d_bw, reg);
1245            let b_bw = restrict((cb.0 / (cb.1 * cb.1 + reg * cb.2)).powf(cb.3), max);
1246            let ch = ctx.bw(s, p, 0, q, c_bw, b_bw, reg);
1247            let h_bw = restrict((ch.0 / (ch.1 * ch.1 + reg * ch.2)).powf(ch.3), max);
1248            (x_sd * h_bw, x_sd * b_bw)
1249        };
1250        let (h_l, b_l) = side(&l, c_d_l, bw_max_l, bw_min.map(|m| m.0));
1251        let (h_r, b_r) = side(&r, c_d_r, bw_max_r, bw_min.map(|m| m.1));
1252        two = Some(RdBandwidths {
1253            h: (h_l, h_r),
1254            b: (b_l, b_r),
1255        });
1256    }
1257    // Common bandwidths for the difference (`sign = −1`) or the sum (`+1`)
1258    // of the two sides' estimates.
1259    let common = |sign: f64| -> (f64, f64) {
1260        let d_bw = floor(restrict(
1261            ((c_d_l.0 + c_d_r.0) / (c_d_r.1 + sign * c_d_l.1).powi(2)).powf(c_d_l.3),
1262            bw_max,
1263        ));
1264        let cb_l = ctx.bw(&l, q, p + 1, q + 1, c_bw, d_bw, reg);
1265        let cb_r = ctx.bw(&r, q, p + 1, q + 1, c_bw, d_bw, reg);
1266        let b_bw = restrict(
1267            ((cb_l.0 + cb_r.0) / ((cb_r.1 + sign * cb_l.1).powi(2) + reg * (cb_r.2 + cb_l.2)))
1268                .powf(cb_l.3),
1269            bw_max,
1270        );
1271        let ch_l = ctx.bw(&l, p, 0, q, c_bw, b_bw, reg);
1272        let ch_r = ctx.bw(&r, p, 0, q, c_bw, b_bw, reg);
1273        let h_bw = restrict(
1274            ((ch_l.0 + ch_r.0) / ((ch_r.1 + sign * ch_l.1).powi(2) + reg * (ch_r.2 + ch_l.2)))
1275                .powf(ch_l.3),
1276            bw_max,
1277        );
1278        (x_sd * h_bw, x_sd * b_bw)
1279    };
1280    if sel.needs_sum() {
1281        sum = Some(common(1.0));
1282    }
1283    if sel.needs_rd() {
1284        rd = Some(common(-1.0));
1285    }
1286    // np.min / np.median propagate NaN.
1287    let min_nan = |a: f64, b: f64| {
1288        if a.is_nan() || b.is_nan() {
1289            f64::NAN
1290        } else {
1291            a.min(b)
1292        }
1293    };
1294    let median3 = |a: f64, b: f64, c: f64| {
1295        if a.is_nan() || b.is_nan() || c.is_nan() {
1296            return f64::NAN;
1297        }
1298        let mut v = [a, b, c];
1299        v.sort_by(f64::total_cmp);
1300        v[1]
1301    };
1302    let mse = match sel {
1303        BwSelect::Mserd | BwSelect::Cerrd => {
1304            let (h, b) = rd.expect("computed");
1305            RdBandwidths::common(h, b)
1306        }
1307        BwSelect::Msetwo | BwSelect::Certwo => two.expect("computed"),
1308        BwSelect::Msesum | BwSelect::Cersum => {
1309            let (h, b) = sum.expect("computed");
1310            RdBandwidths::common(h, b)
1311        }
1312        BwSelect::Msecomb1 | BwSelect::Cercomb1 => {
1313            let ((h_d, b_d), (h_s, b_s)) = (rd.expect("computed"), sum.expect("computed"));
1314            RdBandwidths::common(min_nan(h_d, h_s), min_nan(b_d, b_s))
1315        }
1316        BwSelect::Msecomb2 | BwSelect::Cercomb2 => {
1317            let ((h_d, b_d), (h_s, b_s)) = (rd.expect("computed"), sum.expect("computed"));
1318            let t = two.expect("computed");
1319            RdBandwidths {
1320                h: (median3(h_d, h_s, t.h.0), median3(h_d, h_s, t.h.1)),
1321                b: (median3(b_d, b_s, t.b.0), median3(b_d, b_s, t.b.1)),
1322            }
1323        }
1324    };
1325    let bws = if sel.is_cer() {
1326        let base = match (left.cl.as_ref(), right.cl.as_ref()) {
1327            (Some(a), Some(b)) => (n_unique_i64(a) + n_unique_i64(b)) as f64,
1328            _ => n as f64,
1329        };
1330        let pf = p as f64;
1331        let cer_h = base.powf(-(pf / ((3.0 + pf) * (3.0 + 2.0 * pf))));
1332        RdBandwidths {
1333            h: (mse.h.0 * cer_h, mse.h.1 * cer_h),
1334            b: mse.b,
1335        }
1336    } else {
1337        mse
1338    };
1339    let all = [bws.h.0, bws.h.1, bws.b.0, bws.b.1];
1340    if !all.iter().all(|v| v.is_finite() && *v > 0.0) {
1341        return Err(DmlError::InvalidArgument(
1342            "not enough variability in the running variable to compute the bandwidth".into(),
1343        ));
1344    }
1345    Ok(match opts.rho {
1346        Some(rho) => RdBandwidths {
1347            h: bws.h,
1348            b: (bws.h.0 / rho, bws.h.1 / rho),
1349        },
1350        None => bws,
1351    })
1352}
1353
1354fn n_unique_i64(v: &[i64]) -> usize {
1355    let mut v = v.to_vec();
1356    v.sort_unstable();
1357    v.dedup();
1358    v.len()
1359}
1360
1361/// The variance estimator actually used, with `rdrobust`'s switching between
1362/// the cluster-robust estimators and the others (see [`Vce`]).
1363fn effective_vce(vce: Vce, clustered: bool) -> Vce {
1364    match (vce, clustered) {
1365        (Vce::Nn | Vce::Hc0 | Vce::Hc1 | Vce::Cr1, true) => Vce::Cr1,
1366        (Vce::Hc2 | Vce::Cr2, true) => Vce::Cr2,
1367        (Vce::Hc3 | Vce::Cr3, true) => Vce::Cr3,
1368        (Vce::Cr1, false) => Vce::Hc1,
1369        (Vce::Cr2, false) => Vce::Hc2,
1370        (Vce::Cr3, false) => Vce::Hc3,
1371        (v, false) => v,
1372    }
1373}
1374
1375fn check_support(left: &Side, right: &Side, order: usize) -> Result<()> {
1376    if n_unique(&left.x).min(n_unique(&right.x)) < order + 1 {
1377        return Err(DmlError::InvalidArgument(format!(
1378            "not enough distinct running-variable values on each side of the cutoff for a polynomial of order {order}"
1379        )));
1380    }
1381    Ok(())
1382}
1383
1384/// Bandwidths of `rdrobust`'s `rdbwselect` for the RD effect at the cutoff,
1385/// with the selector and options in `opts`.
1386pub fn rd_bandwidths(data: &RdData<'_>, opts: &RdOptions) -> Result<RdBandwidths> {
1387    opts.validate()?;
1388    let (left, right) = prepare(data, opts)?;
1389    check_support(&left, &right, opts.p)?;
1390    select_bandwidths(&left, &right, data.cutoff, opts)
1391}
1392
1393/// The MSE-optimal common bandwidths `(h, b)` of `rdrobust`'s `rdbwselect`
1394/// (default options) for the RD effect at cutoff `c`; pass the treatment as
1395/// `fuzzy` for a fuzzy design.
1396pub fn rdbwselect<'a>(
1397    y: ArrayView1<'a, f64>,
1398    x: ArrayView1<'a, f64>,
1399    fuzzy: Option<ArrayView1<'a, f64>>,
1400    c: f64,
1401) -> Result<(f64, f64)> {
1402    let mut data = RdData::new(y, x).cutoff(c);
1403    data.fuzzy = fuzzy;
1404    let bws = rd_bandwidths(&data, &RdOptions::default())?;
1405    Ok((bws.h.0, bws.b.0))
1406}
1407
1408// ---------------------------------------------------------------------------
1409// Estimation (rdrobust)
1410// ---------------------------------------------------------------------------
1411
1412/// One side's local polynomial fits at bandwidths `h` (order `p`) and `b`
1413/// (order `q`), and the residuals for the variance.
1414struct SideFit {
1415    beta_p: Array2<f64>,
1416    beta_bc: Array2<f64>,
1417    inv_g_p: Array2<f64>,
1418    inv_g_q: Array2<f64>,
1419    rw_p: Array2<f64>,
1420    rw_q_h: Array2<f64>,
1421    q_q: Array2<f64>,
1422    res_h: Array2<f64>,
1423    res_b: Array2<f64>,
1424    /// `Σ ZW D` and `Σ U` terms for covariate adjustment.
1425    zwd: Option<Array2<f64>>,
1426    u_p: Option<Array2<f64>>,
1427    cl: Option<Vec<i64>>,
1428    n_h: usize,
1429    /// For the leverage-adjusted cluster variances: `R_p√W_h`, `R_q√W_h`,
1430    /// `R_q`, the bias weights `W_b` and `G_q = R_qᵀW_bR_q`.
1431    sqrt_rx_p: Array2<f64>,
1432    sqrt_rx_q_h: Array2<f64>,
1433    r_q: Array2<f64>,
1434    w_b: Vec<f64>,
1435    g_q: Array2<f64>,
1436}
1437
1438fn fit_side(side: &Side, c: f64, h: f64, b: f64, opts: &RdOptions, vce: Vce) -> Result<SideFit> {
1439    let (p, q, kernel) = (opts.p, opts.q(), opts.kernel);
1440    let w_h: Vec<f64> = side.x.iter().map(|&x| kernel.weight(x, c, h)).collect();
1441    let w_b: Vec<f64> = side.x.iter().map(|&x| kernel.weight(x, c, b)).collect();
1442    let in_h: Vec<bool> = w_h.iter().map(|&w| w > 0.0).collect();
1443    let in_b: Vec<bool> = w_b.iter().map(|&w| w > 0.0).collect();
1444    let n_h = in_h.iter().filter(|&&k| k).count();
1445    let uniq_in = |keep: &[bool]| {
1446        n_unique(
1447            &side
1448                .x
1449                .iter()
1450                .zip(keep)
1451                .filter(|(_, &k)| k)
1452                .map(|(x, _)| *x)
1453                .collect::<Vec<_>>(),
1454        )
1455    };
1456    if uniq_in(&in_h) < p + 1 || uniq_in(&in_b) < q + 1 {
1457        return Err(DmlError::InvalidArgument(
1458            "not enough distinct running-variable values with positive weight on each side of the cutoff"
1459                .into(),
1460        ));
1461    }
1462    let keep = if h > b { &in_h } else { &in_b };
1463    let pick = |v: &[f64]| -> Vec<f64> {
1464        v.iter()
1465            .zip(keep.iter())
1466            .filter(|(_, &k)| k)
1467            .map(|(a, _)| *a)
1468            .collect()
1469    };
1470    let (ww_h, ww_b) = (pick(&w_h), pick(&w_b));
1471    // Nearest neighbours are counted within the estimation window.
1472    let e = side.select(keep);
1473    let u: Vec<f64> = e.x.iter().map(|x| (x - c) / h).collect();
1474    let r_q = vander(&e.x.iter().map(|x| x - c).collect::<Vec<_>>(), q);
1475    let r_p = r_q.slice(s![.., ..=p]).to_owned();
1476    let rw_p = scale_rows(&r_p, &ww_h);
1477    let rw_q_b = scale_rows(&r_q, &ww_b);
1478    let l: Array1<f64> = rw_p
1479        .t()
1480        .dot(&Array1::from_iter(u.iter().map(|&v| powk(v, p + 1))));
1481    let sq = |w: &[f64]| w.iter().map(|v| v.sqrt()).collect::<Vec<_>>();
1482    let g_q = {
1483        let m = scale_rows(&r_q, &sq(&ww_b));
1484        crossprod(&m, &m)
1485    };
1486    let inv_g_q = spd_inverse(&g_q)?;
1487    let inv_g_p = {
1488        let m = scale_rows(&r_p, &sq(&ww_h));
1489        spd_inverse(&crossprod(&m, &m))?
1490    };
1491    let m: Vec<f64> = r_q
1492        .dot(&inv_g_q.row(p + 1))
1493        .iter()
1494        .zip(&ww_b)
1495        .map(|(a, w)| a * w)
1496        .collect();
1497    let hp = powk(h, p + 1);
1498    let mut q_q = rw_p.clone();
1499    for i in 0..q_q.nrows() {
1500        for j in 0..q_q.ncols() {
1501            q_q[[i, j]] -= hp * m[i] * l[j];
1502        }
1503    }
1504    let d = e.d();
1505    let beta_p = inv_g_p.dot(&crossprod(&rw_p, &d));
1506    let beta_q = inv_g_q.dot(&crossprod(&rw_q_b, &d));
1507    let beta_bc = inv_g_p.dot(&crossprod(&q_q, &d));
1508    let (res_h, res_b) = if vce == Vce::Nn {
1509        let res = residuals(&e, Vce::Nn, None, None, opts.nnmatch, p + 1);
1510        (res.clone(), res)
1511    } else {
1512        let (fit_p, fit_q) = (r_p.dot(&beta_p), r_q.dot(&beta_q));
1513        let (hii_p, hii_q) = if matches!(vce, Vce::Hc2 | Vce::Hc3) {
1514            (
1515                Some(leverages(&r_p, &inv_g_p, &rw_p)),
1516                Some(leverages(&r_q, &inv_g_q, &rw_q_b)),
1517            )
1518        } else {
1519            (None, None)
1520        };
1521        (
1522            residuals(&e, vce, Some(&fit_p), hii_p.as_ref(), opts.nnmatch, p + 1),
1523            residuals(&e, vce, Some(&fit_q), hii_q.as_ref(), opts.nnmatch, q + 1),
1524        )
1525    };
1526    let (zwd, u_p) = match &e.z {
1527        Some(z) => (
1528            Some(crossprod(&scale_rows(z, &ww_h), &d)),
1529            Some(crossprod(&rw_p, &d)),
1530        ),
1531        None => (None, None),
1532    };
1533    Ok(SideFit {
1534        sqrt_rx_p: scale_rows(&r_p, &sq(&ww_h)),
1535        sqrt_rx_q_h: scale_rows(&r_q, &sq(&ww_h)),
1536        g_q,
1537        w_b: ww_b,
1538        rw_q_h: scale_rows(&r_q, &ww_h),
1539        beta_p,
1540        beta_bc,
1541        inv_g_p,
1542        inv_g_q,
1543        rw_p,
1544        q_q,
1545        res_h,
1546        res_b,
1547        zwd,
1548        u_p,
1549        cl: e.cl,
1550        n_h,
1551        r_q,
1552    })
1553}
1554
1555/// Local polynomial RD estimate at the cutoff with its bias correction, as
1556/// `rdrobust` with the options in `opts`. `bandwidths` fixes `h` and `b`
1557/// (with `opts.rho`, `b = h / rho`); `None` selects them with
1558/// [`rd_bandwidths`]. A fuzzy design estimates the ratio of the outcome and
1559/// treatment jumps; covariates adjust both.
1560pub fn rd_estimate(
1561    data: &RdData<'_>,
1562    opts: &RdOptions,
1563    bandwidths: Option<RdBandwidths>,
1564) -> Result<RdEstimate> {
1565    opts.validate()?;
1566    let q = opts.q();
1567    let c = data.cutoff;
1568    let (left, right) = prepare(data, opts)?;
1569    check_support(&left, &right, q)?;
1570    if let (Some(lt), Some(rt)) = (&left.t, &right.t) {
1571        if var(lt) == 0.0
1572            && var(rt) == 0.0
1573            && (np_mean(lt) - np_mean(rt)).abs() < f64::EPSILON.sqrt()
1574        {
1575            return Err(DmlError::InvalidArgument(
1576                "fuzzy RD: the treatment has no variation and no jump at the cutoff".into(),
1577            ));
1578        }
1579    }
1580    let vce = effective_vce(opts.vce, data.cluster.is_some());
1581    let n = left.n() + right.n();
1582    let bws = match bandwidths {
1583        Some(bws) => {
1584            let all = [bws.h.0, bws.h.1, bws.b.0, bws.b.1];
1585            if !all.iter().all(|v| v.is_finite() && *v > 0.0) {
1586                return Err(DmlError::InvalidArgument(
1587                    "bandwidths must be positive and finite".into(),
1588                ));
1589            }
1590            match opts.rho {
1591                Some(rho) => RdBandwidths {
1592                    h: bws.h,
1593                    b: (bws.h.0 / rho, bws.h.1 / rho),
1594                },
1595                None => bws,
1596            }
1597        }
1598        None if n < 20 => {
1599            let h = (c - left.x[0])
1600                .abs()
1601                .max((right.x[right.n() - 1] - c).abs());
1602            RdBandwidths::common(h, opts.rho.map_or(h, |rho| h / rho))
1603        }
1604        None => select_bandwidths(&left, &right, c, opts)?,
1605    };
1606    let fl = fit_side(&left, c, bws.h.0, bws.b.0, opts, vce)?;
1607    let fr = fit_side(&right, c, bws.h.1, bws.b.1, opts, vce)?;
1608
1609    let fuzzy = data.fuzzy.is_some();
1610    let dt = usize::from(fuzzy);
1611    let dz = left.d_z();
1612    let sp = opts.scalepar;
1613    let beta_p = &fr.beta_p - &fl.beta_p;
1614    let beta_bc = &fr.beta_bc - &fl.beta_bc;
1615    let (tau_cl, tau_bc, s_y) = if dz == 0 {
1616        let (tau_y_cl, tau_y_bc) = (sp * beta_p[[0, 0]], sp * beta_bc[[0, 0]]);
1617        if fuzzy {
1618            let (tau_t_cl, tau_t_bc) = (beta_p[[0, 1]], beta_bc[[0, 1]]);
1619            let tau_cl = tau_y_cl / tau_t_cl;
1620            let s = vec![1.0 / tau_t_cl, -(tau_y_cl / (tau_t_cl * tau_t_cl))];
1621            let tau_bc = tau_cl - (s[0] * (tau_y_cl - tau_y_bc) + s[1] * (tau_t_cl - tau_t_bc));
1622            (tau_cl, tau_bc, s)
1623        } else {
1624            (tau_y_cl, tau_y_bc, vec![1.0])
1625        }
1626    } else {
1627        // Partial the covariates out of the pooled local fits (pinv, as
1628        // rdrobust with covs_drop).
1629        let cols_z = s![.., 1 + dt..1 + dt + dz];
1630        let side_terms = |f: &SideFit| -> (Array2<f64>, Array2<f64>) {
1631            let (zwd, u) = (f.zwd.as_ref().expect("covs"), f.u_p.as_ref().expect("covs"));
1632            let uigu = u.slice(cols_z).t().dot(&f.inv_g_p.dot(u));
1633            (
1634                &zwd.slice(cols_z) - &uigu.slice(cols_z),
1635                &zwd.slice(s![.., ..1 + dt]) - &uigu.slice(s![.., ..1 + dt]),
1636            )
1637        };
1638        let ((zwz_l, zwy_l), (zwz_r, zwy_r)) = (side_terms(&fl), side_terms(&fr));
1639        let gamma = sym_pinv(&(&zwz_r + &zwz_l)).dot(&(&zwy_r + &zwy_l));
1640        let s_y: Vec<f64> = std::iter::once(1.0)
1641            .chain(gamma.column(0).iter().map(|g| -g))
1642            .collect();
1643        let dot = |a: &[f64], b: &[f64]| a.iter().zip(b).map(|(x, y)| x * y).sum::<f64>();
1644        let row = |beta: &Array2<f64>, col: usize| -> Vec<f64> {
1645            std::iter::once(beta[[0, col]])
1646                .chain((0..dz).map(|j| beta[[0, 1 + dt + j]]))
1647                .collect()
1648        };
1649        if fuzzy {
1650            let s_t: Vec<f64> = std::iter::once(1.0)
1651                .chain(gamma.column(1).iter().map(|g| -g))
1652                .collect();
1653            let tau_y_cl = sp * dot(&s_y, &row(&beta_p, 0));
1654            let tau_t_cl = dot(&s_t, &row(&beta_p, 1));
1655            let tau_y_bc = sp * dot(&s_y, &row(&beta_bc, 0));
1656            let tau_t_bc = dot(&s_t, &row(&beta_bc, 1));
1657            let tau_cl = tau_y_cl / tau_t_cl;
1658            let s2 = [1.0 / tau_t_cl, -(tau_y_cl / (tau_t_cl * tau_t_cl))];
1659            let tau_bc = tau_cl - (s2[0] * (tau_y_cl - tau_y_bc) + s2[1] * (tau_t_cl - tau_t_bc));
1660            let mut s = s2.to_vec();
1661            s.extend((0..dz).map(|j| {
1662                -(1.0 / tau_t_cl) * gamma[[j, 0]]
1663                    + (tau_y_cl / (tau_t_cl * tau_t_cl)) * gamma[[j, 1]]
1664            }));
1665            (tau_cl, tau_bc, s)
1666        } else {
1667            let tau_cl = sp * dot(&s_y, &beta_p.row(0).to_vec());
1668            let tau_bc = sp * dot(&s_y, &beta_bc.row(0).to_vec());
1669            (tau_cl, tau_bc, s_y)
1670        }
1671    };
1672
1673    let hb_match = bws.h == bws.b;
1674    let clustered = data.cluster.is_some();
1675    let variance = |f: &SideFit, robust: bool| -> f64 {
1676        let cl = f.cl.as_deref();
1677        if !robust {
1678            let k = f.rw_p.ncols();
1679            // rdrobust recovers G_p by inverting G_p⁻¹ here.
1680            let crv = CrvParts::new(vce, &f.inv_g_p, &f.sqrt_rx_p, false);
1681            return f
1682                .inv_g_p
1683                .dot(&vce_meat(&s_y, &f.rw_p, &f.res_h, cl, k, crv.as_ref()))
1684                .dot(&f.inv_g_p)[[0, 0]];
1685        }
1686        if hb_match && clustered {
1687            // The q-order regression directly (rdrobust's h = b path).
1688            let k = f.rw_q_h.ncols();
1689            let crv = Crv::of(vce).map(|kind| CrvParts {
1690                kind,
1691                inv_g: &f.inv_g_q,
1692                sqrt_rx: &f.sqrt_rx_q_h,
1693                g: (kind == Crv::Three).then(|| f.g_q.clone()),
1694            });
1695            f.inv_g_q
1696                .dot(&vce_meat(&s_y, &f.rw_q_h, &f.res_b, cl, k, crv.as_ref()))
1697                .dot(&f.inv_g_q)[[0, 0]]
1698        } else if let (Some(cl), Some(kind)) = (cl, Crv::of(vce)) {
1699            let meat = crv_meat_qq(
1700                &f.q_q, &f.r_q, &f.w_b, &f.inv_g_q, &f.g_q, &f.res_b, cl, kind, &s_y,
1701            );
1702            f.inv_g_p.dot(&meat).dot(&f.inv_g_p)[[0, 0]]
1703        } else {
1704            f.inv_g_p
1705                .dot(&vce_meat(&s_y, &f.q_q, &f.res_b, cl, q + 1, None))
1706                .dot(&f.inv_g_p)[[0, 0]]
1707        }
1708    };
1709    let se_cl = (sp * sp * (variance(&fl, false) + variance(&fr, false))).sqrt();
1710    let se_rb = (sp * sp * (variance(&fl, true) + variance(&fr, true))).sqrt();
1711    let coef = [tau_cl, tau_bc, tau_bc];
1712    let se = [se_cl, se_cl, se_rb];
1713    let quant = -norm_ppf((1.0 - opts.level) / 2.0);
1714    let ci = std::array::from_fn(|k| [coef[k] - quant * se[k], coef[k] + quant * se[k]]);
1715    Ok(RdEstimate {
1716        coef,
1717        se,
1718        ci,
1719        h: bws.h,
1720        b: bws.b,
1721        n_h: (fl.n_h, fr.n_h),
1722        n: (left.n(), right.n()),
1723    })
1724}
1725
1726/// Local linear RD estimate at cutoff `c` with a quadratic bias correction
1727/// and nearest-neighbour standard errors, as `rdrobust` with its default
1728/// options. `bandwidths` gives `(h, b)` (both sides); `None` selects them
1729/// with [`rdbwselect`]. Pass the treatment as `fuzzy` for a fuzzy design (the
1730/// effect is then the ratio of the outcome and treatment jumps).
1731pub fn rdrobust<'a>(
1732    y: ArrayView1<'a, f64>,
1733    x: ArrayView1<'a, f64>,
1734    fuzzy: Option<ArrayView1<'a, f64>>,
1735    c: f64,
1736    bandwidths: Option<(f64, f64)>,
1737) -> Result<RdEstimate> {
1738    let mut data = RdData::new(y, x).cutoff(c);
1739    data.fuzzy = fuzzy;
1740    rd_estimate(
1741        &data,
1742        &RdOptions::default(),
1743        bandwidths.map(|(h, b)| RdBandwidths::common(h, b)),
1744    )
1745}
1746
1747#[cfg(test)]
1748mod tests {
1749    use super::*;
1750    use ndarray::array;
1751
1752    #[test]
1753    fn duplicates_count_runs() {
1754        let (d, id) = duplicates(&[1.0, 2.0, 2.0, 2.0, 3.0]);
1755        assert_eq!(d, vec![1, 3, 3, 3, 1]);
1756        assert_eq!(id, vec![1, 1, 2, 3, 1]);
1757    }
1758
1759    #[test]
1760    fn quantile_type2_matches_r() {
1761        let x = [1.0, 2.0, 3.0, 4.0];
1762        assert_eq!(quantile_type2(&x, 0.25), 1.5);
1763        assert_eq!(quantile_type2(&x, 0.75), 3.5);
1764        assert_eq!(quantile_type2(&[1.0, 2.0, 3.0], 0.25), 1.0);
1765    }
1766
1767    #[test]
1768    fn collinear_covariates_are_dropped() {
1769        let z = array![
1770            [1.0, 2.0, 0.5],
1771            [2.0, 4.0, -1.0],
1772            [3.0, 6.0, 2.0],
1773            [4.0, 8.0, 0.0]
1774        ];
1775        let keep = independent_columns(&z);
1776        assert_eq!(keep.len(), 2);
1777        assert!(keep.contains(&2));
1778        let full = array![[1.0, 0.0], [0.0, 1.0], [1.0, 1.0]];
1779        assert_eq!(independent_columns(&full), vec![0, 1]);
1780    }
1781
1782    #[test]
1783    fn options_are_validated() {
1784        let bad = |o: RdOptions| o.validate().is_err();
1785        assert!(bad(RdOptions {
1786            q: Some(1),
1787            ..RdOptions::default()
1788        }));
1789        assert!(bad(RdOptions {
1790            level: 95.0,
1791            ..RdOptions::default()
1792        }));
1793        assert!(bad(RdOptions {
1794            nnmatch: 0,
1795            ..RdOptions::default()
1796        }));
1797        assert!(bad(RdOptions {
1798            rho: Some(0.0),
1799            ..RdOptions::default()
1800        }));
1801        assert!(RdOptions::default().validate().is_ok());
1802        assert_eq!(effective_vce(Vce::Hc2, true), Vce::Cr2);
1803        assert_eq!(effective_vce(Vce::Nn, true), Vce::Cr1);
1804        assert_eq!(effective_vce(Vce::Cr3, false), Vce::Hc3);
1805    }
1806}