Standard Rdd
Executive Summary

Executive Summary

Did crossing this dem vote share = 0.5000 change next dem vote share?

Observations
13566
Cutoff
0.5000
Bandwidth
0.1591
Effective N Near Cutoff
7385
Jump at Cutoff
0.097
95% CI
0.087 to 0.106
p-value
< 0.001
Design
sharp (assumed — no treatment column supplied)
Treatment Jump at Cutoff
treatment column not supplied
Manipulation Test
no evidence
Covariate Balance
not tested
Bandwidth Sensitivity
stable
Crossing this dem vote share = 0.5000 is associated with a step up in next dem vote share of 0.097 (95% CI 0.087 to 0.106, p < 0.001). It rests on 7,385 of the 13,566 usable rows (54.44%) — the ones within 0.1591 of the cutoff — not on the whole table. Diagnostics: manipulation of this dem vote share shows no evidence of sorting across the threshold; covariate balance was not tested (no covariates mapped); bandwidth sensitivity is stable. The checks that could be run did not contradict the design. Whatever this estimates, it estimates it only at this dem vote share = 0.5000. It describes units sitting right at that threshold and says nothing about next dem vote share for units far above or below it — a regression discontinuity buys credibility at the cutoff by giving up everything else.
What this means

The short answer

Barely winning — crossing 50% — is associated with a 0.097-point boost in next-election vote share, statistically significant (p < 0.001). The effect is real at the threshold; it describes only units sitting right at that line, not parties far above or below it.

The detail

The jump at the cutoff is 0.097 (95% CI 0.087 to 0.106), estimated from 7,385 of 13,566 rows (54.44%) — those within the bandwidth of 0.1591. The manipulation test found no evidence of sorting across the threshold (density ratio 1.11, p = 0.169), so the design survives that check. Covariate balance was not tested because no pre-determined columns were supplied. Bandwidth sensitivity is stable.

What this can't tell you

A regression discontinuity buys credibility at the cutoff by giving up generalizability elsewhere. This finding is firm at 0.5000 vote share but does not extend to elections where a party won by larger margins or lost. Covariate balance remains untested; mapping pre-determined columns would strengthen confidence that the jump reflects the barely-win rule rather than some other threshold effect.

Overview

Analysis Overview

Regression discontinuity in next dem vote share at this dem vote share = 0.5000.

N Observations13566
Jump At Cutoff0.097
Effective N7385
Bandwidth0.1591
What this means

The short answer

A party that barely wins an election sees its vote share jump about 0.097 points in the next one—a gain of roughly 21% relative to where it would have been had it barely lost. This is a sharp step right at the 50% threshold, not a gradual trend, and the evidence is strong enough to rule out chance.

The detail

The analysis compares districts where a party's vote share landed just above 0.5000 to those just below it, using a local linear regression weighted by proximity to the cutoff. The fitted vote share just below the threshold was 0.453; just above, 0.550. The discontinuity estimate is 0.0966 (95% CI 0.087 to 0.106, p < 0.001). This rests on 7,385 of 13,566 rows (54.44%)—those within the bandwidth of 0.1591 of the cutoff. Across six bandwidths spanning 0.0796 to 0.3183, the estimate remained between 0.092 and 0.097, with all six confidence intervals excluding zero.

What this can't tell you

The estimate applies only at districts sitting exactly at the 50% threshold. It says nothing about vote-share dynamics for districts that won by 10 points or lost by 10 points. To understand whether the boost extends to safer wins or how it fades with distance from the cutoff, consider mapping outcome data for districts at varying distances from the threshold and testing whether the effect persists at wider bandwidths.

Data Preparation

Data Preparation

How the cutoff was set, what was dropped, and how the rows split.

Initial Rows13566
Final Rows13566
Rows Removed0
What this means

The short answer

All 13,566 rows were retained and used; no missing-value imputation was performed near the cutoff. The threshold of 0.5000 split the data into 5,666 districts below and 7,900 at or above it, preserving the full sample for analysis.

The detail

The dataset loaded with 13,566 rows and 13,566 were used—zero rows removed. The cutoff was supplied as 0.5000 on the running variable (this dem vote share). This split yielded 5,666 rows below the cutoff and 7,900 at or above it. Missing values in the outcome (next dem vote share) near the cutoff were never imputed: filling them in would artificially construct part of the very discontinuity being measured, biasing the estimate upward.

What this can't tell you

The refusal to impute is methodologically sound but means any districts with genuinely missing next-election vote data near the threshold are excluded from the weighted sample. If missingness correlates with the treatment (e.g., districts that barely won are more likely to have missing future data), the effective sample near the cutoff may be unrepresentative. Consider documenting the pattern of missingness by side of the cutoff to check for asymmetry.

Visualization

Outcome Against the Running Variable

Binned means with the local linear fit either side of the cutoff.

What this means

The short answer

The plot shows two smooth arms—one rising gently below the 50% threshold, another rising more steeply above it—meeting at a visible step of about 0.097. The discontinuity is clean and centered exactly at the cutoff, with no evidence of curvature that the straight lines cannot follow.

The detail

Binned means of next dem vote share across 37 slices of this dem vote share show a steady upward trend on both sides of the cutoff at 0.5000. Below the cutoff, the fitted line predicts 0.453 at the threshold; above it, 0.550. The two lines meet at a gap of 0.097. The pattern is consistent with a sharp threshold effect: the step appears exactly at the cutoff, not before it, and both arms are approximately linear within the bandwidth (0.1591 either side).

