--- title: "Crossover at a Milestone" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Crossover at a Milestone} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} editor_options: markdown: wrap: 72 --- ```{r, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, cache.path = 'cache/crossoverAtMilestone/', comment = '#>', dpi = 300, out.width = '100%' ) ``` ```{r setup, echo = FALSE, message = FALSE} library(TrialSimulator) ``` The [dynamic treatment switching](https://zhangh12.github.io/TrialSimulator/articles/dynamicTreatmentSwitching.html) vignette describes crossover that is decided *at enrollment*: every patient's switching rule is fixed when they enter the trial, off their own (fully simulated) outcome trajectory. That covers crossover-at- progression, rescue medication, and similar patient-level designs. This vignette covers a different situation: crossover that becomes available only **at, or after, a milestone**, and is therefore a decision the trial makes once it reaches that milestone. Typical examples include: - a promising efficacy signal is seen at an interim analysis, so control patients who are still on study are, on ethical grounds, offered the experimental treatment; - after a futile dose is dropped at an interim, patients on that dose are switched to the retained dose; - a protocol amendment opens crossover from a fixed calendar time onward. The defining feature is that switching is anchored to a **calendar time** that is not known until the trial runs (it may be event-driven), and it applies only to patients who are still in the trial when that time is reached. Already-observed outcomes must not change; only the future may. `TrialSimulator` supports this through the member function `trial$crossover()`. `crossover()` reuses the same `what()` / `when()` / `how()` interface as `regimen()`, so this vignette assumes familiarity with the three-function design described in the [dynamic treatment switching](https://zhangh12.github.io/TrialSimulator/articles/dynamicTreatmentSwitching.html) vignette. Here we focus on what is specific to a milestone crossover: the opening time, how the eligible population is determined, and how the package validates the triplet you supply. ## Where `crossover()` is called Unlike `add_regimen()`, which must be registered *before* `add_arms()` and is applied at enrollment, `crossover()` is meant to be called **inside the action function of a milestone**: ```{r eval=FALSE} action <- function(trial){ locked_data <- trial$get_locked_data('interim') ## ... interim analysis / decision making ... trial$crossover(what = what_fn, how = how_fn) } interim <- milestone(name = 'interim', when = eventNumber(endpoint = 'pfs', n = 300), action = action) ``` You do not compute the crossover time yourself. `crossover()` reads the trial's current time internally and sets the earliest crossover time `T` for you (see the next section) -- there is no need to call `trial$get_current_time()` or to pass any time. For context: when a milestone is triggered, `TrialSimulator` advances the trial's current time to that milestone's (calendar) trigger time and automatically stores a locked snapshot of the data as of that time. This storing happens *whether or not* the action calls `get_locked_data()`, which is why a later milestone's action can still retrieve a snapshot taken at an earlier milestone. `get_locked_data()` does not edit the evolving trial data; it only returns a time-cut view of it. Inside an action, `trial$get_current_time()` therefore returns the current milestone's time -- the same value `crossover()` uses as the basis for `T`. (Calling `crossover()` before any milestone has fired, when the current time is still `0`, is rejected with an informative error.) The user-friendly wrapper `crossover(trial, ...)` is also available for those who prefer not to call member functions directly: ```{r eval=FALSE} crossover(trial, what = what_fn, how = how_fn) ``` ## The earliest crossover time: `delay` and the opening time The only milestone-specific argument is `delay`. From it, `crossover()` computes the **earliest crossover time** -- the calendar time from which switching may take effect: ``` T = (current milestone time) + delay ``` You set only `delay`; the milestone time is supplied automatically. - `delay = 0` (default): crossover opens *at* the milestone. This is the common "let eligible patients switch now" case. - `delay > 0`: crossover opens a fixed amount of time *after* the milestone. This is convenient for an administrative ramp-up or a wash-out window before switching becomes effective. `T` is the reference time for everything that follows: it determines who is eligible, it is the *floor* for the switching time, and it is the boundary that separates already-observed outcomes (which are protected) from future outcomes (which `how()` may modify). Note that `T` is the *earliest* time a switch may take effect, not necessarily when it does. Under the default timing every selected patient switches exactly at `T`, but a `when()` you supply may place each patient's switch at any time at or after `T` -- for instance at a progression that occurs after the opening. ## How the eligible population is determined A milestone crossover only makes sense for patients who still have something left to change. Before calling `what()`, the package therefore restricts the candidate pool to patients who are **still in the trial at `T`**, i.e. patients with at least one endpoint whose value has not yet been settled by `T`. Concretely, for each patient the package checks whether any endpoint is still *open*, using the reference time `ref = max(T, enroll_time)` (so that not-yet-enrolled patients are handled correctly too): - a time-to-event endpoint is open when `enroll_time + min(event_time, dropout_time) > ref`, i.e. neither the event nor dropout has occurred by `ref`; - a non-time-to-event endpoint is open when `enroll_time + readout > ref` **and** the reading will actually be taken, that is `readout <= dropout_time` and the reading falls within the trial duration. A baseline endpoint (`readout = 0`) is measured at enrollment and is therefore never open. A patient enters the pool passed to `what()` if **any** of their endpoints is open. Patients who have died, dropped out, or completed all of their readouts by `T` are excluded automatically -- they have no post-`T` outcome that a crossover could alter. Eligibility does not depend on the treatment arm; restricting the switch to particular arms is the job of `what()`. This mirrors the way `regimen()` "figures out patients who are eligible for the three functions"; the difference is only the reference time `T`. The three functions then receive progressively smaller data frames, which keeps the logic clean and avoids unnecessary computation: - `what()` receives the **eligible** patients; - `when()` receives the patients that `what()` actually selected for switching; - `how()` receives those same switchers, with their assigned `switch_time`. All three functions share the same signature: each takes the data frame `patient_data` and returns a data frame. ```{r eval=FALSE} what(patient_data) # -> patient_id, new_treatment when(patient_data) # -> patient_id, switch_time how(patient_data) # -> patient_id, ``` Importantly, the `patient_data` they receive is already the filtered subset shown below; there is no way to reach a non-eligible patient from inside the triplet, because the package controls the input. This both prevents accidental logic errors and avoids unnecessary computation. ```{r funnel, echo=FALSE, fig.width=7.2, fig.height=4.2, out.width="95%", fig.alt="Funnel: trial_data is filtered to eligible patients (passed to what()), then to switchers (passed to when()), then to switchers with their switch_time (passed to how())."} op <- par(mar = c(0, 0, 0, 0)) plot.new(); plot.window(xlim = c(0, 10), ylim = c(0, 10)) cx <- 5 yb <- c(9.3, 7.1, 4.9, 2.7, 0.6) # band boundaries (top -> bottom) wt <- c(3.4, 2.6, 1.8, 1.0) # band top widths wd <- c(2.6, 1.8, 1.0, 0.5) # band bottom widths for (i in 1:4) { polygon(cx + c(-wt[i], wt[i], wd[i], -wd[i]) / 2, c(yb[i], yb[i], yb[i + 1], yb[i + 1]), col = "#e8eef5", border = "#33618f", lwd = 1.4) } ymid <- (head(yb, -1) + tail(yb, -1)) / 2 left <- c("all patients\n(trial_data)", "eligible\n(open endpoint at T)", "switchers\n(what() selects)", "+ switch_time") text(cx - 2.0, ymid, left, adj = 1, cex = 0.76) fn <- c("what()", "when()", "how()") for (i in 1:3) { arrows(cx + wt[i + 1] / 2 + 0.1, ymid[i + 1], cx + 2.0, ymid[i + 1], length = 0.07, col = "#33618f") text(cx + 2.1, ymid[i + 1], paste0("passed to ", fn[i]), adj = 0, cex = 0.82) } par(op) ``` To make timing rules easy to express, the package injects two helper columns into `patient_data` for the triplet: - `earliest_crossover_calendar_time`, equal to `T`; - `earliest_crossover_time_from_enrollment`, equal to `max(T - enroll_time, 0)`, i.e. the earliest admissible `switch_time` measured from enrollment. These let `when()` express, for example, "switch at progression, but not before crossover opens": ```{r eval=FALSE} time_selector <- function(patient_data){ data.frame( patient_id = patient_data$patient_id, switch_time = pmax(patient_data$pfs, patient_data$earliest_crossover_time_from_enrollment) ) } ``` If `when()` is not supplied, patients switch at the opening time `T` by default (`switch_time = earliest_crossover_time_from_enrollment`). ## How the package monitors the triplet's output Because the triplet is user-supplied, `TrialSimulator` validates what each function returns and stops with an informative error when an input would corrupt the simulation. This lets you develop the three functions incrementally and catch mistakes early (a `browser()` inside any of them is a convenient way to debug). **`what()`** must return a data frame with columns `patient_id` and `new_treatment`, with one row per switching patient. Patients you leave out simply do not switch, so the returned set may be smaller than the input. The package checks that: - the result is a data frame containing the required columns; - no `patient_id` is duplicated; - every selected patient belongs to the eligible pool. Because `what()` is only ever shown eligible patients, selecting a patient outside that pool indicates a logic error and is rejected. `new_treatment` is a free-form label; it does not need to be an existing arm. The randomized `arm` column is never changed -- the label is recorded only in the patient's switching history (see below). **`when()`** must return a data frame with columns `patient_id` and `switch_time`, measured from enrollment. The package checks that: - the result is a data frame containing the required columns; - no `patient_id` is duplicated; - a non-`NA` `switch_time` is provided for *every* patient selected by `what()`; - the switch does not predate the opening time, i.e. `enroll_time + switch_time >= T` for every switcher. A milestone crossover cannot take effect before it opens, so an earlier time is rejected. **`how()`** must return a data frame with `patient_id` and the endpoint columns it modifies. You return only the endpoints you change, and within an endpoint you change just the cells that should change and return the rest at their original value -- the natural `ifelse(condition, new_value, original)` pattern used in the examples. The package checks that: - the result is a data frame with a `patient_id` column, no duplicated ids, and only columns that exist in the trial data; - protected columns (`arm`, `enroll_time`, `dropout_time`, and the internal switching-history column) are not modified; - **only post-switch outcomes are altered.** Returning a value that differs from the original for a cell whose readout or event falls at or before the patient's `switch_time` -- a pre-switch or already-observed outcome -- is rejected. This guarantees that the crossover never rewrites history: outcomes observed before the switch are preserved exactly, and the data locked at the milestone is left intact. In practice this means a `how()` for a milestone crossover guards the post-switch region explicitly, for example: ```{r eval=FALSE} data_modifier <- function(patient_data){ data.frame( patient_id = patient_data$patient_id, ## extend only the residual (post-switch) survival; leave os unchanged ## for patients whose event is at or before the switch os = ifelse(patient_data$os > patient_data$switch_time, patient_data$switch_time + 1.2 * (patient_data$os - patient_data$switch_time), patient_data$os) ) } ``` As with `regimen()`, never apply dropout or censoring manually in `how()`; `TrialSimulator` re-applies dropout and calendar censoring automatically after the crossover and when locking data at later milestones. ## Developing the triplet The most convenient way to develop the three functions is to put a `browser()` at the top of each, register them through `crossover()` inside the milestone action, and run the trial. Execution then pauses *inside* your function, with the actual `patient_data` in scope -- the eligible pool for `what()`, and the selected switchers for `when()` and `how()`. You can inspect the available columns (including the injected helper columns), try your selection, timing, and update logic on real data, and confirm the package handed you the subset you expected. ```{r eval=FALSE} what <- function(patient_data){ browser() # pause here with the eligible pool in scope switchers <- patient_data[patient_data$arm == 'control', ] data.frame(patient_id = switchers$patient_id, new_treatment = 'experimental') } ``` Comment out `browser()` before production runs; it is in any case a no-op in non-interactive sessions, so it never interferes with vignette building or parallel workers. ## A worked example The following runs a small two-arm trial end to end. Control patients who are still on study at an interim are offered the experimental treatment; each switches at progression, but no earlier than the moment crossover opens, and only their post-switch survival is extended. We first define the triplet and the action, using the full `crossover()` signature: ```{r} what <- function(patient_data){ ## return only the patients who switch (here, everyone on control) switchers <- patient_data[patient_data$arm == 'control', ] data.frame( patient_id = switchers$patient_id, new_treatment = 'experimental' ) } when <- function(patient_data){ data.frame( patient_id = patient_data$patient_id, switch_time = pmax(patient_data$pfs, patient_data$earliest_crossover_time_from_enrollment) ) } how <- function(patient_data){ data.frame( patient_id = patient_data$patient_id, os = ifelse(patient_data$os > patient_data$switch_time, patient_data$switch_time + 1.3 * (patient_data$os - patient_data$switch_time), patient_data$os) ) } crossover_action <- function(trial){ trial$get_locked_data('interim') # interim decision making can go here ## delay = 0 opens crossover at the interim; use delay > 0 for a wash-out trial$crossover(what = what, when = when, how = how, delay = 0) } ``` The remaining set-up (endpoints, arms, enrollment, the milestones, and the run) is ordinary `TrialSimulator` code and is hidden here for brevity. ```{r echo=FALSE} os_e <- endpoint(name = 'os', type = 'tte', generator = rexp, rate = log(2) / 12) pfs_e <- endpoint(name = 'pfs', type = 'tte', generator = rexp, rate = log(2) / 6) control <- arm(name = 'control', pfs <= os); control$add_endpoints(pfs_e, os_e) trt <- arm(name = 'trt', pfs <= os); trt$add_endpoints(pfs_e, os_e) trial <- trial(name = 'demo', n_patients = 200, seed = 123, duration = 36, enroller = StaggeredRecruiter, accrual_rate = data.frame(end_time = Inf, piecewise_rate = 15), silent = TRUE) trial$add_arms(sample_ratio = c(1, 1), control, trt) lst <- listener(silent = TRUE) lst$add_milestones( milestone(name = 'interim', when = calendarTime(time = 12), action = crossover_action), milestone(name = 'final', when = calendarTime(time = 36)) ) invisible(controller(trial, lst)$run(n = 1, silent = TRUE, plot_event = FALSE)) ``` After the run, each control switcher carries a recorded switching history in the locked data. The recorded switch times show the timing rule at work: a patient whose progression falls after the opening switches at progression (a larger `@` time), while a patient who would have progressed earlier is held at the opening, so the recorded time equals `T - enroll_time` -- the `earliest_crossover_time_from_enrollment` that `when()` used: ```{r} final <- trial$get_locked_data('final') head(final[final$arm == 'control', c('patient_id', 'arm', 'regimen_trajectory', 'n_switches')]) ``` ## Scenarios **Delayed opening (wash-out).** Supplying `delay` opens crossover after the milestone, which is useful when switching cannot be implemented immediately: ```{r eval=FALSE} trial$crossover(what = what, how = how, delay = 2) # opens 2 time units later ``` **Custom timing.** Provide `when()` to switch at a clinically meaningful time rather than at the opening, using the injected helper column to respect the opening floor (see the `time_selector` example above). **Patients enrolled after the milestone.** A milestone crossover also applies to patients who enroll later: they switch according to the same triplet once they are in the trial. There is nothing extra to do. **Several crossovers.** `crossover()` can be called in more than one milestone. Each call stacks an additional switching opportunity, applied in chronological order, so a patient may cross over more than once. ## Relationship to `add_regimen()` A milestone crossover and an enrollment-time `regimen()` are the same machinery under the hood: both are sequences of `what()` / `when()` / `how()` triplets, each with an earliest crossover time. The enrollment regimen is simply the special case where that time is `0` (switching is open from the first enrollment); `crossover()` appends a triplet whose earliest time is the milestone time plus `delay`. The eligibility filter, the timing floor, and the post-switch protection therefore behave consistently across both entry points. ## Inspecting the switches Every switch is recorded in the `regimen_trajectory` column shown above, as a compact `"arm@0;new_treatment@switch_time"` string. Use `expandRegimen()` to expand it into one row per regimen segment per patient for summaries and checks (continuing the worked example above): ```{r} head(expandRegimen(final)) ```