# Specify Actions to Execute at Trial Milestones

In `TrialSimulator`, we can define custom action functions that will be executed automatically whenever a trial milestone is reached. This mechanism provides a flexible way to simulate adaptive and dynamic trial behaviors, such as:

- Conducting interim analyses

- Early stopping for efficacy or futility

- Dropping, adding or selecting treatment arms

- Resizing based on sample size reassessment

- extending trial duration

- Adjusting enrollment rules

- Logging or tracking intermediate results

- Locking and exporting trial data snapshot at specific points

This vignette demonstrates how to define such actions, associate them with milestones, and make use of them to enrich trial simulations.

## Define Trial Milestones

A milestone represents a pre-specified time point or condition during the trial at which a snapshot of accumulated data becomes available for custom analysis. Each milestone consists of three key elements:

- `name`: the label of the milestone. A data snapshot at the triggering time can be retrieved by calling `trial$get_locked_data(<name>)`, where `<name>` is a placeholder of `name`.

- `when`: the logical conditions that trigger the milestone. Refer to the vignette Condition System for Triggering Milestones in a Trial for more information.

- `action`: a function to be executed once the milestone is reached. Within this function, we can analyze data snapshot, make decisions, and store analysis results for later summary.

For example, consider an interim analysis that is triggered once the trial has been running for at least 20 months and at least 450 events have occurred for the endpoint `PFS`.

    interim <- milestone(name = 'interim analysis',
                         action = doNothing,
                         when = calendarTime(time = 20) &
                           eventNumber('PFS', n = 450)
                         )

Note that in this example, `action` is set to the default `doNothing` function. In such cases, `TrialSimulator` will not perform additional computations but will still record the triggering time of the milestone. This can be useful when the sole interest is in timing of interim analysis given recruitment and dropout assumptions.

Importantly, whenever a milestone is reached, `TrialSimulator` automatically records a set of standard outputs, even when `doNothing` is used. These are summarized in the following table.

Automatically Saved Results At Triggered Milestones
Type of Results | Format | Column Naming Convention | Examples
--- | --- | --- | ---
Time of triggering a milestone | scalar | `milestone_time_<...>` where `...` is milestone name | `milestone_time_<interim>`
Number of observed events for a time-to-event endpoint | scalar | `n_events_<...1>_<...2>` where `...1` is milestone name and `...2` is endpoint name | `n_events_<interim>_<PFS>`
Number of observed non-missing readouts for a non-TTE endpoint | scalar | `n_events_<...1>_<...2>` where `...1` is milestone name and `...2` is endpoint name | `n_events_<final>_<ORR>`
Number of enrolled patients | scalar | `n_events_<...>_<patient_id>` where `...` is milestone name | `n_events_<interim>_<patient_id>`
Number of observed events/readouts per arm for a TTE/non-TTE endpoint | data frame | `n_events_<...>_<arms>` where `...` is milestone name | `n_events_<final>_<arms>`

Note that these automatically saved columns can be eliminated from outputs by setting `tidy = TRUE` in `controller$get_output()`. Currently, we use regex `"^n_events_<.*?>_<.*?>$"` and `"^milestone_time_<.*?>$"` to match columns to be eliminated. If users plan to use `tidy = TRUE`, caution is needed when naming #’ custom outputs in `save()`.

We can define arbitrary number of milestones in a trial. For example, the final analysis might be triggered when both `PFS` and `OS` events reach their required numbers:

    final <- milestone(name = 'final analysis',
                       action = doNothing,
                       when = eventNumber('PFS', n = 800) &
                         eventNumber('OS', n = 500))

Here the trial is considered complete once sufficient events are observed for both endpoints to ensure targeted statistical power.

Note that a milestone is only triggered if

- its `when` condition is satisfied, and

- the milestone has been registered to a `listener`.

The following code snippet shows how to register milestones and run a trial with a controller. You may have seen it in many of the documents of this package:

    listener <- listener()
    #' register milestones with listener
    listener$add_milestones(interim, final)
    controller <- controller(trial, listener)
    controller$run()

## Custom Action Functions