What this can't tell you

Straight lines fitted to binned means can mask curvature the local linear estimator cannot follow. The bandwidth sensitivity check (all six intervals excluding zero) is the practical guard against this. If next dem vote share follows a polynomial curve near the cutoff, a narrower bandwidth would reduce bias at the cost of wider intervals. Consider fitting a quadratic local polynomial as a robustness check if you suspect curvature.

Data Table

The Discontinuity Estimate

The cutoff, the bandwidth, the effective sample, and the jump with its interval.

MeasureEstimateDetail
Cutoff on this dem vote share0.5The cutoff was supplied as 0.5000 on this dem vote share.
Bandwidth0.1591The bandwidth 0.1591 came from the Imbens-Kalyanaraman plug-in rule, which trades the bias of a wide window against the noise of a narrow one using the estimated density, residual variance, and curvature at the cutoff.
Rows inside the bandwidth73857,385 of the 13,566 usable rows (54.44%) — 3,883 below the cutoff and 3,502 at or above it. This is the sample the estimate actually rests on.
Fitted next dem vote share just below the cutoff0.453The left-hand local linear line extrapolated to this dem vote share = 0.5000
Fitted next dem vote share just above the cutoff0.55The right-hand local linear line extrapolated to this dem vote share = 0.5000
Discontinuity estimate at the cutoff0.096695% CI 0.087 to 0.106; p < 0.001; statistically significant (p < 0.001)
Standard error0.0048Heteroskedasticity-robust (HC1) around the weighted local linear fit
Effect relative to the level just below21.29The jump as a percentage of the fitted next dem vote share just below the cutoff (0.453)
What this means

The short answer

Parties that barely win (reaching 0.5000 vote share) see their next-election vote share jump by 0.097 points — equivalent to a 21.29% increase over the fitted level just below the threshold (0.453). The effect is statistically significant and rests on about half the data.

The detail

The cutoff is 0.5000. The bandwidth (0.1591) comes from the Imbens-Kalyanaraman plug-in rule. Within that window sit 7,385 rows (54.44%): 3,883 below the cutoff and 3,502 at or above it. The left-hand line predicts 0.453 at the cutoff; the right-hand line predicts 0.550. The discontinuity is their difference: 0.0966 (95% CI 0.087 to 0.106, p < 0.001, SE = 0.0048). Relative to the fitted level just below, this jump is 21.29%.

What this can't tell you

This estimate applies only at the threshold. Parties far above 0.5000 may show different momentum; parties far below may not. Generalization beyond barely-winning elections would require separate analysis of those ranges.

Data Table

Manipulation of the Running Variable

Density test for sorting across the cutoff in this dem vote share.

