Introduction to docketscape: Legal Network and Congestion-Aware Delay Modeling

Shraddha Ajit and DK Janani

2026-10-28

1. Motivation

Empirical research on judicial court delay often struggles to separate two fundamentally different phenomena:

  1. What a case is — its statutory complexity and substantive nature, determined by the Acts and Sections invoked.
  2. Where the case sits — the institutional congestion and docket queue of the specific court at the time of filing.

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: docketscape is 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.


2. Input Data Structures

The package works with two primary tabular inputs:

  1. Case Records (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.
  2. Legal Section Records (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))

3. Case Cleaning and Censoring Handling (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     TRUE

5. Profile Diffusion (diffuse_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 6

6. Case Similarity Graph (build_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 --30

Mutual connections are symmetrized to form an undirected similarity graph.


7. Neighbourhood Survival Experience (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.1195892

The 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"
)


8. Relative Lateness (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.


9. Court Docket Load (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    6

Each 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.


10. Cox Delay Model (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)\]

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.


11. Delay Decomposition (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.01893813

Absolute contribution shares are calculated on component magnitudes. They describe partitions of the fitted linear predictor and are not causal attributions.


12. Leakage-Aware Temporal Validation (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.


13. Methodological Limitations

  1. Observational: Delay models describe statistical correlations, not causal mechanisms.
  2. No Judge Rankings: The package explicitly rejects and does not implement judge ranking, scoring, or efficiency metrics.
  3. No Malfeasance Inference: Slower resolution relative to neighbours reflects timeline disparity, not judicial misconduct.
  4. Data Dependence: Findings depend on the accuracy and completeness of underlying institutional records.
  5. Censoring Assumptions: Non-informative right censoring is assumed. Systematic causes of prolonged pendency may violate this assumption.