In most realistic scenarios, we do not simply want to record the timing of a milestone and number of events/readouts. Instead, we want to analyze the data snapshot available at that time and possibly take actions based on the results. To achieve this, we define a custom action function and assign it to the `action` argument of a milestone. A custom action function must always takes `trial` as its first argument, which is returned from the function `trial()`.

The first step inside every action function should be calling `trial$get_locked_data()` to retrieve data snapshot using milestone name. After this, we are free to perform any analyses or decision-making steps.

For example, the following action function illustrates how one might analyze survival data at an interim milestone. Here, hazard ratios are estimated from proportional hazard models, and p-values (or other statistics) can also be calculated if desired.

    action_at_interim <- function(trial){

      locked_data <- trial$get_locked_data('interim analysis')

      ## write your own codes to fit coxph model on locked_data
      ## extract estimate and p-value of hazard ratio

      ## no return value is needed in an action function

    }

Once the functions is defined, it can be passed to the `action` argument of a milestone:

    interim <- milestone(name = 'interim analysis',
                         action = action_at_interim,
                         when = calendarTime(time = 20) &
                           eventNumber('PFS', n = 450)
                         )

Thus, whenever the milestone is triggered, the action function will be executed.

Within a custom action function, we can go beyond simple data access. The main possibilities include

- performing statistical analyses;

- saving intermediate results;

- altering the trial through adaptation.

Each of these topics will be discussed in the following sections.

### Perform statistical analyses on data snapshot

Statisticians rarely stop at just locking the data–we want to analyze it!

A milestone provide an opportunity to perform formal analysis on the accumulated data, guiding decisions such as continuing, adapting, or stopping the trial.

Let’s revisit the interim milestone. The following example shows how one might compute hazard ratios for two active arms (low and high dose) compared against placebo. The action function extracts the snapshot of patient-level data, fits separate Cox proportional hazards models, and then computes the hazard ratios.

    action_at_interim <- function(trial){

      locked_data <- trial$get_locked_data('interim analysis')

      ## write your own codes to fit coxph model on locked_data
      fit_high <- coxph(Surv(PFS, PFS_event) ~ arm,
                        data = locked_data %>%
                          filter(arm %in% c('pbo', 'high'))
                        )

      fit_low <- coxph(Surv(PFS, PFS_event) ~ arm,
                       data = locked_data %>%
                         filter(arm %in% c('pbo', 'low'))
                       )

      hr <- data.frame(high = exp(coef(fit_high)),
                       low = exp(coef(fit_low)))

      ## write more codes to compute one- or two-sided p-values, etc.

    }

This function illustrates the flexibility of action functions: any valid R code can be executed at the milestone. However, writing such functions from scratch may become cumbersome and error-prone, especially when:

- multiple treatment arms exist,

- one-sided tests are required, or

- different trial scenarios have varying arm names or endpoints

To reduce coding burden and promote standardization, `TrialSimulator` provides a collection of helper functions for common statistical analyses. These wrappers ensure consistent inputs and outputs, making downstream use (such as saving or combining results) much easier.

Here is a rewritten version of the interim action function using these helper functions:

    action_at_interim <- function(trial){

      locked_data <- trial$get_locked_data('interim analysis')

      fit_PFS <- fitCoxph(Surv(PFS, PFS_event) ~ arm,
                          placebo = 'pbo',
                          data = locked_data,
                          alternative = 'less',
                          scale = 'hazard ratio') # also also request other scales

      fit_OS <- fitLogrank(Surv(OS, OS_event) ~ arm,
                           placebo = 'pbo',
                           data = locked_data,
                           alternative = 'less',
                           biomarker1 == 'positive' &
                             biomarker2 == 'negative') # specify subgroup through ...

    }

In this example,

- `fitCoxph` estimates hazard ratio for `PFS` against placebo, with a one-sided test.

- `figLogrank` performs logrank test for `OS`, restricted to a biomarker-defined subgroup through the argument `...` in helper functions.

Both helper functions return results in a standardized format, which integrates seamlessly with other utilities of `TrialSimulator`. This is particularly useful when saving outputs across milestones or performing multiplicity adjustments.