MeasureEstimateDetail
Density of this dem vote share just below the cutoff1.59Local linear fit of the binned frequencies of this dem vote share below the cutoff, extrapolated to it
Density of this dem vote share just above the cutoff1.773Local linear fit of the binned frequencies of this dem vote share at or above the cutoff, extrapolated to it
Density ratio (above divided by below)1.1151.00 means no pile-up on either side
Log density difference0.1088The quantity actually tested — zero under no manipulation
z statistic1.376Log difference divided by its standard error (0.0791)
p-value0.16880.169 — no detectable pile-up on either side
Histogram bin width0.00392 times the spread of this dem vote share divided by the square root of the row count (McCrary's rule)
Bins used each side3417 below and 17 above, inside a density bandwidth of 0.0675
What this means

The short answer

The density of vote share does not jump detectably at the 0.5000 threshold, so there is no sign that parties sorted themselves across the barely-win line. The manipulation test passes.

The detail

Density just below the cutoff is 1.5902; just above it, 1.7729 — a ratio of 1.1149 (p = 0.169). The test compares these fitted densities by their log difference (0.1088) divided by its standard error (0.0791), yielding z = 1.376. At p = 0.169, this is not statistically significant. The binning used 34 bins total (17 each side) with width 0.0039, following McCrary's rule.

What this can't tell you

A non-significant result in a small sample is absence of evidence, not proof of no sorting. The test has limited power when the sample near the cutoff is tight. This check rules out gross manipulation but does not guarantee that unmeasured confounders are balanced at the threshold — that is why covariate balance testing matters.

Data Table

Covariate Balance at the Cutoff

Do pre-determined columns jump at the threshold as well?

CovariateJumpStd ErrorP ValueRows UsedVerdict
(no covariates mapped)n/a0not tested — map pre-determined columns to run this check
What this means

The short answer

No pre-determined columns were supplied, so covariate balance could not be tested. This is a gap in the evidence, not a pass.

The detail

The test runs each pre-determined covariate (age, prior score, tenure, region — anything fixed before the election) through the same local linear estimator at the same bandwidth, asking whether it jumps at the cutoff. A jump would indicate that something other than the barely-win rule changes at the threshold, which is how a discontinuity becomes spurious. Without mapped covariates, this check cannot run.

What this can't tell you

The analysis cannot rule out that a confounding factor jumps at 0.5000 and drives the observed jump in next-election vote share. Supplying pre-determined columns — fixed before the election in question — would test whether the effect is attributable to the barely-win rule itself or to some other threshold phenomenon.

Visualization

Bandwidth Sensitivity

The estimate and its interval recomputed across a range of bandwidths.

What this means

The short answer

The estimate holds steady across a sixfold range of bandwidths: from 0.0796 to 0.3183, the jump stays between 0.092 and 0.097, and all six confidence intervals exclude zero. The finding does not depend on the specific bandwidth the rule chose.

The detail

The estimate was recomputed at six bandwidths: 0.50× h (0.0796, estimate 0.0917, CI 0.079–0.1045), 0.75× h (0.1194, estimate 0.0964, CI 0.0857–0.1072), 1.00× h (0.1591, estimate 0.0966, CI 0.0871–0.106), 1.25× h (0.1989, estimate 0.0960, CI 0.0873–0.1047), 1.50× h (0.2387, estimate 0.0966, CI 0.0885–0.1048), and 2.00× h (0.3183, estimate 0.0956, CI 0.0882–0.103). The spread across all six estimates is 5.07% of the headline figure. This stability indicates the effect is not a bandwidth artefact.

What this can't tell you

Stability across this range does not rule out bias from curvature in next dem vote share near the cutoff. A narrower bandwidth (e.g., 0.50× h) reduces bias at the cost of wider intervals; a wider bandwidth (2.00× h) narrows intervals at the cost of absorbing curvature the straight lines cannot follow. If next dem vote share follows a polynomial trend near the threshold, consider fitting a local quadratic polynomial as a robustness check.

Data Table

Method & Assumptions

The design, the identification choices, the diagnostics, and the limits.

ItemDetail
DesignSharp is ASSUMED: no treatment column was mapped, so the analysis takes it that everything at or above 0.5000 on this dem vote share was treated and nothing below it was. If compliance was imperfect, this assumption is wrong and the number below is not a sharp RD estimate — map the treatment column to have it checked.
CutoffThe cutoff was supplied as 0.5000 on this dem vote share.
EstimatorLocal linear regression fitted separately either side of the cutoff, weighting each row by a triangular kernel (weight 1 at the cutoff falling to 0 at the edge of the bandwidth). The estimate is the gap between the two lines where they meet this dem vote share = 0.5000.
BandwidthThe bandwidth 0.1591 came from the Imbens-Kalyanaraman plug-in rule, which trades the bias of a wide window against the noise of a narrow one using the estimated density, residual variance, and curvature at the cutoff.
InferenceHeteroskedasticity-robust (HC1) standard errors on the weighted fit; the 95% interval uses the t distribution on 7,381 degrees of freedom. The interval is NOT bias-corrected: a bandwidth chosen to minimise mean squared error deliberately accepts some bias in exchange for precision, so the true coverage of this interval is below 95% whenever next dem vote share is curved near the cutoff. The bandwidth sensitivity row is the practical check — a narrower bandwidth carries less bias and a wider interval.
Effective sample7,385 of 13,566 rows sit inside the bandwidth (54.44%). A regression discontinuity on a large table can still rest on a small handful of rows near the threshold, which is why this number is reported rather than the row count.
Manipulation testThe density of this dem vote share does not jump detectably at the cutoff (1.77 just above versus 1.59 just below, ratio 1.11, p = 0.169), so there is no sign that units sorted themselves across the threshold. Absence of evidence is not proof: the test has limited power in small samples.
Covariate balanceNo pre-determined columns were mapped, so covariate balance could not be tested. That is a gap in the evidence, not a pass: map columns that were fixed before the threshold was applied (age, prior score, tenure, region) and they should show no jump at the cutoff.
Bandwidth sensitivityAcross 6 bandwidths from 0.0796 to 0.3183 the estimate stays between 0.092 and 0.097 — a spread of 5.07% of the headline figure — and 6 of the 6 confidence intervals exclude zero. The finding does not depend on the bandwidth the rule chose.
What this cannot tell youWhatever this estimates, it estimates it only at this dem vote share = 0.5000. It describes units sitting right at that threshold and says nothing about next dem vote share for units far above or below it — a regression discontinuity buys credibility at the cutoff by giving up everything else.
Key assumptionsUnits just below and just above this dem vote share = 0.5000 are otherwise comparable; nothing else changes at the same threshold; next dem vote share and any other pre-determined characteristic evolve smoothly through it; and the relationship between this dem vote share and next dem vote share is well approximated by a straight line on each side within the bandwidth.
What this means

The short answer

The analysis fits straight lines to vote share on each side of the 50% threshold, weights rows by how close they are to the cutoff, and reads the gap where the lines meet. The design assumes perfect compliance (sharp RD); the bandwidth was chosen by a plug-in rule that balances bias and variance; and inference uses robust standard errors. All diagnostics that could be run passed.

The detail

Design: Sharp RD is assumed because no treatment column was supplied. Everything at or above 0.5000 is treated as having won; everything below, as having lost. If compliance was imperfect, this assumption is invalid and the estimate does not hold. Cutoff: 0.5000 (supplied). Estimator: Local linear regression with triangular kernel, fitted separately either side of the cutoff. Bandwidth: 0.1591 from the Imbens-Kalyanaraman plug-in rule. Inference: Heteroskedasticity-robust (HC1) standard errors; 95% interval uses t distribution on 7,381 degrees of freedom. The interval is not bias-corrected because the bandwidth minimizes mean squared error, accepting bias for precision. Effective sample: 7,385 of 13,566 rows (54.44%). Manipulation test: Density ratio 1.11, p = 0.169 (no evidence of sorting). Covariate balance: Not tested (no covariates mapped). Bandwidth sensitivity: Estimate stable across six bandwidths (0.092–0.097, all CIs exclude zero).

What this can't tell you

The estimate applies only at the 50% threshold and nowhere else. If the running variable was manipulated (units strategically chose which side to land on), the assumption of comparability breaks down and the design is invalid. The covariate balance check was not run, so we cannot rule out threshold-aligned structural breaks unrelated to the rule. To extend credibility, map pre-determined columns and verify no jump at the cutoff. If next dem vote share is curved near the threshold, the interval's true coverage is below 95%; bandwidth sensitivity provides empirical reassurance but is not a formal bias correction.

Rate this report Was this the answer you needed?
The exact source that produced this report — yours to keep, read, and re-run.
Download PDF
How this was computed method · R source · citation
The code that did it

Regression Discontinuity — Did Crossing the Threshold Cause the Change?

Sharp regression discontinuity. Units on either side of a cutoff in a running variable receive different treatment, and the jump in the outcome AT the cutoff estimates the causal effect for units sitting right at the threshold. The estimate is a local linear regression with a triangular kernel fitted separately on each side, at a data-driven bandwidth, with a heteroskedasticity-robust confidence interval.

Why This Method?

When a rule ("scholarship if score >= 60", "audit if revenue >= 5m") decides who is treated, units just below and just above the line are otherwise alike — the assignment near the threshold is as good as random. Comparing them recovers a causal effect from observational data, with no experiment.

What This Analysis Covers

  • The discontinuity estimate with a 95% confidence interval
  • Outcome against the running variable: binned means plus both fitted lines
  • Manipulation of the running variable (McCrary-style density test)
  • Covariate balance at the cutoff
  • Bandwidth sensitivity across a range of bandwidths
  • The effective sample size that actually carries the estimate

Standard Library

Platform standard-library module (LAT-1441): runs on ANY dataset via the semantic mapping {outcome, running, treatment, covariate_1..N}. All narrative is derived from the user's own column names and computed values.

suppressPackageStartupMessages(library(DT))
suppressPackageStartupMessages(library(htmlwidgets))
suppressPackageStartupMessages(library(arrow))
suppressPackageStartupMessages(library(knitr))
suppressPackageStartupMessages(library(rmarkdown))
suppressPackageStartupMessages(library(dplyr))
suppressPackageStartupMessages(library(tidyr))
suppressPackageStartupMessages(library(ggplot2))
suppressPackageStartupMessages(library(stringr))
suppressPackageStartupMessages(library(lubridate))
suppressPackageStartupMessages(library(broom))
suppressPackageStartupMessages(library(Matrix))
suppressPackageStartupMessages(library(cluster))
suppressPackageStartupMessages(library(data.table))

Estimation Machinery

Everything below is implemented directly on base R / stats: the local linear RD estimator with a triangular kernel and HC1-robust standard errors, the Imbens-Kalyanaraman plug-in bandwidth, and the McCrary density test. No RD-specific package is used.

A non-positive extrapolated density means the side has been emptied out right at the cutoff — the strongest possible sorting signal, not a reason to decline the test. Floor it at half of one observation's worth of density and say so, so the log ratio and its standard error stay defined.

f_floor <- 0.5 / (n * b)
  floored <- lo$f <= f_floor || hi$f <= f_floor
  lo$f <- max(lo$f, f_floor)
  hi$f <- max(hi$f, f_floor)
  theta <- log(hi$f) - log(lo$f)
  se <- sqrt((1 / (n * hd)) * (24 / 5) * (1 / hi$f + 1 / lo$f))
  if (!is.finite(se) || se <= 0) {
    return(list(testable = FALSE, bin_width = b, bandwidth = hd,
                n_bins_below = lo$nbin, n_bins_above = hi$nbin))
  }
  z <- theta / se
  list(testable = TRUE, f_below = lo$f, f_above = hi$f, floored = floored,
       ratio = hi$f / lo$f, theta = theta, se = se, z = z,
       p = 2 * stats::pnorm(-abs(z)), bin_width = b, bandwidth = hd,
       n_bins_below = lo$nbin, n_bins_above = hi$nbin)
}

