tters is the R companion package that exposes the tte-expand Rust core crate to R via extendr. It reproduces, bit-for-bit, the sequential trial-emulation data expansion of the TrialEmulation R package, with a Parquet path, an in-memory data.frame path, and a TrialEmulation companion backend (see ../../ROADMAP.md).
Installation
install.packages("tters",
repos = c("https://oldschoolcool2.r-universe.dev", "https://cloud.r-project.org"))No Rust toolchain is required to install from r-universe (binary builds are provided). Building from source needs Cargo / rustc >= 1.95.
How it fits together
bindings/tters/
├── DESCRIPTION, NAMESPACE # R package metadata
├── R/ # R wrappers (extendr-wrappers.R is generated)
├── configure[.win], cleanup[.win] # R CMD INSTALL build hooks
├── tools/ # config.R / msrv.R (toolchain checks)
└── src/
├── Makevars.in, Makevars.win.in, entrypoint.c, tters-win.def
└── rust/ # a DETACHED Cargo workspace
├── Cargo.toml # extendr-api + path dep on ../../../../crates/tte-expand
└── src/lib.rs # #[extendr] FFI shims
The Rust crate under src/rust/ declares an empty [workspace] table, making it its own workspace root with its own Cargo.lock. This is required so R CMD INSTALL builds a self-contained, reproducible (CRAN-vendorable) crate. It is excluded from the repo-root workspace (exclude = ["bindings/tters"] in the root Cargo.toml) and is therefore not linted by the main CI --workspace clippy job — extendr’s macro-expanded FFI would otherwise trip pedantic lints.
Companion backend for TrialEmulation
tters plugs into upstream TrialEmulation’s te_datastore extension API so a trial_sequence() pipeline runs the expensive expansion in Rust while estimation (sampling + the marginal structural model) stays in R, consuming the Rust output bit-identically to the default path. This is Suggests-level and opt-in: TrialEmulation is never an Imports, and the backend is a no-op for anyone who does not call it. Validated against TrialEmulation v0.0.4.11.
Set up the trial sequence exactly as for TrialEmulation::expand_trials(), then call expand_trials_tters() instead — everything downstream is unchanged:
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 RThe 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. The split is deliberate: Rust owns the deterministic data transformation; R owns statistical estimation. The per-period weight wt computed by calculate_weights() is read verbatim — Rust performs only its deterministic accumulation. If TrialEmulation (or the Rust build) is unavailable, or for the not-yet-supported AT estimand, expand_trials_tters() falls back to TrialEmulation::expand_trials() with a message (fallback = FALSE to force the Rust path).
Regenerating with rextendr
These files mirror what rextendr produces. To (re)scaffold or refresh after changing the Rust signatures, from this directory in R:
# install.packages("rextendr")
rextendr::document() # regenerates R/extendr-wrappers.R + NAMESPACE
# or, to (re)create the binding scaffold from scratch:
# rextendr::use_extendr()Verify pinned versions before relying on this scaffold.
extendr-apiis pinned to0.9andConfig/rextendr/versionto0.5.0from research that could not be confirmed against crates.io offline. Runrextendr::document()andcargo update(with network) to reconcile to the installed versions.
Caveats for distribution
-
Path dependency vs. CRAN.
cargo vendordoes not vendor path dependencies, andR CMD buildonly tarballs files underbindings/tters/. Before any CRAN submission, either publishtte-expandto a registry and switch to a version dependency, or add a build step that copiescrates/tte-expand/into the package. The vendored Polars tree is also large; r-universe / source install is the realistic channel. -
Two lockfiles. Commit both the root
Cargo.lockandsrc/rust/Cargo.lock(Dependabot tracks the latter at/bindings/tters/src/rust).