For more complete description of these helper functions for common statistical analyses, see the vignette Wrapper Functions of Common Statistical Methods in TrialSimulator.

At this stage, we are only calculating statistics. In the next section, we will show how to save these results for later use—either to summarize operating characteristics or to carry information forward into future milestones.

### Save intermediate results

`TrialSimulator` offers dedicated member functions for saving intermediate results during a trial. The stored information can be broadly classified into two categories:

- Trial output—results that directly contribute to the summary of trial operating characteristics (e.g., effect estimates, test decisions) or anything that we need for each simulated trial. They are stored in a private single-row data frame within the `trial` object. We cannot access or modify it directly. Instead, they should be inserted and accessed using member functions `trial$save()` and `trial$get_output()`. Since outputs are stored in a single-row data frame, only scalars or single-row data frame are accepted by `trial$save()`

- auxiliary information—results that may be useful in subsequent analysis (e.g., in action functions of other milestones). Any results that are not directly part of trial-level summaries should be considered as auxiliary information. These are stored using member function `trial$save_custom_data()`. To retrieve it later, simply call `trial$get()`. This mechanism provides a safe way to pass data across milestones without relying on global variables. Unlike `trial$save()`, `trial$save_custom_data()` can save any objects, which provides flexibility.

In addition, a convenience function `trial$bind()` is provided for sequentially appending rows to a stored data frame—useful when accumulating results across milestones.

Here we give more details of those member functions. We can also refer to their manuals by running `?Trials` in R console.

- `save(value, name)`: saves a scalar or a single-row data frame into the trial’s output.

  - later retrieved with `trial$get_output(cols = name)`.

  - calling `trial$get_output()` without argument returns all saved outputs available so far.

- `save_custom_data(value, name)`: saves an object of any type as auxiliary information as a temporary and private list within the trial.

  - retrieved later by calling `trial$get(name)`.

- `bind(value, name)`: a special case of `save_custom_data` for data frames.

  - If the named data frame already exists, the new `value` is row-bound to it. Otherwise, a new data frame is created and saved.

  - retrieved later by `trial$get_custom_data(name)`.

  - particularly useful for accumulating results across multiple milestones in group sequential designs. For example, we may want to save estimates, p-values, etc. across milestones to a data frame, so that we can test it with a graphical testing strategy when all p-values are collected (i.e. at the last milestone).

#### Example 1: saving hazard ratio and p-value

Below, the interim action function computes a hazard ratio `hr` and its p-value `pval`, then saves them as part of the trial output:

    action_at_interim <- function(trial){

      locked_data <- trial$get_locked_data('interim analysis')

      ## fit coxph model on locked_data
      ## extract estimate and p-value of hazard ratio
      ## assume that hr is estimate, and pval is p-value
      trial$save(value = hr, name = 'pfs_interim_hazard_ratio')
      trial$save(value = pval, name = 'pfs_interim_p_value')

      ## values of hr and pval can be accessed anywhere later by calling
      ## trial$get_output(cols = 'pfs_interim_hazard_ratio')
      ## trial$get_output(cols = 'pfs_interim_p_value')
      ## trial$get_output(cols = c('pfs_interim_hazard_ratio', 'pfs_interim_hazard_ratio'))
      ## If a colum specified in cols does not exist (e.g., typo in codes),
      ## an informative error message will be prompted.

    }

#### Example 2: passing complex objects between milestones

Sometimes we want to carry more complex information forward, such as lists of results, which cannot be stored in the single-row output. In this case, we use `save_custom_data()`.

    action_at_interim <- function(trial){

      locked_data <- trial$get_locked_data('interim analysis')

      ## fit coxph model on locked_data
      ## extract estimate and p-value of hazard ratio
      ## assume that hr is estimate, and pval is p-value
      trial$save(value = hr, name = 'pfs_interim_hazard_ratio')
      trial$save(value = pval, name = 'pfs_interim_p_value')

      ## assume that we also compute other values that may be useful in later milestone,
      ## we save them in a list and call
      more_results <- list(numeric = 1,
                           string = 'abc',
                           list = list(e1 = 100, e2 = 'AA'))

      trial$save_custom_data(value = more_results, name = 'more_results')
      ## value of more_results can be accessed anywhere later by calling
      ## tmp <- trial$get(name = 'more_results')

      ## no return value is needed in an action function

    }