Core Analysis Pipeline

compute_shared <- function(df, params, col_map = list()) {
  # === SHARED EXPORTS ===
  #   initial_rows/final_rows/rows_removed  $ row accounting
  #   outcome_h/running_h/treatment_h       $ humanized user column names
  #   cutoff / cutoff_rule                  $ the threshold and how it was set
  #   n_below/n_above/n_at_cutoff           $ counts either side of the cutoff
  #   h / bw_rule                           $ bandwidth and the rule that chose it
  #   fit                                   $ local_linear_rd() at h
  #   tau/tau_se/tau_p/ci_low/ci_high       $ the discontinuity estimate
  #   n_eff/n_eff_left/n_eff_right/eff_pct  $ effective sample near the cutoff
  #   design_kind/design_label/first_stage  $ sharp vs fuzzy detection
  #   dens/dens_verdict/dens_short          $ manipulation (McCrary) test
  #   bal_df/bal_short/bal_verdict          $ covariate balance
  #   bw_df/bw_short/bw_verdict             $ bandwidth sensitivity
  #   locality                              $ the local-effect honesty sentence
  #   rdd_fit_df/estimate_df/manip_df/methods_df  $ card datasets
  #   metrics / json_output
  # === /SHARED EXPORTS ===

Step 1: Resolve the mapped columns (humanized for every sentence)

initial_rows <- nrow(df)
  outcome_h   <- humanize_semantic("outcome", col_map)
  running_h   <- humanize_semantic("running", col_map)
  treatment_h <- humanize_semantic("treatment", col_map)
  for (req in c("outcome", "running")) {
    if (!(req %in% names(df))) {
      stop(sprintf("Regression discontinuity needs &#x27;%s' (%s) mapped.",
                   humanize_semantic(req, col_map),
                   c(outcome = "the numeric outcome",
                     running = "the running variable that the cutoff applies to")[[req]]))
    }
  }

Step 2: Coerce outcome and running variable to numeric (95% rule)

y_all <- coerce_num_95(df$outcome)
  if (is.null(y_all)) {
    stop(sprintf("The outcome column &#x27;%s' does not look numeric — fewer than 95%% of its values parse as numbers. Pick a numeric column.",
                 outcome_h))
  }
  x_all <- coerce_num_95(df$running)
  if (is.null(x_all)) {
    stop(sprintf("The running variable &#x27;%s' does not look numeric — fewer than 95%% of its values parse as numbers. A regression discontinuity needs a numeric score, date-number, or measure that the cutoff is applied to.",
                 running_h))
  }

Step 3: Keep rows complete on the outcome and the running variable

keep <- is.finite(y_all) & is.finite(x_all)
  n_dropped <- sum(!keep)
  y <- y_all[keep]; x <- x_all[keep]
  final_rows <- length(y)
  rows_removed <- initial_rows - final_rows
  if (final_rows < 30) {
    stop(sprintf("Only %d rows have both %s and %s — regression discontinuity needs at least 30, because the estimate is built from the rows near the cutoff alone.",
                 final_rows, outcome_h, running_h))
  }
  if (length(unique(x)) < 10) {
    stop(sprintf("The running variable &#x27;%s' takes only %d distinct values — regression discontinuity needs a running variable that varies continuously around the cutoff.",
                 running_h, length(unique(x))))
  }
  if (!is.finite(stats::sd(x)) || stats::sd(x) == 0) {
    stop(sprintf("The running variable &#x27;%s' is constant — there is no threshold to compare across.", running_h))
  }

Step 4: The treated flag, when supplied — and the cutoff

treat <- NULL
  treat_note <- ""
  if ("treatment" %in% names(df)) {
    tv <- df$treatment[keep]
    tn <- coerce_num_95(tv)
    if (!is.null(tn) && length(unique(stats::na.omit(tn))) == 2) {
      lv <- sort(unique(stats::na.omit(tn)))
      treat <- as.numeric(tn == lv[2])
    } else {
      ch <- trimws(as.character(tv))
      lv <- sort(unique(ch[!is.na(ch) & ch != ""]))
      if (length(lv) == 2) {
        pos <- c("yes", "y", "true", "t", "1", "treated", "treatment", "enrolled",
                 "eligible", "awarded", "in", "on", "approved")
        hit <- tolower(lv) %in% pos
        pos_lvl <- if (sum(hit) == 1) lv[hit] else lv[2]
        treat <- as.numeric(ch == pos_lvl)
        treat_note <- sprintf("&#x27;%s' was read as the treated value of %s. ", pos_lvl, treatment_h)
      } else {
        treat_note <- sprintf("The treatment column %s has %d distinct values rather than two, so it could not be read as a treated/untreated flag and was ignored. ",
                              treatment_h, length(lv))
      }
    }
    if (!is.null(treat) && any(!is.finite(treat))) treat[!is.finite(treat)] <- 0
  }

  cutoff_param <- suppressWarnings(as.numeric(params$cutoff %||% params$threshold %||% NA))
  if (length(cutoff_param) != 1) cutoff_param <- NA_real_
  if (is.finite(cutoff_param)) {
    cutoff <- cutoff_param
    cutoff_rule <- sprintf("The cutoff was supplied as %s on %s.", rs(cutoff), running_h)
  } else if (!is.null(treat)) {

Sharp separation gives the cutoff exactly: the smallest treated value.

if (max(c(-Inf, x[treat == 0])) < min(c(Inf, x[treat == 1]))) {
      cutoff <- min(x[treat == 1])
      cutoff_rule <- sprintf("The cutoff was read off %s: every treated unit has %s of at least %s and every untreated unit is below it.",
                             treatment_h, running_h, rs(cutoff))
    } else {
      v <- sort(unique(x))
      cnt_all <- as.numeric(table(factor(x, levels = v)))
      cnt_trt <- as.numeric(tapply(treat, factor(x, levels = v), sum))
      cnt_trt[!is.finite(cnt_trt)] <- 0
      cnt_unt <- cnt_all - cnt_trt
      before_trt <- c(0, cumsum(cnt_trt)[-length(v)])
      before_unt <- c(0, cumsum(cnt_unt)[-length(v)])
      acc <- (sum(cnt_trt) - before_trt + before_unt) / length(x)
      acc[!is.finite(acc)] <- -Inf
      cutoff <- v[which.max(acc)]
      cutoff_rule <- sprintf("No cutoff was supplied and %s does not separate cleanly, so the threshold on %s that best reproduces it(%s, matching %s of rows) was used.",
                             treatment_h, running_h, rs(cutoff),
                             paste0(r2(100 * max(acc)), "%"))
    }
  } else {
    zero_ok <- min(x) < 0 && max(x) > 0 &&
      sum(x < 0) >= 10 && sum(x >= 0) >= 10
    if (zero_ok) {
      cutoff <- 0
      cutoff_rule <- sprintf("No cutoff and no treatment column were supplied, so zero was ASSUMED to be the threshold on %s because the values straddle it. If the real rule sits elsewhere, supply the cutoff — every number below changes with it.",
                             running_h)
    } else {
      cutoff <- stats::median(x)
      cutoff_rule <- sprintf("No cutoff and no treatment column were supplied, so the median of %s(%s) was ASSUMED to be the threshold. This is a placeholder, not a discovered rule: supply the real cutoff, because every number below changes with it.",
                             running_h, rs(cutoff))
    }
  }
  if (!is.finite(cutoff)) {
    stop(sprintf("The cutoff on %s could not be determined. Supply it as the &#x27;cutoff' module parameter.", running_h))
  }

  xc <- x - cutoff
  n_below <- sum(xc < 0); n_above <- sum(xc >= 0)
  n_at_cutoff <- sum(xc == 0)
  if (n_below < 10 || n_above < 10) {
    stop(sprintf("Only %d rows fall below the cutoff(%s on %s) and %d at or above it — at least 10 are needed on each side for a regression discontinuity.",
                 n_below, rs(cutoff), running_h, n_above))
  }

Step 5: Bandwidth — Imbens-Kalyanaraman plug-in, then guard rails

side_h <- function(v, k) {
    if (length(v) == 0) return(Inf)
    v <- sort(v)
    if (length(v) < k) return(v[length(v)])
    v[k]
  }
  h_min <- max(side_h(abs(xc[xc < 0]), 15), side_h(xc[xc >= 0], 15)) * 1.0001
  h_max <- max(abs(xc))
  h_param <- suppressWarnings(as.numeric(params$bandwidth %||% NA))
  if (length(h_param) != 1) h_param <- NA_real_
  h_ik <- ik_bandwidth(xc, y)
  if (is.finite(h_param) && h_param > 0) {
    h_raw <- h_param
    bw_source <- sprintf("The bandwidth %s was supplied as a module parameter.", rs(h_param))
  } else if (is.finite(h_ik)) {
    h_raw <- h_ik
    bw_source <- sprintf("The bandwidth %s came from the Imbens-Kalyanaraman plug-in rule, which trades the bias of a wide window against the noise of a narrow one using the estimated density, residual variance, and curvature at the cutoff.",
                         rs(h_ik))
  } else {
    h_raw <- 1.84 * stats::sd(xc) * final_rows^(-1 / 5)
    bw_source <- sprintf("The plug-in bandwidth rule could not be evaluated on this data, so the pilot rule(1.84 times the spread of %s divided by the fifth root of the row count) was used instead, giving %s.",
                         running_h, rs(h_raw))
  }
  h <- min(max(h_raw, h_min), h_max)
  bw_clamped <- abs(h - h_raw) > 1e-9
  bw_rule <- paste0(
    bw_source,
    if (bw_clamped)
      sprintf(" It was then widened/narrowed to %s so that at least 15 rows sit on each side of the cutoff inside the window.", rs(h))
    else "")

  fit <- local_linear_rd(xc, y, h)
  if (is.null(fit)) {
    stop(sprintf("A local linear fit around the cutoff(%s on %s) could not be estimated — too few rows, or no variation in %s, on one side of the threshold.",
                 rs(cutoff), running_h, running_h))
  }
  tau <- fit$est; tau_se <- fit$se; tau_p <- fit$p
  ci_low <- fit$ci_low; ci_high <- fit$ci_high
  n_eff <- fit$n_eff; n_eff_left <- fit$n_left; n_eff_right <- fit$n_right
  eff_pct <- 100 * n_eff / final_rows
  rel_pct <- if (abs(fit$level_below) > 1e-8) 100 * tau / abs(fit$level_below) else NA_real_

Step 6: Sharp or fuzzy? The sharp estimator needs deterministic assignment

The first stage is the jump in the probability of treatment at the cutoff, estimated with the same local linear machinery at the same bandwidth.

implied <- as.numeric(xc >= 0)
  design_kind <- "sharp_assumed"
  n_crossovers <- NA_integer_
  first_stage <- NA_real_
  if (!is.null(treat)) {
    n_crossovers <- sum(treat != implied)
    if (n_crossovers == 0) {
      design_kind <- "sharp"
      first_stage <- 1
    } else {
      fs <- local_linear_rd(xc, treat, h)
      first_stage <- if (is.null(fs)) NA_real_ else fs$est
      design_kind <- if (is.finite(first_stage) && abs(first_stage) >= 0.2)
        "fuzzy" else "no_first_stage"
    }
  }
  design_label <- switch(
    design_kind,
    sharp = "sharp",
    sharp_assumed = "sharp(assumed — no treatment column supplied)",
    fuzzy = "fuzzy — sharp estimate does not apply",
    no_first_stage = "no first stage — the cutoff does not change treatment"
  )
  is_sharp <- design_kind %in% c("sharp", "sharp_assumed")

Step 7: Diagnostic 1 — manipulation of the running variable

dens <- mccrary_density(xc)
  if (is.null(dens) || !isTRUE(dens$testable)) {
    dens_short <- "not testable"
    dens_verdict <- sprintf("The density of %s around the cutoff could not be estimated(too few distinct values or too few bins on one side), so manipulation could not be tested — treat that as a gap in the evidence, not as a clean bill of health.",
                            running_h)
  } else if (dens$p < 0.05) {
    dens_short <- "detected"
    dens_verdict <- sprintf("The density of %s jumps at the cutoff: %s just above versus %s just below, a ratio of %s(%s).%s Units appear able to place themselves on the favourable side of the threshold, and when that is true the units either side are no longer comparable — the design is not credible and the estimate below should not be read as causal.",
                            running_h, rs(dens$f_above), rs(dens$f_below),
                            r2(dens$ratio), fmt_pp(dens$p),
                            if (isTRUE(dens$floored))
                              " One side extrapolates to a density at or below zero at the cutoff — effectively a hole in the data right where the rule bites — so it was floored at half of one observation&#x27;s worth of density to keep the ratio finite; the true gap is wider than the number shown."
                            else "")
  } else {
    dens_short <- "no evidence"
    dens_verdict <- sprintf("The density of %s does not jump detectably at the cutoff(%s just above versus %s just below, ratio %s, %s), so there is no sign that units sorted themselves across the threshold. Absence of evidence is not proof: the test has limited power in small samples.",
                            running_h, rs(dens$f_above), rs(dens$f_below),
                            r2(dens$ratio), fmt_pp(dens$p))
  }

Step 8: Diagnostic 2 — covariate balance at the cutoff

cov_cols <- grep("^covariate_[0-9]+$", names(df), value = TRUE)
  cov_cols <- cov_cols[order(as.integer(sub("^covariate_", "", cov_cols)))]
  bal_rows <- list(); dropped_cov <- character(0)
  for (cc in cov_cols) {
    lab <- humanize_semantic(cc, col_map)
    v <- df[[cc]][keep]
    cn <- coerce_num_95(v)
    if (is.null(cn)) {
      ch <- trimws(as.character(v))
      lv <- sort(unique(ch[!is.na(ch) & ch != ""]))
      if (length(lv) == 2) {
        cn <- as.numeric(ch == lv[2])
        lab <- sprintf("%s is %s", lab, lv[2])
      } else {
        dropped_cov <- c(dropped_cov, sprintf("%s(%d distinct text values)", lab, length(lv)))
        next
      }
    }
    if (sum(is.finite(cn)) < 20) {
      dropped_cov <- c(dropped_cov, sprintf("%s(fewer than 20 usable values)", lab)); next
    }
    vv <- suppressWarnings(stats::var(cn, na.rm = TRUE))
    if (!is.finite(vv) || vv == 0) {
      dropped_cov <- c(dropped_cov, sprintf("%s(constant)", lab)); next
    }
    cfit <- local_linear_rd(xc, cn, h)
    if (is.null(cfit)) {
      dropped_cov <- c(dropped_cov, sprintf("%s(no usable values near the cutoff)", lab)); next
    }
    bal_rows[[length(bal_rows) + 1]] <- data.frame(
      covariate = lab,
      jump = round(cfit$est, 4),
      std_error = round(cfit$se, 4),
      p_value = fmt_p(cfit$p),
      rows_used = cfit$n_eff,
      verdict = if (cfit$p < 0.05) "jumps at the cutoff" else "no detectable jump",
      stringsAsFactors = FALSE
    )
  }
  if (length(bal_rows) > 0) {
    bal_df <- do.call(rbind, bal_rows)
    rownames(bal_df) <- NULL
    n_bad <- sum(bal_df$verdict == "jumps at the cutoff")
    if (n_bad > 0) {
      bal_short <- "imbalanced"
      bal_verdict <- sprintf("%d of the %d pre-determined column(s) tested(%s) jump at the cutoff themselves. Things that were fixed before the threshold was applied should not change at it — when they do, the units either side differ in more than the treatment and the design is compromised. Read the estimate as a description of the two groups, not as the effect of crossing the threshold.",
                             n_bad, nrow(bal_df),
                             paste(bal_df$covariate[bal_df$verdict == "jumps at the cutoff"], collapse = ", "))
    } else {
      bal_short <- "balanced"
      bal_verdict <- sprintf("None of the %d pre-determined column(s) tested(%s) jump detectably at the cutoff, which is what a credible discontinuity looks like: only the treatment changes at the threshold. This supports the design without proving it — an unmeasured characteristic could still jump.",
                             nrow(bal_df), paste(bal_df$covariate, collapse = ", "))
    }
  } else {
    bal_df <- data.frame(
      covariate = "(no covariates mapped)", jump = NA_real_, std_error = NA_real_,
      p_value = "n/a", rows_used = 0L,
      verdict = "not tested — map pre-determined columns to run this check",
      stringsAsFactors = FALSE)
    bal_short <- "not tested"
    bal_verdict <- "No pre-determined columns were mapped, so covariate balance could not be tested. That is a gap in the evidence, not a pass: map columns that were fixed before the threshold was applied(age, prior score, tenure, region) and they should show no jump at the cutoff."
  }

Step 9: Diagnostic 3 — bandwidth sensitivity

mults <- c(0.50, 0.75, 1.00, 1.25, 1.50, 2.00)
  bw_list <- list(); used_h <- numeric(0)
  for (mm in mults) {
    hh <- min(h * mm, h_max)

Multipliers that clamp to the same window would repeat a row verbatim.

if (any(abs(used_h - hh) < 1e-9)) next
    used_h <- c(used_h, hh)
    f2 <- local_linear_rd(xc, y, hh)
    if (is.null(f2)) next
    bw_list[[length(bw_list) + 1]] <- data.frame(
      bandwidth_label = sprintf("%s× h(%s)", r2(mm), rs(hh)),
      estimate = round(f2$est, 4),
      ci_low = round(f2$ci_low, 4),
      ci_high = round(f2$ci_high, 4),
      stringsAsFactors = FALSE
    )
  }
  bw_df <- if (length(bw_list) > 0) do.call(rbind, bw_list) else
    data.frame(bandwidth_label = character(0), estimate = numeric(0),
               ci_low = numeric(0), ci_high = numeric(0), stringsAsFactors = FALSE)
  rownames(bw_df) <- NULL
  n_bw <- nrow(bw_df)
  n_bw_sig <- if (n_bw > 0) sum(bw_df$ci_low > 0 | bw_df$ci_high < 0) else 0L
  sign_consistent <- n_bw > 0 && all(sign(bw_df$estimate) == sign(tau))

Spread across the bandwidth grid relative to the headline figure — a sharper stability statistic than the distance of any single bandwidth.

spread_pct <- if (n_bw > 0 && abs(tau) > 1e-12)
    100 * (max(bw_df$estimate) - min(bw_df$estimate)) / abs(tau) else NA_real_
  main_sig <- is.finite(tau_p) && tau_p < 0.05
  if (n_bw < 2) {
    bw_short <- "not testable"
    bw_verdict <- "Fewer than two bandwidths could be estimated on this data, so the estimate&#x27;s stability across bandwidths could not be checked."
  } else if (n_bw >= 4 && n_bw_sig == 1) {
    bw_short <- "fragile"
    bw_verdict <- sprintf("The jump reaches significance at exactly one of the %d bandwidths tried and at none of the others; across the grid the estimate runs from %s to %s. An effect that appears at a single bandwidth and disappears at every neighbouring one is not an effect, it is a bandwidth artefact, and it should not be reported as a finding.",
                          n_bw, r3(min(bw_df$estimate)), r3(max(bw_df$estimate)))
  } else if (!sign_consistent) {
    bw_short <- "unstable"
    bw_verdict <- sprintf("The estimate changes sign across the %d bandwidths tried, from %s to %s. A discontinuity whose direction depends on how wide a window you look through is not identified by this data.",
                          n_bw, r3(min(bw_df$estimate)), r3(max(bw_df$estimate)))
  } else if (main_sig && n_bw_sig >= ceiling(0.8 * n_bw) &&
             is.finite(spread_pct) && spread_pct <= 25) {
    bw_short <- "stable"
    bw_verdict <- sprintf("Across %d bandwidths from %s to %s the estimate stays between %s and %s — a spread of %s of the headline figure — and %d of the %d confidence intervals exclude zero. The finding does not depend on the bandwidth the rule chose.",
                          n_bw, rs(h * min(mults)), rs(min(h * max(mults), h_max)),
                          r3(min(bw_df$estimate)), r3(max(bw_df$estimate)),
                          paste0(r2(spread_pct), "%"), n_bw_sig, n_bw)
  } else if (!main_sig && n_bw_sig == 0) {
    bw_short <- "consistently null"
    bw_verdict <- sprintf("No bandwidth among the %d tried produces a confidence interval that excludes zero, so the absence of a jump is not an artefact of the bandwidth either. The estimates run from %s to %s.",
                          n_bw, r3(min(bw_df$estimate)), r3(max(bw_df$estimate)))
  } else {
    bw_short <- "sensitive"
    bw_verdict <- sprintf("The estimate moves with the bandwidth: %d of the %d confidence intervals exclude zero and the estimates range from %s to %s%s. Treat the headline number as one point in that range rather than a settled quantity.",
                          n_bw_sig, n_bw, r3(min(bw_df$estimate)), r3(max(bw_df$estimate)),
                          if (is.finite(spread_pct))
                            sprintf(", a spread of %s of the headline figure", paste0(r2(spread_pct), "%"))
                          else "")
  }

Step 10: The locality statement — repeated every run, in the user's names

locality <- sprintf("Whatever this estimates, it estimates it only at %s = %s. It describes units sitting right at that threshold and says nothing about %s for units far above or below it — a regression discontinuity buys credibility at the cutoff by giving up everything else.",
                      running_h, rs(cutoff), outcome_h)
Your data has more stories to tell.Run any analysis on your own data — validated R modules, interactive reports, AI insights, and PDF export. 500 free credits on signup.
Try Free — No SignupSign Up Free

Cite this analysis

Report an Issue

Tell us what's wrong. You'll get a free re-run of this analysis so you can try again with different parameters. If the re-run still doesn't meet your expectations, we'll refund your credits.

Want to run this analysis on your own data? Upload CSV — Free Analysis See Pricing