Empirical research on judicial court delay often struggles to separate two fundamentally different phenomena:
Traditional empirical analyses frequently aggregate diverse cases into broad categories (e.g. “Criminal” or “Civil”), overlooking intricate statutory differences. Furthermore, ongoing matters that remain pending at the observation cutoff are often improperly excluded or treated as missing values, introducing substantial survivor bias.
docketscape addresses these challenges by: -
Constructing statutory co-occurrence graphs and diffusing legal profiles
across provisions. - Estimating censoring-aware expected resolution
timelines from legally similar historical peers using weighted
Kaplan-Meier curves. - Quantifying baseline court docket load at filing.
- Modeling observational resolution hazards via Cox proportional hazards
regression and decomposing fitted linear predictors. - Auditing and
preventing future-information leakage during temporal holdout
validation.
Methodological Boundary:
docketscapeis an observational modeling tool designed for case-level records structured similarly to the Development Data Lab (DDL) Indian lower-court database. It contains no empirical DDL records and uses simulated synthetic data throughout. It does not evaluate judicial efficiency, rank individual judges, establish causal mechanisms of delay, or determine whether an individual case was mishandled.
The package works with two primary tabular inputs:
cases): A table
containing at least case_id, filing_date, and
decision_date (with NA for pending cases),
plus optional administrative identifiers such as
court.sections): A
table linking each case to its cited statutory provisions, containing
case_id, act, and section.All data structures in this vignette are generated synthetically for demonstration:
Let us create a representative synthetic dataset:
library(docketscape)
set.seed(123)
n_cases <- 30
cases_raw <- data.frame(
case_id = 1:n_cases,
court = sample(c("Court_A", "Court_B"), n_cases, replace = TRUE),
filing_date = sample(seq.Date(as.Date("2012-01-01"), as.Date("2013-03-01"), by = "month"), n_cases, replace = TRUE),
duration_days = round(runif(n_cases, 60, 500)),
disposed = rbinom(n_cases, 1, 0.70),
stringsAsFactors = FALSE
)
# Pending cases have NA for decision_date
cases_raw$decision_date <- as.Date(NA)
cases_raw$decision_date[cases_raw$disposed == 1] <-
cases_raw$filing_date[cases_raw$disposed == 1] + cases_raw$duration_days[cases_raw$disposed == 1]
# Simulate legal sections
set.seed(42)
acts <- c("NI Act", "IPC", "CrPC")
secs <- list("NI Act" = c("138", "141"), "IPC" = c("420", "406"), "CrPC" = c("154", "482"))
sec_rows <- lapply(1:n_cases, function(i) {
n_s <- sample(1:2, 1)
act_sample <- sample(acts, n_s, replace = TRUE)
sec_sample <- sapply(act_sample, function(a) sample(secs[[a]], 1))
data.frame(case_id = i, act = unname(act_sample), section = unname(sec_sample), stringsAsFactors = FALSE)
})
sections_raw <- unique(do.call(rbind, sec_rows))clean_cases)clean_cases() standardises filing and decision dates,
evaluates durations relative to an observation cutoff, and flags invalid
records (such as decisions preceding filing or filings after cutoff)
without silent deletion.
cleaned <- clean_cases(cases_raw, observation_end = "2014-12-31")
head(cleaned[, c("case_id", "court", "duration", "event", "is_valid")])
#> case_id court duration event is_valid
#> 1 1 Court_A 229 1 TRUE
#> 2 2 Court_A 181 1 TRUE
#> 3 3 Court_A 418 1 TRUE
#> 4 4 Court_B 257 1 TRUE
#> 5 5 Court_A 698 0 TRUE
#> 6 6 Court_B 417 1 TRUEevent = 1 and duration equal to
elapsed days to disposition.event = 0 (right-censored) and
duration equal to follow-up from filing to
observation_end.build_section_graph)Sections cited within the same case reflect statutory co-occurrence.
build_section_graph() constructs an undirected graph where
nodes represent Act::Section pairs and edge weights count
distinct cases citing both provisions:
sec_graph <- build_section_graph(sections_raw)
print(sec_graph)
#> IGRAPH f88c94e UNW- 6 10 --
#> + attr: name (v/c), act (v/c), section (v/c), identifier (v/c), weight
#> | (e/n)
#> + edges from f88c94e (vertex names):
#> [1] CrPC::154 --IPC::406 CrPC::154 --IPC::420 CrPC::154 --NI Act::138
#> [4] CrPC::154 --NI Act::141 CrPC::482 --IPC::420 IPC::406 --IPC::420
#> [7] IPC::406 --NI Act::138 IPC::420 --NI Act::138 IPC::420 --NI Act::141
#> [10] NI Act::138--NI Act::141diffuse_profiles)Raw citation vectors are sparse. A case citing Section 420 shares statutory affinities with Section 406 if those sections frequently co-occur in other matters.
diffuse_profiles() applies one-step weighted profile
diffusion over the row-stochastic transition matrix \(P = D^{-1} A\):
\[X_{\text{raw}} = \alpha X_{\text{init}} + (1 - \alpha) X_{\text{init}} P\]
diffused <- diffuse_profiles(sections_raw, sec_graph, alpha = 0.7)
print(diffused)
#> Diffused Case Legal Profiles
#> ============================
#> Number of cases: 30
#> Number of sections: 6
#> Alpha parameter: 0.7
#> Sparse matrix size: 30 x 6build_case_graph)build_case_graph() computes pairwise cosine similarities
across IDF-weighted diffused vectors and retains the top-\(k\) nearest neighbours for each case,
breaking ties deterministically by case_id:
case_graph <- build_case_graph(diffused, k = 3, use_idf = TRUE)
print(case_graph)
#> IGRAPH f8abe9b UNW- 30 63 --
#> + attr: name (v/c), case_id (v/c), weight (e/n)
#> + edges from f8abe9b (vertex names):
#> [1] 1 --16 1 --17 1 --18 1 --22 1 --25 1 --6 1 --7 10--11 10--13 10--20
#> [11] 10--26 10--29 11--13 11--14 11--15 11--19 11--20 11--27 12--14 12--18
#> [21] 12--23 12--28 13--14 13--15 13--19 13--20 13--27 15--27 16--17 16--25
#> [31] 17--25 2 --11 2 --13 2 --15 21--26 21--28 22--28 23--26 23--29 24--26
#> [41] 24--30 26--29 26--30 3 --11 3 --13 3 --19 4 --12 4 --21 4 --22 4 --28
#> [51] 5 --10 5 --26 5 --29 6 --16 6 --25 7 --12 7 --18 8 --11 8 --13 8 --14
#> [61] 9 --24 9 --26 9 --30Mutual connections are symmetrized to form an undirected similarity graph.
neighbourhood_survival)For any focal case, an expected resolution timeline is estimated from its legally similar neighbours using weighted Kaplan-Meier estimation:
\[\hat{S}(t) = \prod_{t_{(j)} \le t} \left(1 - \frac{\sum_{i \in \mathcal{D}_j} w_i}{\sum_{l \in \mathcal{R}_j} w_l}\right)\]
where weights \(w_i\) correspond to graph edge similarity weights. Censored neighbours remain in the risk set until their censoring time, avoiding bias against long-running matters.
km_focal <- neighbourhood_survival(case_graph, cleaned, case_id = 1)
print(km_focal)
#> Neighbourhood Survival Estimate
#> ===============================
#> Target Case ID: 1
#> Target Status: disposed
#> Target Duration: 229 days
#> Valid Neighbours: 7
#> Median Survival: 410 days
#>
#> Estimated Survival Table:
#> time n_at_risk n_events surv
#> 1 0 6.095324 0.0000000 1.0000000
#> 2 109 6.095324 0.8010361 0.8685819
#> 3 215 5.294288 0.7643170 0.7431879
#> 4 330 4.529971 1.0000000 0.5791277
#> 5 410 3.529971 0.8010361 0.4477095
#> 6 417 2.728935 1.0000000 0.2836493
#> 7 493 1.728935 1.0000000 0.1195892The peer survival curve can be visualized directly using standard base R graphics without any extra graph or plotting packages:
surv_tab <- km_focal$survival_table
plot(
surv_tab$time, surv_tab$surv,
type = "s",
lwd = 2,
col = "darkblue",
xlab = "Time (days)",
ylab = "Estimated Survival Probability S(t)",
main = paste("Neighbourhood Survival Curve for Case", km_focal$target_case_id),
ylim = c(0, 1)
)
abline(h = 0.5, lty = 2, col = "grey50")
if (!is.na(km_focal$target_duration)) {
abline(v = km_focal$target_duration, col = "firebrick", lty = 3, lwd = 2)
}
grid()
legend(
"topright",
legend = c("Peer Survival S(t)", "Median Threshold (50%)", "Target Case Duration"),
col = c("darkblue", "grey50", "firebrick"),
lty = c(1, 2, 3),
lwd = c(2, 1, 2),
bty = "n"
)relative_lateness)Relative lateness evaluates the focal case duration against the peer survival curve:
\[\text{relative\_position} = 1 - S(T)\]
where \(T\) is the focal case duration.
lateness <- relative_lateness(km_focal)
print(lateness)
#> Relative Case Lateness
#> ======================
#> Target Case ID: 1
#> Target Status: disposed
#> Target Duration: 229 days
#> Valid Neighbours: 7
#> Estimable: TRUE
#> Median Neighbourhood Survival: 410 days
#> Survival at Target Duration: 0.7432
#> Relative Position: 0.2568 (25.7th percentile)
#>
#> Interpretation:
#> Retrospectively, at its resolution duration of 229 days, the case was older than approximately 25.7% of the weighted legally similar resolution experience.If the focal duration exceeds the maximum follow-up among neighbours,
docketscape flags the metric as unestimable
(estimable = FALSE) rather than extrapolating beyond
observed data.
court_load)court_load() calculates monthly open docket volume
across courts:
load_df <- court_load(cleaned, by = "month")
head(load_df)
#> Court Docket Load Summary
#> =========================
#> Number of courts: 1 (Court_A)
#> Date range: 2012-01-01 to 2012-06-01
#> Min load: 0
#> Max load: 6
#>
#> Head of court_load data:
#> court date load
#> 1 Court_A 2012-01-01 0
#> 2 Court_A 2012-02-01 0
#> 3 Court_A 2012-03-01 1
#> 4 Court_A 2012-04-01 1
#> 5 Court_A 2012-05-01 5
#> 6 Court_A 2012-06-01 6Each case’s load_at_filing is aligned to the first day
of its calendar month (format(filing_date, "%Y-%m-01")),
reflecting initial queue volume.
fit_delay_model)Case resolution hazard is modeled using semi-parametric Cox proportional hazards regression:
\[\lambda(t \mid X) = \lambda_0(t) \exp\left(\beta_{\text{legal}} \cdot \text{legal\_score}_i + \beta_{\text{load}} \cdot \text{load\_at\_filing}_i\right)\]
legal_score: Mean similarity weight of incident edges
in the case graph.load_at_filing: Monthly court docket volume at case
filing.fit <- fit_delay_model(cleaned, case_graph, load_df)
print(fit)
#> Docketscape Delay Model (Cox Proportional Hazards)
#> =================================================
#> Status: Cox proportional hazards model fitted successfully.
#> Observations: 30 included, 0 excluded
#> Events: 22 resolutions observed
#>
#> Hazard Ratios & 95% Confidence Intervals:
#> term hazard_ratio conf_low conf_high
#> 1 legal_score 2.419567 0.01367269 428.17501
#> 2 load_at_filing 1.014968 0.90821479 1.13427
#>
#> Coefficient Estimates:
#> term estimate std_error statistic p_value
#> 1 legal_score 0.88358872 2.64083592 0.3345868 0.7379368
#> 2 load_at_filing 0.01485743 0.05670094 0.2620314 0.7932972
#>
#> Proportional Hazards Assumption Test (cox.zph):
#> chisq df p
#> legal_score 1.76 1 0.18
#> load_at_filing 2.34 1 0.13
#> GLOBAL 3.93 2 0.14
#>
#> Note: Hazard ratios quantify observational baseline associations with resolution hazard;
#> they do NOT represent causal delay impacts or judicial performance metrics.A hazard ratio \(\exp(\beta) > 1\) denotes a higher resolution rate (faster resolution). All coefficients reflect observational associations.
decompose_delay)decompose_delay() partitions the fitted linear predictor
into legal and docket load components:
\[\eta_i = \beta_{\text{legal}} \cdot \text{legal\_score}_i + \beta_{\text{load}} \cdot \text{load\_at\_filing}_i\]
decomp <- decompose_delay(fit)
if (fit$fit_success) {
head(decomp$case_decomposition, 3)
}
#> case_id legal_score load_at_filing legal_component load_component
#> 1 1 0.8707606 10 0.7693942 0.14857429
#> 2 2 1.0000000 14 0.8835887 0.20800401
#> 3 3 0.8710696 1 0.7696672 0.01485743
#> total_linear_predictor absolute_legal_share absolute_load_share
#> 1 0.9179685 0.8381488 0.16185119
#> 2 1.0915927 0.8094491 0.19055093
#> 3 0.7845247 0.9810619 0.01893813Absolute contribution shares are calculated on component magnitudes. They describe partitions of the fitted linear predictor and are not causal attributions.
validate_models)In longitudinal court records, constructing a graph over pooled cross-sectional data creates future-neighbour leakage: cases filed in 2012 can be connected to cases filed in 2014.
docketscape enforces a strict temporal information
boundary: 1. Cases are split chronologically by
(filing_date, case_id). 2. The section graph, diffusion
matrix, and IDF weights are computed only on training
cases. 3. Validation cases are projected into the training
representation space via project_validation_legal_score(),
acquiring only training cases as neighbours. 4.
Validation cases never form neighbours with each
other.
val <- validate_models(fit, sections = sections_raw, temporal_split = 0.7)
print(val)
#> Docketscape Delay Model Temporal Validation
#> ===========================================
#> Status: Temporal validation completed successfully.
#> Available: TRUE
#>
#> ==================================================
#> LEAKAGE-AWARE TEMPORAL VALIDATION
#> ==================================================
#>
#> Training cases: 21
#> Validation cases: 9
#>
#> Training future-neighbour edges: 0
#> Training future-neighbour cases: 0
#>
#> Naive validation C-index: 0.4861
#> Leakage-aware validation C-index: 0.5000
#>
#> Validation cases using validation neighbours: 0
#> Validation cases using training neighbours: 9
#>
#> Unseen-section validation cases: 0
#>
#> Statistical Safeguard:
#> Temporal validation evaluates how well the fitted model generalises to later-filed cases. Validation metrics describe predictive performance and do not establish causal relationships.The package also provides auditing functions: -
check_future_neighbours() to audit temporal ordering across
graph edges. - check_court_overlap() to inspect court
distributions across splits. - check_near_duplicates() to
inspect same-court/date clusters.