In the final analysis, we can then retrieve both the saved trial outputs and the auxiliary information:

    action_at_final <- function(trial){

      locked_data <- trial$get_locked_data('final analysis')

      ## extract outputs
      pfs_hr_interim <- trial$get_output(cols = 'pfs_interim_hazard_ratio')
      pfs_hr_pval <- trial$get_output(cols = 'pfs_interim_p_value')

      ## extract auxiliary information
      custom_list <- trial$get('more_results')

    }

#### Example 3: defining auxiliary information before the trial runs

All member functions discussed in this section can be called before any milestone is triggered, as long as the `trial` object has been created by calling the function `trial()`. For example, we may want to define the level of family-wise error rate (FWER) once, and then use it later in interim and final analysis:

    trial <- trial(...) ## initialize a trial with necessary arguments in ...
    trial$add_arms(sample_ratio = c(1, 2, 1), pbo, low, high)

    trial$save_custom_data(value = 0.025, name = 'fwer')

This auxiliary information can then be retrieved in later action functions to adjust boundaries or multiplicity procedures:

    action_at_interim <- function(trial){

      locked_data <- trial$get_locked_data('interim analysis')

      ## compute p-values of PFS and OS at interim
      ## then extract FWER
      alpha <- trial$get(name = 'fwer')

      ## compute decision boundaries at interim based on FWER
      ## then test PFS and OS

      ## save testing results for interim
      # trial$save(...)

    }

    action_at_final <- function(trial){

      locked_data <- trial$get_locked_data('final analysis')

      ## compute p-values of PFS and OS at final
      ## then extract FWER and testing result at interim to adjust boundaries
      alpha <- trial$get(name = 'fwer')
      # interim_results <- trial$get_output(...)

      ## test PFS and OS again

      ## save testing results for final
      # trial$save(...)

    }

In summary, `TrialSimulator` provides a structured saving system that allows users to:

- record scalar results directly into trial outputs;

- carry forward complex objects across milestones, and

- accumulate data frames across multiple milestones.

This ensures smooth information flow between milestones and avoids ad-hoc workarounds such as global variables.

### Alter a trial via adaptation

Beyond analyzing data and saving results, one of the most powerful uses of action functions is the ability to adapt the ongoing trial.

Adaptation means that trial features—such as randomization ratios, sample size, trial duration, or active arms—can be modified in response to accumulated data. This reflects how many modern clinical trials are designed in practice, aiming to increase efficiency, ethical and cost balance.

In `TrialSimulator`, such adaptations are implemented directly within action functions. At a milestone, after retrieving the locked data and performing statistical analyses, the action function can call methods of the `trial` object to alter its internal state. These changes will then influence the subsequent course of the trial simulation.

Adaptations that are currently supported are summarized below:

Adaptation | Member Function | Use Case
--- | --- | ---
Remove an arm | `trial$remove_arms()` | dose selection; seamless design
Add an arm | `trial$add_arms()` | adaptive platform trials
Update sample ratio | `trial$update_sample_ratio()` | response-adaptive design
Extend trial duration | `trial$set_duration()` or add a event-driven milestone | actual patient or event accrual is slower than expected
Increase sample size1 | `trial$resize()` | sample size reassessment
Eliminate sub-population | `trial$update_generator()` | enrichment design; data model changes over time

Note that functions in the table above are member functions of `Trials` object. Users who are not familiar with the concept of classes may consider using their wrapper functions. The wrapper function of `trial$foo(...)` is `foo(trial, ...)`.

These adaptive features allow users to simulate complex, data-driven designs where trial conduct is not static but evolves in response to accumulating evidence. At the time of writing, the vignette dedicated to trial adaptation is still under development. Detailed examples will be provided in that vignette.

1. experimental↩︎