Using tters with TrialEmulation

tters reproduces the sequential trial-emulation data expansion of TrialEmulation bit-for-bit, in a memory-safe Rust + Polars engine. Rust owns the deterministic data transformation; R keeps statistical estimation. That split is exposed two ways: standalone expansion functions, and a drop-in te_datastore backend for a TrialEmulation pipeline.

Install

install.packages("tters",
  repos = c("https://oldschoolcool2.r-universe.dev", "https://cloud.r-project.org"))

No Rust toolchain is needed to install the binary build from r-universe.

Standalone: expand a cohort in memory

The *_df functions take a data.frame and return one, with no intermediate files. Column dtypes are preserved exactly (including 64-bit bit64::integer64 ids).

library(tters)

# long person-time: one row per (id, period)
cohort <- data.frame(
  id        = c(1L, 1L, 1L, 2L, 2L),
  period    = c(0L, 1L, 2L, 0L, 1L),
  treatment = c(1L, 1L, 0L, 0L, 1L),
  eligible  = c(1L, 0L, 0L, 1L, 0L),
  outcome   = c(0L, 0L, 1L, 0L, 0L)
)

expanded <- expand_trial_df(cohort, estimand = "ITT")
head(expanded)

A Parquet-in / Parquet-out path (expand_trial()), pre-computed-weight (expand_trial_weighted()) and in-Rust weight-fitting (fit_trial_weights(), expand_trial_weighted_fitted()) variants exist too — see their help pages and the package reference.

As a TrialEmulation backend

Set up a trial_sequence() exactly as you would for TrialEmulation::expand_trials(), then run the expansion in Rust by pointing the output at save_to_tters() and calling expand_trials_tters(). Everything downstream — sampling, fit_msm() — is unchanged and bit-identical.

library(TrialEmulation)
library(tters)
data("data_censored")

trial <- trial_sequence("ITT") |>
  set_data(data = data_censored) |>
  set_censor_weight_model(
    censor_event = "censored", numerator = ~x2, denominator = ~ x2 + x1,
    pool_models = "numerator",
    model_fitter = stats_glm_logit(save_path = tempfile())
  ) |>
  calculate_weights() |>                    # weight MODELS fit in R
  set_outcome_model(adjustment_terms = ~x2) |>
  set_expansion_options(output = save_to_tters(), chunk_size = 0)

trial <- expand_trials_tters(trial)         # the EXPANSION runs in Rust
trial <- load_expanded_data(trial, seed = 1234, p_control = 0.5)
trial <- fit_msm(trial)                     # estimation stays in R

The produced frame is byte-equivalent to TrialEmulation::expand_trials() (structural columns bit-exact, weight to within machine precision), so load_expanded_data(), sample_controls(), and fit_msm() behave identically. If TrialEmulation or the Rust build is unavailable — or for the not-yet- supported “as treated” estimand — expand_trials_tters() falls back to TrialEmulation::expand_trials() with a message.