Executive Summary
Did crossing this dem vote share = 0.5000 change next dem vote share?
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.
Analysis Overview
Regression discontinuity in next dem vote share at this dem vote share = 0.5000.
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
How the cutoff was set, what was dropped, and how the rows split.
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.
Outcome Against the Running Variable
Binned means with the local linear fit either side of the cutoff.
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.
The Discontinuity Estimate
The cutoff, the bandwidth, the effective sample, and the jump with its interval.
| Measure | Estimate | Detail |
|---|---|---|
| Cutoff on this dem vote share | 0.5 | The cutoff was supplied as 0.5000 on this dem vote share. |
| Bandwidth | 0.1591 | The 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 bandwidth | 7385 | 7,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 cutoff | 0.453 | The left-hand local linear line extrapolated to this dem vote share = 0.5000 |
| Fitted next dem vote share just above the cutoff | 0.55 | The right-hand local linear line extrapolated to this dem vote share = 0.5000 |
| Discontinuity estimate at the cutoff | 0.0966 | 95% CI 0.087 to 0.106; p < 0.001; statistically significant (p < 0.001) |
| Standard error | 0.0048 | Heteroskedasticity-robust (HC1) around the weighted local linear fit |
| Effect relative to the level just below | 21.29 | The jump as a percentage of the fitted next dem vote share just below the cutoff (0.453) |
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.
Manipulation of the Running Variable
Density test for sorting across the cutoff in this dem vote share.
| Measure | Estimate | Detail |
|---|---|---|
| Density of this dem vote share just below the cutoff | 1.59 | Local 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 cutoff | 1.773 | Local 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.115 | 1.00 means no pile-up on either side |
| Log density difference | 0.1088 | The quantity actually tested — zero under no manipulation |
| z statistic | 1.376 | Log difference divided by its standard error (0.0791) |
| p-value | 0.1688 | 0.169 — no detectable pile-up on either side |
| Histogram bin width | 0.0039 | 2 times the spread of this dem vote share divided by the square root of the row count (McCrary's rule) |
| Bins used each side | 34 | 17 below and 17 above, inside a density bandwidth of 0.0675 |
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.
Covariate Balance at the Cutoff
Do pre-determined columns jump at the threshold as well?
| Covariate | Jump | Std Error | P Value | Rows Used | Verdict |
|---|---|---|---|---|---|
| (no covariates mapped) | — | — | n/a | 0 | not tested — map pre-determined columns to run this check |
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.
Bandwidth Sensitivity
The estimate and its interval recomputed across a range of bandwidths.
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.
Method & Assumptions
The design, the identification choices, the diagnostics, and the limits.
| Item | Detail |
|---|---|
| Design | Sharp 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. |
| Cutoff | The cutoff was supplied as 0.5000 on this dem vote share. |
| Estimator | Local 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. |
| Bandwidth | The 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. |
| Inference | Heteroskedasticity-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 sample | 7,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 test | The 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 balance | 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. |
| Bandwidth sensitivity | Across 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 you | 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. |
| Key assumptions | Units 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. |
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.
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 '%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 '%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 '%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 '%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 '%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("'%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 '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'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'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)