Short course
Simulating Modern Clinical Trial in R
A Versatile Framework for Complex and Adaptive Trial Designs
Empowering clinical trial design with simulations
Han Zhang
Innovative Statistical Science, Astellas
zhangh12.github.io/TrialSimulator
2026 NIC-ASA and ICSA Midwest Chapter Joint Fall Meeting
October 15, 2026 · Northbrook, IL
TrialSimulator hex logo
Before we start
Install TrialSimulator
From CRAN
Prebuilt binary on Windows and macOS
install.packages("TrialSimulator")
From GitHub
Development version, built from source
if(!requireNamespace("remotes", quietly = TRUE)){   install.packages("remotes") }   remotes::install_github(   "zhangh12/TrialSimulator",   dependencies = TRUE,   upgrade = "never" )
Needs a compiler: Rtools on Windows, Xcode Command Line Tools on macOS.
QR code linking to the TrialSimulator website
Copy the code from the package website
zhangh12.github.io/TrialSimulator
Disclaimer: The views and opinions expressed in this presentation are those of the presenter and do not necessarily reflect the official policy or position of Astellas Pharma Inc. or any of its affiliates.
Why simulate · FDA guidance, 2019
Adaptive designs: FDA expects simulation
Cover of the FDA guidance Adaptive Designs for Clinical Trials of Drugs and Biologics, November 2019
Adaptive Designs for Clinical Trials of Drugs and Biologics Guidance for Industry FDA (CDER, CBER), November 2019 §VI.A Simulations in Adaptive Design Planning, p. 18
Excerpt of the guidance page (ad19_a.png)
⋯
Excerpt of the guidance page (ad19_b.png)
Why simulate · FDA draft guidance, 2026
Bayesian designs: simulate widely, show the code
Cover of the FDA draft guidance Use of Bayesian Methodology in Clinical Trials of Drug and Biological Products, January 2026
Use of Bayesian Methodology in Clinical Trials of Drug and Biological Products Draft Guidance for Industry FDA (CDER, CBER), January 2026 §IV.B.1, p. 8 · §VIII.A, p. 22
Excerpt of the guidance page (by26_a.png)
§VIII.A Documenting plans for Bayesian analyses · p. 22
Excerpt of the guidance page (by26_b.png)
Why simulate · operating characteristics
Crossover after a promising interim
One OS trial, two looks
600 patients, true HR 0.7. Final at 330 deaths, interim at 30%, 50% or 70% of them. Efficacy is tested at both looks, ITT; the trial never stops at the interim.
If the interim is promising (observed HR below 0.8), control patients who progress are offered the experimental drug; those who accept live their remaining survival on its hazard. Otherwise everybody stays on the randomized arm.
deaths so far
165 (50%)
330 (100%)
Cumulative deaths over calendar time in one simulated trial
control patients of this trial
on control
progression
death
switched to experimental
death had it not switched
1
dies before the interim
2
progressed before the interim
3
progresses after the interim
4
declines the switch
5
enrolls after the interim
0
6
12
18
24
30
36
42
calendar time (months)
interim · 165 deaths (50%)
final · 330 deaths
observed HR < 0.8: open crossover otherwise: no crossover
← crossover opens
Two looks. The interim reads 165 deaths (50%), the final 330. Efficacy is tested at both; the trial never stops at the interim.
At the interim: a promising result (observed HR below 0.8) opens crossover; otherwise nobody switches.
Control patients on study at the interim, and those enrolled later, can be offered the switch when they progress.
60% accept. They switch at progression, or at the interim if they progressed before it; their remaining survival follows the experimental hazard.
ITT: switchers still count as control at the final look. How much power is left?
Why simulate · operating characteristics
Crossover after the interim drains the final look
One OS trial, two looks
600 patients, true HR 0.7. Final at 330 deaths, interim at 30%, 50% or 70% of them. Efficacy is tested at both looks, ITT; the trial never stops at the interim.
If the interim is promising (observed HR below 0.8), control patients who progress are offered the experimental drug; those who accept live their remaining survival on its hazard. Otherwise everybody stays on the randomized arm.
interim look
final look (if not won at the interim)
100%
50%
0%
interim at 30% of deaths
32
53
86%
0%
32
49
81%
30%
32
42
75%
60%
32
34
66%
90%
interim at 50% of deaths
54
32
86%
0%
54
29
83%
30%
54
26
79%
60%
54
22
75%
90%
interim at 70% of deaths
71
16
87%
0%
71
15
86%
30%
71
13
84%
60%
71
11
82%
90%
share of offered control patients who accept the switch
Interim power (32%, 54%, 71%) is the same in every column: crossover only starts after the interim.
The final look pays. With an interim at 30%, overall power falls from 86% to 66% as crossover reaches 90%.
Without crossover the interim timing barely matters (86–87%); with 90% crossover it does: 66% vs 82%. No boundary formula shows this.
Why simulate · more designs
Two more choices that only simulation can price
Graphical testing in a group sequential design
Futility on ORR, efficacy on OS
H1 PFS · biomarker+
H2 PFS · all-comers
H3 OS · biomarker+
H4 OS · all-comers
initial α₁
initial α₂
initial 0
initial 0
α₁ + α₂ = α; each hypothesis has its own two-look boundary
Arrows of the testing graph
γ
γ
1 − γ
1 − γ
1
1
Which split α₁/α₂, which γ? Power of each hypothesis depends on the PFS–OS correlation, the biomarker prevalence and when each look comes: simulate every candidate.
Interim when 200 patients have a 3-month ORR: stop if the ORR difference (experimental − control) is below a bound. Final OS log-rank at 380 deaths. Which bound?
Effective drug: ORR 35% vs 20%, OS median 16 vs 12 months. Futile drug: 20% and 12 months in both arms. ORR and OS correlated. 2,000 trials each.
100%
50%
0%
-5%
0
+5%
+10%
+15%
futility bound on the ORR difference
no futility look: 82%
Overall OS power for each futility bound
82
82
79
68
45
overall OS power, effective drug
Chance of stopping a futile drug at the interim for each futility bound
17
47
79
95
99
stops a futile drug at the interim
Bound 0: stops 47% of futile trials for 0.5 points of power. +10%: stops 95%, but costs 14 points.
Why it is hard · simulation code
Simulation code is easy to start and hard to trust
generate_trial_data <- function(...){   ... } analyze_trial_data <- function(...){   ... } arg1 <- ... ... argk <- ... run_simulation <- function(n, ...){   for(i in 1:n){     dat <- generate_trial_data(...)     res <- analyze_trial_data(dat, ...)     # update output with res   }   summarize(output) }
The usual hand-written simulation
1  Data generation by hand
Several endpoints and arms, enrollment, dropout, and the data cut at every look: when do 150 events happen, who is censored then. Re-coded for each design.
2  Analysis coded look by look
Each look needs its own data cut, tests, estimates and boundaries (alpha spending, multiplicity), written and checked by hand.
3  A long list of parameters
Distributions, boundaries and timings as loose arguments with ad hoc names: hard to manage and to vary across scenarios.
4  Hard to reuse, check and submit
A small design change means a new script; a reviewer has to read all of it. The FDA 2026 draft asks for the simulation code in the report.
Two-way arrow: data generation and analysis depend on each other
5  Adaptation: the two functions cannot be decoupled
In a complex adaptive design, the data still to be generated depend on the analysis of the data already observed. An interim result can drop an arm, change the allocation ratio, add patients or narrow the population, and the remaining patients are generated under the new design.
generate patients up to the look
analyze the data at the interim
adapt the design: arms, ratio, size
Cycle: generate, analyze, adapt, and back to generating the remaining patients
changes how the remaining patients are generated
This feedback is the adaptation. Inside one replicate, generation and analysis call each other; they cannot be two functions run one after the other.
Why it is hard · existing tools
Existing tools: validated, but closed or narrow
Commercial platforms
East Horizon product image
FACTS logo
East Horizon (Cytel) · FACTS (Berry) · ADDPLAN
+  Broad, validated, familiar to regulators.
−  Closed engines. Your R code plugs only into vendor-defined integration points, and which ones you get depends on the design and endpoint. Licensed.
Validated open-source packages
gsDesign hex logo
gsDesign2 hex logo
rpact hex logo
gsDesign · gsDesign2 · rpact · simtrial
+  Open, tested, widely used for group sequential designs.
−  Design-first. gsDesign and gsDesign2 compute designs analytically; rpact simulates one endpoint type per function through fixed hooks; simtrial focuses on time-to-event.
Method-centric packages
Header of the CRAN Task View Clinical Trial Design, Monitoring, Analysis and Reporting
e.g. MAMS, adaptr, and the CRAN Task View
+  Each solves its own design family well.
−  One family each. “The majority of the provided software is very specialized” (Meyer et al., Trials 2021).
The gap: an open, general engine where any design is assembled from parts, every patient is simulated, and the code reads like the protocol.
TrialSimulator
A general engine, written as building blocks
Patient level
Every patient has an arm, an enrollment time, a dropout time and all endpoints.
Declarative
Endpoints, arms, milestones and actions are defined once and stacked into a design.
Customizable
Any generator, any analysis at a milestone, adaptations as trial methods.
Validated
2,500+
unit tests
88%
code coverage
20+
papers proposing new designs: their simulation tables, reproduced
Parallel
controller$run(n, n_workers)
spreads replicates over cores.
targets
pipeline: recommended for large studies
Readable
Code that reads like the protocol: easier to train, to review, and to generate with AI.
Next: common designs, and what they share.
Designs · fixed and group sequential
Sequential: a fixed design with an interim look
treatment
placebo
0
12
24
36
48 mo
interim
if futile, the trial ends here
final
Final analysis · month 36
Triggered when
400 OS events observed
What happens
Lock the data; final test; record the result
Adaptation
none: the trial carries on unchanged
Interim analysis · month 20
Triggered when
200 OS events observed
What happens
Lock the data at month 20; compare the arms (log-rank)
Adaptation
If futile: end the trial now
One look, at the end: this is the fixed design.
Add one interim look and the fixed design becomes group sequential.
Designs · seamless dose selection
Seamless: pick the dose, keep the trial
placebo
low dose
high dose
0
12
24
36 mo
dose selection
dropped
interim
final
Dose selection · month 9
Triggered when
300 surrogate readouts available
What happens
Lock the data; compare both doses on the surrogate
Adaptation
Drop the weaker dose: high dose stops
Interim analysis · month 18
Triggered when
300 PFS events observed
What happens
Lock the data; log-rank test; futility check (non-binding)
Adaptation
none: the trial carries on unchanged
Final analysis · month 30
Triggered when
1,000 enrolled + 300 OS events + (month 28 or 520 PFS events)
What happens
Lock the data; closed testing of the surviving dose
Adaptation
none: the trial carries on unchanged
The surviving dose is chosen by the data, not fixed in advance.
Designs · dose ranging
Dose ranging: open new arms mid-trial
dose 0.0
dose 4.0
dose 0.5
dose 1.5
dose 2.5
0
6
12
18
24 mo
interim
final
Interim analysis · month 8
Triggered when
30 readouts from the two open arms
What happens
Lock the data; go/no-go on the highest dose
Adaptation
Open three new dose arms
Final analysis · month 20
Triggered when
150 readouts across all arms
What happens
Lock the data; dose-response decision
Adaptation
none: the trial carries on unchanged
Arms can be added while the trial is running.
Designs · adaptive enrichment
Enrichment: narrow the population mid-trial
trt · BM+
trt · BM−
pbo · BM+
pbo · BM−
0
10
20
30
40 mo
stage 1 cohort · patient_id ≤ 80
interim
BM− enrollment closes
stage 1
follow-up frozen at 22
final
Interim analysis · month 16
Triggered when
45 PFS events among the first 80 patients
What happens
Lock the data; conditional power in the full population and in BM+
Adaptation
Enroll BM+ patients only Slow accrual to 5 per month Enlarge the sample size Re-target the final analysis
Stage 1 analysis · month 22
Triggered when
65 PFS events among the first 80 patients
What happens
Lock the data; stage 1 p-values
Adaptation
Stop following the first 80 patients
Final analysis · month 38
Triggered when
Re-targeted BM+ events, and 65 events in the first 80 patients
What happens
Lock the data; combine stages; closed test
Adaptation
none: the trial carries on unchanged
One interim decision changes who enrolls, how fast, how many, and when the trial ends.
Designs · basket
Basket: each disease closes on its own
trt · D1
pbo · D1
trt · D2
pbo · D2
trt · D3
pbo · D3
0
12
24
36 mo
interim 1
D2 closed
interim 2
D3 closed
final
Interim 1 · month 12
Triggered when
60 response readouts
What happens
Lock the data; response rate per basket
Adaptation
Close basket D2
Interim 2 · month 20
Triggered when
120 response readouts
What happens
Same analysis, same rule
Adaptation
Close basket D3
Final analysis · month 32
Triggered when
200 response readouts
What happens
Lock the data; test every basket still open
Adaptation
none: the trial carries on unchanged
Baskets close independently, by the same rule at every look.
Designs · response-adaptive randomization
Response-adaptive: shift patients to better doses
dose 0
dose 20
dose 25
dose 30
dose 35
bar thickness = share of new patients
0
6
12
18
24 mo
stage 1
stage 2
final
Stage 1 · month 7
Triggered when
50 FEV1 readouts
What happens
Lock the data; estimate the dose response
Adaptation
Re-weight randomization toward better doses; placebo keeps 20%
Stage 2 · month 13
Triggered when
120 FEV1 readouts
What happens
Same estimate on more data
Adaptation
Re-weight again
Final analysis · month 20
Triggered when
200 FEV1 readouts
What happens
Lock the data; multiple contrast test
Adaptation
none: the trial carries on unchanged
Allocation is a setting that changes between looks.
From designs to abstractions
Every design asks the same three questions
1 · When?
A look is triggered by one of three things
calendar time
patients enrolled
events or readouts
alone or combined with “and” / “or”
→ a milestone and its condition
2 · What then?
A fixed sequence of steps runs at that moment
lock the data
analyze and decide
record the results
different at every look, in every design
→ an action function on the milestone
3 · What changes?
Some steps rewrite the rest of the trial
drop or add arms re-weight randomization change who, how fast, how many stop follow-up · move the next look
→ adaptation methods in the action
milestone(name, when = <condition>, action = function(trial){ … })
inside the action: lock the data → analyze → record → adapt
Example · sample size re-estimation
Two looks, each with its own trigger
Chart grid
Enrollment, ORR readouts, PFS and OS events for the planned 600 patients
The same curves after the interim re-sizes the trial to 800
The trial ends at the final; later data are not used
Threshold: 200 PFS events
Single ring and guide: 200th PFS event
Threshold: 300 ORR readouts
Single ring and guide: 300th ORR readout
Interim settled at month 21.15
Threshold: 400 OS events
Single ring and guide: 400th OS event
Single ring and guide: month 48
Final settled at month 41.34
0
200
400
600
800
1000
0
12
24
36
48
60
Calendar time (months)
Enrolled
ORR readouts
PFS events
OS events
t sub PFS equals 19.41
t sub ORR equals 21.15
interim · 21.15
t sub OS equals 41.34
t sub cal equals 48
final · 41.34
Interim analysis
Interim analysis · month 21.15
Triggered when
200 PFS events and 300 ORR readouts (read 6 months after entry), whichever comes later
t sub PFS equals 19.41
t sub ORR equals 21.15
max(19.41, 21.15) = 21.15
What happens
Re-estimate the sample size: 600 → 800 patients
Interim analysis · month 21.15
200 PFS events and 300 ORR readouts; sample size 600 → 800
Final analysis
Final analysis · month 41.34
Triggered when
400 OS events or month 48, whichever comes first
t sub OS equals 41.34
t sub cal equals 48
min(41.34, 48) = 41.34
What happens
Lock the data; final test; the trial ends
By hand · the interim
When is the interim? Written by hand
# dat: one row per patient, all 600 generated up front n <- nrow(dat)   # condition 1: 200 PFS events pfs_times <- c() for (i in 1:n) {   if (dat$pfs_event[i] == 1) {     t_i <- dat$enroll_time[i] + dat$pfs[i]     pfs_times <- c(pfs_times, t_i)   } } pfs_times <- sort(pfs_times) if (length(pfs_times) >= 200) {   t_pfs <- pfs_times[200] } else {   t_pfs <- Inf }
# condition 2: 300 ORR readouts, 6 months after entry orr_times <- c() for (i in 1:n) {   if (dat$dropout_time[i] > 6) {     if (!is.na(dat$orr[i])) {       t_i <- dat$enroll_time[i] + 6       orr_times <- c(orr_times, t_i)     }   } } orr_times <- sort(orr_times) if (length(orr_times) >= 300) {   t_orr <- orr_times[300] } else {   t_orr <- Inf }   # both conditions: the interim waits for the later one t_interim <- max(t_pfs, t_orr)
TrialSimulator · the same trigger
when = eventNumber('pfs', n = 200) &        eventNumber('orr', n = 300)
By hand · the final
When is the final? Written by hand
# dat: one row per patient, 800 after the re-sizing # (patients enrolled after the interim were re-generated) n <- nrow(dat)   # condition 1: 400 OS events os_times <- c() for (i in 1:n) {   if (dat$os_event[i] == 1) {     t_i <- dat$enroll_time[i] + dat$os[i]     os_times <- c(os_times, t_i)   } } os_times <- sort(os_times) if (length(os_times) >= 400) {   t_os <- os_times[400] } else {   t_os <- Inf }
# condition 2: month 48 t_cal <- 48   # either condition: the final takes the earlier one t_final <- min(t_os, t_cal)   # ... but never before the interim t_final <- max(t_final, t_interim)
TrialSimulator · the same trigger
when = eventNumber('os', n = 400) |        calendarTime(time = 48)
Condition system · the building blocks
Time and enrollment trigger milestones
Calendar time
calendarTime(time = 6)
The trial has run at least 6 months since the first patient enrolled
Timeline from first patient in to month 6
0
12
24
36
t = 6.0 months
Enrollment
enrollment(n = 520)
At least 520 patients randomized (dashed line)
Cumulative enrollment reaches 520 at month 17.3
0
12
24
36
t = 17.3 months
Each condition resolves to the earliest calendar time it holds; if it can never hold, t = ∞ with a warning.
Condition system · eventNumber()
One function counts events or readouts
Time-to-event endpoint
eventNumber('pfs', n = 340)
Counts observed events: 340 PFS progressions or deaths, after dropout
Cumulative PFS events reach 340 at month 22.1
340
0
12
24
36
t = 22.1 months
Any other endpoint
eventNumber('orr', n = 300)
Counts non-missing readouts: 300 ORR values, each ready 6 months after randomization (gray: enrollment)
ORR readouts trail enrollment by six months and reach 300 at month 16.3
300
0
12
24
36
t = 16.3 months
Same call, two meanings: events for a time-to-event endpoint, non-missing readouts for any other.
Condition system · what gets counted
Count only the arms and patients you mean
Chart grid with a threshold at 340 PFS events
PFS events in all arms reach 340 at month 22.1
PFS events in placebo and high dose reach 340 at month 29.0
PFS events in placebo level off at 283 and never reach 340
PFS events among biomarker-positive patients reach 340 at month 35.9
900
450
0
340
0
12
24
36
48
PFS events · calendar time (months)
22.1
29.0
283 events at most → t = ∞
35.9
all arms
arms = c('pbo', 'high')
arms = 'pbo'
biomarker == 'positive'
eventNumber(   endpoint = 'pfs',   n = 340 )
eventNumber(   endpoint = 'pfs',   n = 340,   arms = c('pbo', 'high') )
eventNumber(   endpoint = 'pfs',   n = 340,   arms = 'pbo' )
eventNumber(   endpoint = 'pfs',   n = 340,   biomarker == 'positive' )
→ t = 22.1
Counted on every arm still in the trial.
→ t = 29.0
Only placebo and high dose count; the low dose is ignored.
→ t = ∞
Placebo alone peaks at 283 events, so the milestone never fires and a warning is raised.
→ t = 35.9
Unnamed arguments in … filter the rows, here on a baseline endpoint: 450 positive patients.
calendarTime(time) enrollment(n, ...,   arms = NULL,   min_treatment_duration = 0) eventNumber(endpoint, n, ...,   arms = NULL)
... keeps the rows matching any dplyr::filter() condition, e.g. on a baseline endpoint.
arms = NULL counts arms still in the trial; an arm you list is counted even after it is removed, with a warning.
Condition system · time after randomization
Treatment exposure and readouts shift the clock
enrollment(n = 400, min_treatment_duration = 3)
400 patients randomized, each treated for at least 3 months
Cumulative enrollment reaches 400 at month 13.3
Exposure of at least 3 months: the same curve shifted right, reaching 400 at month 16.3
900
400
0
0
6
12
18
24
+3 months
t = 13.3 months
t = 13.3 + 3 = 16.3 months
eventNumber('orr', n = 300)  # readout = c(orr = 6)
300 non-missing ORR readouts, each 6 months after randomization
ORR readouts trail enrollment by 6 months and reach 300 at month 16.3
900
300
0
0
6
12
18
24
6-month readout
t = 16.3 months
Dropout counts too: a patient who drops out before an event or readout never adds to the count.
Condition system · vignette recipes
Read a compound condition like a sentence
(enrollment(n = 500) & eventNumber(endpoint = 'os', n = 200)) | calendarTime(time = 12)
“500 patients in and 200 OS events — or 12 months, whichever comes first.”
Timeline: month 12, the 500th patient at 16.6, the 200th OS event at 20.9
month 12
200th OS event · 20.9
500th patient · 16.6
0
36 mo
The milestone fires at month 12; later times are dimmed
min( max(16.6, 20.9), 12 ) = 12
The 12-month cap fires first; neither count is waited for.
calendarTime(time = 6) & (eventNumber('os', n = 320) | eventNumber('pfs', n = 480))
“At least 6 months in, and then either 320 OS or 480 PFS events.”
Timeline: month 6, the 320th OS event at 27.4, the 480th PFS event at 27.9
month 6
320th OS event · 27.4
480th PFS event · 27.9
0
36 mo
The milestone fires at month 27.4
max( 6, min(27.4, 27.9) ) = 27.4
Six months is only a floor; the first of the two event counts decides.
Every recipe plugs straight into milestone(name = 'final', when = …, action = …), and the engine solves it for every replicate.
Summary · trigger conditions
Three functions express every trigger
Function
Waits for
Seen in the designs
calendarTime(time)
time elapsed since the first patient
month-28 cap on the seamless final
enrollment(n, ..., arms = NULL,   min_treatment_duration = 0)
n patients randomized, optionally each treated for a minimum time
1,000 enrolled across all three seamless arms
eventNumber(endpoint, n, ..., arms = NULL)
n events (time-to-event) or n non-missing readouts (others)
200 OS events · 30 readouts · 45 events in the first 80 patients
A & B → the later time  ·  A | B → the earlier time  ·  never reachable → ∞, with a warning
... picks patients with any filter condition; arms picks the arms that count.
TrialSimulator · Core idea
Four building blocks declare a trial
Declare what the trial is — data, triggers, actions. The engine works out how to run it.
os    <- endpoint(name = 'os', type = 'tte',                   generator = rexp, rate = 0.05) trt   <- arm(name = 'trt'); trt$add_endpoints(os) trial <- trial(name = 'demo', n_patients = 600,                enroller = StaggeredRecruiter, ...) trial$add_arms(sample_ratio = c(1, 1), pbo, trt) interim <- milestone(name = 'interim',   when   = eventNumber('os', n = 200),   action = ssr) final   <- milestone(name = 'final',   when   = eventNumber('os', n = 400) |            calendarTime(time = 48),   action = final_test) listener$add_milestones(interim, final) controller(trial, listener)$run(n = 1000)
1
Data
endpoint() · arm()
generator = any function(n, …)
2
Trial
trial() · add_arms()
Enrollment, dropout and allocation are pluggable
3
Milestone
milestone(when, action)
when: A & B, A | B · action: function(trial)
4
Run
listener() · controller()
run(n) replicates → one data frame of results
github.com/zhangh12/TrialSimulator
TrialSimulator · Core idea
Four building blocks declare a trial
Declare what the trial is — data, triggers, actions. The engine works out how to run it.
os    <- endpoint(name = 'os', type = 'tte',                   generator = rexp, rate = 0.05) trt   <- arm(name = 'trt'); trt$add_endpoints(os) trial <- trial(name = 'demo', n_patients = 600,                enroller = StaggeredRecruiter, ...) trial$add_arms(sample_ratio = c(1, 1), pbo, trt) interim <- milestone(name = 'interim',   when   = eventNumber('os', n = 200),   action = ssr) final   <- milestone(name = 'final',   when   = eventNumber('os', n = 400) |            calendarTime(time = 48),   action = final_test) listener$add_milestones(interim, final) controller(trial, listener)$run(n = 1000)
os <- endpoint(name = 'os', type = 'tte',                generator = rexp, rate = 0.05) trt <- arm(name = 'trt') trt$add_endpoints(os)
trial <- trial(name = 'demo',                n_patients = 600,                enroller = StaggeredRecruiter,                ...) trial$add_arms(sample_ratio = c(1, 1),                pbo, trt)
interim <- milestone(name = 'interim',                      when = eventNumber('os', n = 200),                      action = ssr)   final <- milestone(name = 'final',                    when = eventNumber('os', n = 400) |                           calendarTime(time = 48),                    action = final_test)
listener$add_milestones(interim, final) controller(trial, listener)$run(n = 1000)
1
Data
endpoint() · arm()
generator = any function(n, …)
2
Trial
trial() · add_arms()
Enrollment, dropout and allocation are pluggable
3
Milestone
milestone(when, action)
when: A & B, A | B · action: function(trial)
4
Run
listener() · controller()
run(n) replicates → one data frame of results
github.com/zhangh12/TrialSimulator
Every slot takes a function: plug in your own generator, enroller or analysis
Chapter · Endpoints and arms
Endpoints and arms: define, combine, check
Say what each patient's data look like; TrialSimulator draws them, and printing an arm checks them.
endpoint()
One call defines one or more endpoints with any generator whose first argument is n; its other arguments go through ...
type
'tte' (time + event), 'non-tte' (a value at a readout time) or 'baseline' (known at randomization).
arm()
Name the arm, add inclusion criteria if needed, then add_endpoints(). Printing it summarizes 10,000 simulated patients.
Vignettes · scan for details
QR code linking to the vignette Define Time-to-Event Endpoints
Define Time-to-Event Endpoints
defineTimeToEventEndpoints
QR code linking to the vignette Define Non-Time-to-Event Endpoints
Define Non-Time-to-Event Endpoints
defineNonTimeToEventEndpoints
QR code linking to the vignette Define Longitudinal Endpoints
Define Longitudinal Endpoints
defineLongitudinalEndpoints
QR code linking to the vignette Define and Summarize Arms in Clinical Trials
Define and Summarize Arms in Clinical Trials
defineArms
Endpoints and arms · signatures
endpoint() and arm(), checked by printing
endpoint(name, type, readout = NULL, generator, ...) arm(name, ...)
Arguments
endpoint()
name
Name(s) of the endpoint(s); one call can define several.
type
'tte', 'non-tte' (continuous, binary, categorical, repeated measures) or 'baseline' (non-tte, known at randomization).
readout
Named numeric vector: time from randomization to each non-tte value, e.g. c(orr = 2). NULL if all are tte; leave baseline endpoints out.
generator
Random number generator, first argument n. Returns n values, or a data frame of n rows: a column per name, plus <name>_event for each tte endpoint.
...
Arguments passed to generator.
arm()
name
Label of the arm in the generated data, e.g. in get_locked_data(); no '@' or ';'.
...
Inclusion criteria: dplyr::filter-style conditions on the endpoints. None: all generated patients are used.
arm$add_endpoints()
...
One or more objects returned by endpoint().
Viewer
Endpoints (1)
ttp
No
Variable
Stats / Freqs
Graph
1
ttp [time-to-event]
Median time: 6.32 Events: 10000 Missing: 0 (0%)
Kaplan-Meier curve of ttp
2
ttp_event [event indicator]
1: 10000 (100%) Missing: 0 (0%)
bar chart of ttp_event
Viewer
Arm Name: treatment
Endpoints (1):ttp
No
Variable
Stats / Freqs
Graph
1
ttp [time-to-event]
Median time: 6.32 Events: 10000 Missing: 0 (0%)
Kaplan-Meier curve of ttp
2
ttp_event [event indicator]
1: 10000 (100%) Missing: 0 (0%)
bar chart of ttp_event
ttp <- endpoint(name = 'ttp', type = 'tte',                 generator = rweibull,                 shape = 1.5, scale = 8) trt <- arm(name = 'treatment') trt$add_endpoints(ttp)
my_gen <- function(n, par1, par2){   tte_ep     <- ...  # n event times (pseudo-code)   non_tte_ep <- ...  # n values (pseudo-code)   age        <- ...  # n ages at randomization   data.frame(tte_ep       = tte_ep,              tte_ep_event = 1,  # 1 = event, 0 = censored              non_tte_ep   = non_tte_ep,              age          = age) } eps <- endpoint(name = c('tte_ep', 'non_tte_ep', 'age'),                 type = c('tte', 'non-tte', 'baseline'),                 readout = c(non_tte_ep = 3),                 generator = my_gen,                 par1 = 0.5, par2 = 2)
ttp <- endpoint(name = 'ttp', type = 'tte',                 generator = rweibull,                 shape = 1.5, scale = 8) trt <- arm(name = 'treatment') trt$add_endpoints(ttp)
> set.seed(1) > ttp
> set.seed(1) > trt
A time-to-event endpoint needs no readout.
Any R random number generator works, or one you write yourself.
A custom generator: its first argument is n, the number of patients to draw.
Its other arguments are yours to name; their values arrive through the ... of endpoint().
The body draws n values for each endpoint, any way you like (pseudo-code here).
It returns a data frame of n rows: one column per name.
NAMING RULE  a time-to-event endpoint x needs one more column, x_event: the event indicator (1 = event, 0 = censored). Here tte_ep → tte_ep_event.
Types follow the names. Only non_tte_ep gets a readout: age is 'baseline', known at randomization (readout 0, set for you).
Back to rweibull: shape and scale travel through ..., and ttp_event = 1 is added for you.
Typing the endpoint prints a summary of 10,000 draws. Weibull median = scale × (log 2)^(1/shape) = 6.27; the table shows 6.32.
type = 'tte' adds ttp_event: all 1 here; censoring comes later, from dropout and data locks.
arm() takes inclusion criteria through ...; the last example of this chapter uses one.
Typing the arm prints the same summary for every endpoint registered to it.
Endpoints and arms · independent endpoints
Several endpoint() calls, one arm
Arguments
endpoint()
name
Name(s) of the endpoint(s); one call can define several.
type
'tte', 'non-tte' (continuous, binary, categorical, repeated measures) or 'baseline' (non-tte, known at randomization).
readout
Named numeric vector: time from randomization to each non-tte value, e.g. c(orr = 2). NULL if all are tte; leave baseline endpoints out.
generator
Random number generator, first argument n. Returns n values, or a data frame of n rows: a column per name, plus <name>_event for each tte endpoint.
...
Arguments passed to generator.
arm()
name
Label of the arm in the generated data, e.g. in get_locked_data(); no '@' or ';'.
...
Inclusion criteria: dplyr::filter-style conditions on the endpoints. None: all generated patients are used.
arm$add_endpoints()
...
One or more objects returned by endpoint().
Viewer
Arm Name: placebo
Endpoints (3):pfs, os, orr
No
Variable
Stats / Freqs
Graph
1
pfs [time-to-event]
Median time: 5.01 Events: 10000 Missing: 0 (0%)
Kaplan-Meier curve of pfs
2
pfs_event [event indicator]
1: 10000 (100%) Missing: 0 (0%)
bar chart of pfs_event
3
os [time-to-event]
Median time: 14.12 Events: 10000 Missing: 0 (0%)
Kaplan-Meier curve of os
4
os_event [event indicator]
1: 10000 (100%) Missing: 0 (0%)
bar chart of os_event
5
orr [numeric]
Mean (sd): 0.3 (0.46) Min < median < max: 0 < 0 < 1 IQR (CV): 1 (1.53) Missing: 0 (0%)
histogram of orr
Viewer
Arm Name: placebo
Endpoints (3):pfs, os, orr
No
Variable
Stats / Freqs
Graph
1
pfs [time-to-event]
Median time: 5.01 Events: 10000 Missing: 0 (0%)
Kaplan-Meier curve of pfs
2
pfs_event [event indicator]
1: 10000 (100%) Missing: 0 (0%)
bar chart of pfs_event
3
os [time-to-event]
Median time: 14.12 Events: 10000 Missing: 0 (0%)
Kaplan-Meier curve of os
4
os_event [event indicator]
1: 10000 (100%) Missing: 0 (0%)
bar chart of os_event
5
orr [categorical]
0: 6977 (69.8%) 1: 3023 (30.2%) Missing: 0 (0%)
bar chart of orr
pfs <- endpoint(name = 'pfs', type = 'tte',                 generator = rexp, rate = log(2) / 5) os  <- endpoint(name = 'os', type = 'tte',                 generator = rexp, rate = log(2) / 14) orr <- endpoint(name = 'orr', type = 'non-tte',                 readout = c(orr = 2), generator = rbinom,                 size = 1, prob = 0.3) pbo <- arm(name = 'placebo') pbo$add_endpoints(pfs, os, orr)
> set.seed(1) > pbo
> set.seed(1) > print(pbo, categorical_vars = 'orr')
Three endpoint() calls, three endpoints, three separate generators.
orr is read out 2 time units after randomization; pfs and os need no readout.
Medians 5 and 14 come back as 5.01 and 14.12. A 0/1 endpoint is numeric by default: its mean, 0.3, is the response rate.
categorical_vars = 'orr' shows the same column as frequencies: 30.2% responders.
Separate endpoint() calls are independent: in these 10,000 patients PFS > OS for 26% (5/19 = 26% expected). Endpoints that depend on each other need one generator.
Endpoints and arms · one built-in generator, two endpoints
Correlated PFS and OS: CorrelatedPfsAndOs2
Arguments
endpoint()
name
Name(s) of the endpoint(s); one call can define several.
type
'tte', 'non-tte' (continuous, binary, categorical, repeated measures) or 'baseline' (non-tte, known at randomization).
readout
Named numeric vector: time from randomization to each non-tte value, e.g. c(orr = 2). NULL if all are tte; leave baseline endpoints out.
generator
Random number generator, first argument n. Returns n values, or a data frame of n rows: a column per name, plus <name>_event for each tte endpoint.
...
Arguments passed to generator.
arm()
name
Label of the arm in the generated data, e.g. in get_locked_data(); no '@' or ';'.
...
Inclusion criteria: dplyr::filter-style conditions on the endpoints. None: all generated patients are used.
arm$add_endpoints()
...
One or more objects returned by endpoint().
Viewer
Arm Name: placebo
Endpoints (2):pfs, os
No
Variable
Stats / Freqs
Graph
1
pfs [time-to-event]
Median time: 4.93 Events: 10000 Missing: 0 (0%)
Kaplan-Meier curve of pfs
2
os [time-to-event]
Median time: 13.88 Events: 10000 Missing: 0 (0%)
Kaplan-Meier curve of os
3
pfs_event [event indicator]
1: 10000 (100%) Missing: 0 (0%)
bar chart of pfs_event
4
os_event [event indicator]
1: 10000 (100%) Missing: 0 (0%)
bar chart of os_event
pfs_os <- endpoint(name = c('pfs', 'os'),                    type = c('tte', 'tte'),                    generator = CorrelatedPfsAndOs2,                    median_pfs = 5, median_os = 14,                    kendall = 0.6) pbo <- arm(name = 'placebo') pbo$add_endpoints(pfs_os)
> set.seed(1) > pbo
One call, two endpoints: names and types are vectors.
A built-in generator returning pfs, os, pfs_event, os_event together.
QR code linking to the vignette: simulate correlated PFS and OS using a Gumbel copula
Model details (vignette): how the Gumbel copula links time to progression and OS, and how kendall is set.
zhangh12.github.io/TrialSimulator/ articles/simulatePfsAndOsGumbel
Medians 5 and 14 come back as 4.93 and 13.88.
The table lists the generator’s four columns in its order.
> set.seed(1) > d <- pfs_os$test_generator(n = 1e4) > cor(d$pfs, d$os, method = 'kendall') [1] 0.5959365 > mean(d$pfs <= d$os) [1] 1
The link is checked on the data: τ ≈ 0.6, and PFS never exceeds OS.
Registered together, updated together: update_generator() must name c('pfs', 'os').
Endpoints and arms · custom generator, two endpoint types
Response and PFS from one generator
Arguments
endpoint()
name
Name(s) of the endpoint(s); one call can define several.
type
'tte', 'non-tte' (continuous, binary, categorical, repeated measures) or 'baseline' (non-tte, known at randomization).
readout
Named numeric vector: time from randomization to each non-tte value, e.g. c(orr = 2). NULL if all are tte; leave baseline endpoints out.
generator
Random number generator, first argument n. Returns n values, or a data frame of n rows: a column per name, plus <name>_event for each tte endpoint.
...
Arguments passed to generator.
arm()
name
Label of the arm in the generated data, e.g. in get_locked_data(); no '@' or ';'.
...
Inclusion criteria: dplyr::filter-style conditions on the endpoints. None: all generated patients are used.
arm$add_endpoints()
...
One or more objects returned by endpoint().
Viewer
Arm Name: treatment
Endpoints (2):orr, pfs
No
Variable
Stats / Freqs
Graph
1
orr [categorical]
0: 6964 (69.6%) 1: 3036 (30.4%) Missing: 0 (0%)
bar chart of orr
2
pfs [time-to-event]
Median time: 5.08 Events: 10000 Missing: 0 (0%)
Kaplan-Meier curve of pfs
3
pfs_event [event indicator]
1: 10000 (100%) Missing: 0 (0%)
bar chart of pfs_event
ep <- endpoint(name = c('orr', 'pfs'),                type = c('non-tte', 'tte'),                readout = c(orr = 2),                generator = orr_pfs,                p_resp = 0.3,                med_resp = 10, med_non = 4) trt <- arm(name = 'treatment') trt$add_endpoints(ep)
orr_pfs <- function(n, p_resp, med_resp, med_non){   orr <- rbinom(n, size = 1, prob = p_resp)   med <- ifelse(orr == 1, med_resp, med_non)   data.frame(orr = orr,              pfs = rexp(n, rate = log(2) / med),              pfs_event = 1) }
ep <- endpoint(name = c('orr', 'pfs'),                type = c('non-tte', 'tte'),                readout = c(orr = 2),                generator = orr_pfs,                p_resp = 0.3,                med_resp = 10, med_non = 4) trt <- arm(name = 'treatment') trt$add_endpoints(ep)
> set.seed(1) > print(trt, categorical_vars = 'orr')
Two endpoints of different types from one call.
Only the non-tte endpoint gets a readout.
The custom generator: n first, one column per name. Responders get the longer PFS.
NAMING RULE  pfs is time-to-event, so the generator also returns pfs_event (1 = event, 0 = censored).
p_resp = 0.3 → 30.4% responders.
Pooled PFS: 30% with median 10 and 70% with median 4 give 5.10; the table shows 5.08.
> set.seed(1) > d <- ep$test_generator(n = 1e4) > tapply(d$pfs, d$orr, median)         0         1   3.923916 10.581403 
Responders progress later: 10.6 vs 3.9 months.
Endpoints and arms · custom generator, longitudinal
Repeated measures from one generator
Arguments
endpoint()
name
Name(s) of the endpoint(s); one call can define several.
type
'tte', 'non-tte' (continuous, binary, categorical, repeated measures) or 'baseline' (non-tte, known at randomization).
readout
Named numeric vector: time from randomization to each non-tte value, e.g. c(orr = 2). NULL if all are tte; leave baseline endpoints out.
generator
Random number generator, first argument n. Returns n values, or a data frame of n rows: a column per name, plus <name>_event for each tte endpoint.
...
Arguments passed to generator.
arm()
name
Label of the arm in the generated data, e.g. in get_locked_data(); no '@' or ';'.
...
Inclusion criteria: dplyr::filter-style conditions on the endpoints. None: all generated patients are used.
arm$add_endpoints()
...
One or more objects returned by endpoint().
Viewer
Arm Name: treatment 1
Endpoints (3):bp_cfb2, baseline, bp_cfb4
No
Variable
Stats / Freqs
Graph
1
baseline [numeric]
Mean (sd): 139.99 (1.42) Min < median < max: 133.93 < 140 < 145.77 IQR (CV): 1.94 (0.01) Missing: 0 (0%)
histogram of baseline
2
bp_cfb2 [numeric]
Mean (sd): -15 (1.41) Min < median < max: -19.99 < -15 < -9.81 IQR (CV): 1.96 (0.09) Missing: 0 (0%)
histogram of bp_cfb2
3
bp_cfb4 [numeric]
Mean (sd): -19.99 (2.01) Min < median < max: -27.13 < -20.01 < -12.5 IQR (CV): 2.69 (0.1) Missing: 0 (0%)
histogram of bp_cfb4
ep <- endpoint(   name = c('bp_cfb2', 'baseline', 'bp_cfb4'),   type = 'non-tte',   readout = c(baseline = 0, bp_cfb4 = 4, bp_cfb2 = 2),   generator = bp_gen,   bp_means = c(140, 125, 120), bp_vcov = vcov1) trt <- arm(name = 'treatment 1') trt$add_endpoints(ep)
vcov1 <- matrix(c(2, 1.5, 1, 1.5, 3, 1.5, 1, 1.5, 4), 3) bp_gen <- function(n, bp_means, bp_vcov){   bp <- mvtnorm::rmvnorm(n, mean = bp_means,                          sigma = bp_vcov)   data.frame(baseline = bp[, 1],              bp_cfb2  = bp[, 2] - bp[, 1],              bp_cfb4  = bp[, 3] - bp[, 1]) }
ep <- endpoint(   name = c('bp_cfb2', 'baseline', 'bp_cfb4'),   type = 'non-tte',   readout = c(baseline = 0, bp_cfb4 = 4, bp_cfb2 = 2),   generator = bp_gen,   bp_means = c(140, 125, 120), bp_vcov = vcov1) trt <- arm(name = 'treatment 1') trt$add_endpoints(ep)
> set.seed(1) > trt
Blood pressure at baseline and its changes at weeks 2 and 4: three endpoints, one call.
One type, recycled to all three.
The week each value is observed, counted from randomization. Matched by name, so the order may differ from name.
readout · weeks from randomization to each value
One patient: baseline at week 0, bp_cfb2 at week 2, bp_cfb4 at week 4
baseline
bp_cfb2
bp_cfb4
0
1
2
3
4
5
weeks from randomization
In calendar time each patient starts at randomization; a data lock sees only the values whose readout has passed.
Calendar time: three patients and a data lock at week 6; values after the lock are NA
randomized wk 0
randomized wk 3
NA
randomized wk 5
NA
NA
0
2
4
6
8
10
calendar weeks
data lock · week 6
A value appears at randomization + readout; at a lock, a value whose time has not come yet is NA.
A trivariate normal for weeks 0, 2 and 4, returned as the baseline and the changes from baseline (vignette: Define Longitudinal Endpoints).
Means 140, 125 − 140 = −15 and 120 − 140 = −20; sds √2, √2 and 2 follow from vcov1.
Endpoints and arms · custom generator, baseline covariate
A categorical baseline covariate
Arguments
endpoint()
name
Name(s) of the endpoint(s); one call can define several.
type
'tte', 'non-tte' (continuous, binary, categorical, repeated measures) or 'baseline' (non-tte, known at randomization).
readout
Named numeric vector: time from randomization to each non-tte value, e.g. c(orr = 2). NULL if all are tte; leave baseline endpoints out.
generator
Random number generator, first argument n. Returns n values, or a data frame of n rows: a column per name, plus <name>_event for each tte endpoint.
...
Arguments passed to generator.
arm()
name
Label of the arm in the generated data, e.g. in get_locked_data(); no '@' or ';'.
...
Inclusion criteria: dplyr::filter-style conditions on the endpoints. None: all generated patients are used.
arm$add_endpoints()
...
One or more objects returned by endpoint().
Viewer
Arm Name: treatment
Endpoints (1):region
No
Variable
Stats / Freqs
Graph
1
region [categorical]
North America: 3020 (30.2%) Asia: 2522 (25.2%) Latin America: 1881 (18.8%) Western Europe: 1221 (12.2%) Oceania: 823 (8.2%) 1 others: 533 (5.3%) Missing: 0 (0%)
bar chart of region
Viewer
Arm Name: treatment
Endpoints (1):region
No
Variable
Stats / Freqs
Graph
1
region [categorical]
Asia: 3582 (35.8%) Latin America: 2768 (27.7%) Western Europe: 1727 (17.3%) Oceania: 1188 (11.9%) Eastern Europe: 735 (7.3%) Missing: 0 (0%)
bar chart of region
region <- endpoint(   name = 'region',   type = 'baseline',   # same as type = 'non-tte', readout = c(region = 0)   generator = region_gen,   prob = c(.25, .12, .05, .30, .20, .08)) trt <- arm(name = 'treatment') trt$add_endpoints(region)
region_gen <- function(n, prob){   sample(c('Asia', 'Western Europe', 'Eastern Europe',            'North America', 'Latin America', 'Oceania'),          size = n, replace = TRUE, prob = prob) }
region <- endpoint(   name = 'region',   type = 'baseline',   # same as type = 'non-tte', readout = c(region = 0)   generator = region_gen,   prob = c(.25, .12, .05, .30, .20, .08)) trt <- arm(name = 'treatment') trt$add_endpoints(region)
region <- endpoint(   name = 'region',   type = 'baseline',   # same as type = 'non-tte', readout = c(region = 0)   generator = region_gen,   prob = c(.25, .12, .05, .30, .20, .08)) trt <- arm(name = 'treatment', region != 'North America') trt$add_endpoints(region)
> set.seed(1) > trt
'baseline': observed at randomization, so readout 0 and no readout argument.
Six regions; character values are summarized as categories.
Levels are sorted by frequency, not by their order in the code; the rarest (Eastern Europe, 5%) is pooled as “1 others”.
An inclusion criterion through the ... of arm().
The enrolled population: no North America, shares rescaled (Asia 35.8% ≈ 0.25 / 0.70); five levels left, so Eastern Europe gets its own row.
Trial, listener, controller · signatures
trial(): the settings of one trial
trial(name, n_patients, seed = NULL,       enroller = StaggeredRecruiter, dropout = NULL,       stratification_factors = NULL,       global_data = list(), silent = FALSE, ...) trial$add_arms(sample_ratio, ...)
Arguments
trial()
name
Label of the trial.
n_patients
Planned maximum number of patients; can be changed during the trial.
seed
NULL (recommended): one seed per replicate, kept in the output. An integer reproduces a trial.
enroller
StaggeredRecruiter: piecewise constant accrual.
dropout
Generator of dropout times, first argument n, e.g. rweibull.
stratification_ factors
Baseline endpoints (readout 0) for stratified permuted blocks; NULL: unstratified.
global_data
Named list, read-only, shared by all replicates.
silent
TRUE mutes messages; warnings are still shown.
...
Passed to enroller and dropout: accrual_rate, shape, scale.
trial$add_arms()
sample_ratio
Integers for permuted block randomization.
...
Objects returned by arm().
trial <- trial(name = 'phase3', n_patients = 500,   seed = NULL,   enroller = StaggeredRecruiter,   accrual_rate = data.frame(end_time = c(6, Inf),                             piecewise_rate = c(15, 30)),   dropout = rweibull, shape = 2, scale = 38,   stratification_factors = NULL,   global_data = list(alpha = 0.025), silent = TRUE)   trial$add_arms(sample_ratio = c(1, 1), pbo, trt)
n_patients is the planned maximum; an adaptation can change it later.
NULL: every replicate draws its own seed and records it in the output.
15 patients per month for 6 months, then 30; accrual_rate reaches the enroller through ....
Dropout times from rweibull(n, shape = 2, scale = 38): any generator whose first argument is n.
Stratify on baseline endpoints, e.g. a region defined with type 'baseline'; NULL here.
The same, read-only values in every replicate: trial$get_global_data('alpha').
The two arms of the previous chapter, randomized 1:1 in permuted blocks.
Typing the trial prints its settings, with the seed drawn for this replicate.
Console
> trial
⚕⚕ ⚕⚕ ⚕⚕ ⚕⚕ ⚕⚕ ⚕⚕ ⚕⚕
         Trial Name:  phase3      Number of Arms:  2     Registered Arms:  placebo, treatment        Sample Ratio:  1, 1  Number of Patients:  500             Regimen:  not set         Random Seed:  1555738133
Back to the milestone · its last piece
Every action: snapshot, analyze, remember, adapt
milestone(name, when = <condition>, action = function(trial){ … })
when covered  ·  action: the last piece
runs each time the milestone fires
1  Snapshot
get_locked_data(name)
Data as of the trigger time, censored and truncated by the engine. Earlier snapshots stay available.
2  Analyze
fitLogrank() fitCoxph() fitLogistic() fitLinear() fitFarringtonManning() GroupSequentialTest GraphicalTesting SSR
3  Remember
save() bind() save_custom_data() get() get_global_data()
→ one row per replicate
4  Adapt
Trials methods change the trial in place; what follows obeys the new settings.
remove_arms() dose selection, seamless
add_arms() dose ranging, platform trials
update_sample_ratio() response-adaptive randomization
update_generator() enrichment, drifting data model
stop_followup() freeze a cohort or sub-population
update_milestone() re-target, extend or stop early
resize() sample size re-estimation
update_accrual_rate() slower, paused or resumed accrual
crossover() switch patients after a milestone
Under the hood · data lock
How a milestone locks the data
event
censored
1
response
×
dropout
 id enroll dropout    pfs pfs_event  orr
  1   1.00   27.48   6.97         1    0
  2   2.00   18.13   0.85         1    0
  3   3.00   68.29   5.36         1    1
  4   4.00   27.99  14.44         1    1
  4   4.00   27.99   5.50         0    1
  5   5.00  163.52   8.97         1    1
  5   5.00  163.52   4.50         0    1
  6   6.00    1.13   2.77         1    0
  6   6.00    1.13   1.13         0   NA
  7   7.00   37.99   1.06         1    0
  7   7.00   37.99   1.06         1   NA
  8   8.00   27.01  10.70         1    0
  8   8.00   27.01   1.50         0    0
  8   8.00   27.01   1.50         0   NA
  9   9.00   86.58   5.71         1    0
  9   9.00   86.58   0.50         0    0
  9   9.00   86.58   0.50         0   NA
 10  10.00  106.18   0.30         1    0
 11  11.00   30.36   5.73         1    1
 12  12.00   23.62  25.07         1    0
 12  12.00   23.62  23.62         0    0
0
0
1
1
1
×
0
NA
0
NA
0
NA
0
NA
0
1
0
0
6
12
18
24
months
interim · month 9.5
1  At setup, add_arms() draws every patient’s outcomes: PFS, response (read out at month 3) and dropout time
2  Dropout is applied right away: PFS is censored at dropout, a readout after dropout is NA (patient 6)
3  The interim fires at month 9.5 and lock_data() takes a snapshot of the data at that time
4  Patients enrolled after month 9.5 are not in the snapshot
5  Time-to-event: an event after 9.5 is censored there, so pfs = 9.5 − enroll and pfs_event = 0
6  Non-tte: a readout due after 9.5 is NA. Patient 7’s progression is in, its response is not yet
7  get_locked_data('interim') returns these 9 rows. The trial keeps the full data for later milestones
Under the hood · rollback
An adaptation redraws the patients not yet enrolled
low
placebo
high
high
low
placebo
high
low
low
placebo
placebo
low
low
placebo
placebo
placebo
high
low
0
6
12
18
24
30
months
interim · month 6.5
interim_action <- function(trial){   ...   trial$remove_arms('high',       additional_followup = 0) }
run inside remove_arms(), not by users
1
censor_trial_data(   censor_at = 6.5 + 0,   selected_arms = 'high')
Stop follow-up of the removed arm
2
roll_back()
Keep rows enrolled by month 6.5; drop the later rows but keep their enrollment times
3
enroll_patients()
Randomize the open slots to the remaining arms and draw new outcomes and dropout times
1  At setup all 12 patients are drawn up front and randomized 1 : 1 : 1
2  At month 6.5 the interim action removes the high dose
3  High-dose patients already enrolled stop follow-up at 6.5 + additional_followup
4  roll_back(): patients not yet enrolled are discarded, but their enrollment times stay
5  enroll_patients(): the open slots are re-randomized and their outcomes redrawn under the new design
6  Before the milestone nothing changes except that censoring: patient 6 keeps its event at month 19.5
Under the hood · rollback
Before and after the adaptation
Initial draw · no adaptation
low
placebo
high
high
low
placebo
high
low
placebo
low
placebo
high
0
6
12
18
24
30
After remove_arms('high') at month 6.5
low
placebo
high
high
low
placebo
low
placebo
low
placebo
placebo
low
0
6
12
18
24
30
1  The same 12 enrollment times on both sides; only what the adaptation touches is different
2  Changed: patient 4 (high) is censored at month 6.5; patients 7–12 get new arms and new outcomes. Patients 1–3, 5 and 6 are untouched
Under the hood · rollback
Which adaptations roll back
YOU CALL, IN AN ACTION
ENROLLED PATIENTS
NOT YET ENROLLED
ROLLS BACK
trial$remove_arms(arms_name,   additional_followup)
Removed arms censored at the milestone + additional_followup
Re-randomized to the remaining arms
yes
trial$add_arms(sample_ratio, ...)
Unchanged
Re-randomized, new arms included
yes
trial$update_sample_ratio(   arm_names, sample_ratios)
Unchanged
Re-randomized with the new ratios
yes
trial$update_generator(arm_name,   endpoint_name, generator, ...)
Unchanged
Outcomes redrawn from the new generator
yes
trial$resize(n_patients)
Unchanged
Redrawn, plus extra slots from the reserve drawn by trial()
yes
trial$update_accrual_rate(   accrual_rate)
Unchanged
Redrawn with new enrollment times
yes
trial$stop_followup(...,   additional_followup)
Selected patients censored at the milestone + additional_followup
Unchanged
no
trial$crossover(what, how,   when, delay)
Switching rule applied in place
Switching rule applied in place
no
roll_back() and enroll_patients() are internal: TrialSimulator runs them inside the methods marked yes. Users never call them.
In code · fixed and group sequential
Sequential = fixed design + one milestone
treatment
placebo
0
12
24
36
48 mo
interim
when = eventNumber('os', n = 200)  → month 20
if futile, the trial ends here
Action · interim
function(trial){   d <- trial$get_locked_data(          'interim')   z <- fitLogrank(..., data = d)$z   if(z < 0.5)  # futility     trial$update_milestone(       'final', when = calendarTime(         trial$get_current_time())) }
Futile? Pull the final milestone to now, and the trial stops early. Otherwise nothing changes.
final
when = eventNumber('os', n = 400)  → month 36
Action · final
function(trial){   d <- trial$get_locked_data(          'final')   fit <- fitLogrank(..., data = d)   trial$save(fit$p, name = 'p') }
Analyze the snapshot and save; run(n) stacks one row per replicate.
Delete the interim milestone and you have the fixed design: same arms, same final action.
In code · seamless dose selection
Seamless: pick the dose, keep the trial
placebo
low dose
high dose
0
12
24
36 mo
dose selection
when = eventNumber('surrogate', n = 300)  → month 9
dropped
Action · dose selection
function(trial){   d <- trial$get_locked_data(          'dose selection')   fit <- fitFarringtonManning(     'surrogate', data = d, ...)   ## low dose looks better   trial$remove_arms('high dose',     additional_followup = 0) }
High dose closes: no new patients, follow-up stops, and its data stay in later snapshots.
interim
when = eventNumber('pfs', n = 300)  → month 18
Action · interim
function(trial){   d <- trial$get_locked_data(          'interim')   fit <- fitLogrank(..., data = d)   trial$save(ifelse(max(fit$z) < .5,     'negative', 'positive'),     name = 'futility') }
Non-binding futility: record the decision and keep going.
final
enrollment(n = 1000, arms = <all 3>) & eventNumber('os', n = 300) & (calendarTime(time = 28)    | eventNumber('pfs', n = 520))
25.0 30.0 28.0 31.5
When · final
max(25.0, 30.0, min(28.0, 31.5)) = month 30.0
Four conditions, one trigger time. Listing all three arms keeps the dropped dose in the 1,000.
Action · final
function(trial){   d <- trial$get_locked_data(          'final')   ## closed test with Dunnett's   ## test for intersections   trial$save(decision,              name = 'final') }
Which dose survives is decided by data at run time; the code never names a winner.
In code · dose ranging
Dose ranging: open new arms mid-trial
dose 0.0
dose 4.0
dose 0.5
dose 1.5
dose 2.5
0
6
12
18
24 mo
interim
when = eventNumber('ep', n = 30)  → month 8  # 2 open arms
Action · interim
function(trial){   d <- trial$get_locked_data(          'interim')   trial$save(go_nogo(d), ...)   ## build trt1, trt2, trt3   ## with arm() + endpoint()   trial$add_arms(     sample_ratio = c(2, 2, 2),     trt1, trt2, trt3) }
Three dose arms open at month 8; new patients are randomized into all five.
final
when = eventNumber('ep', n = 150)  → month 20  # all 5 arms
Action · final
function(trial){   d <- trial$get_locked_data(          'final')   trial$save(go_nogo(d),              name = 'decision') }
Arms can join mid-trial: build them inside the action and call add_arms().
In code · adaptive enrichment
Enrichment: narrow the population mid-trial
trt · BM+
trt · BM−
pbo · BM+
pbo · BM−
0
10
20
30
40 mo
stage 1 cohort · patient_id ≤ 80
interim
when = eventNumber('pfs', n = 45,        patient_id <= 80)  → month 16
BM− enrollment closes
Action · interim (enrich)
## CP in subgroup is promising trial$update_generator('trt',   c('pfs', 'biomarker'), rng,   ..., prevalence = 1.0) ## same for 'pbo' trial$update_accrual_rate(   data.frame(end_time = Inf,     piecewise_rate = 5)) trial$resize(n_patients = ...) trial$update_milestone('final',   when = ...)
Only BM+ patients enroll from now on, more slowly, and the final is re-targeted on subgroup events.
stage 1
when = eventNumber('pfs', n = 65,        patient_id <= 80)  → month 22
follow-up frozen at 22
Action · stage 1 analysis
function(trial){   trial$stop_followup(     patient_id <= 80,     additional_followup = 0)   d <- trial$get_locked_data(          'stage 1 analysis')   p1 <- fitLogrank(..., data = d,     patient_id <= 80)$p   trial$save(p1, name = 'pf1') }
Freezing the stage 1 cohort keeps the stage 2 p-values independent.
final
when = eventNumber('pfs', n = D, biomarker == 1)   & eventNumber('pfs', n = 65, patient_id <= 80) → month 38
Action · final
function(trial){   ## stage 2 p-values, inverse   ## normal + closed Simes test   trial$save(decision,              name = 'final')   ## + get_output('pf1') }
Five adaptation methods turn a two-arm trial into an adaptive enrichment design. The engine is untouched.
In code · basket
Basket: each disease closes on its own
trt · D1
pbo · D1
trt · D2
pbo · D2
trt · D3
pbo · D3
0
12
24
36 mo
interim 1
when = eventNumber('orr', n = 60)  → month 12
D2 closed
Action · interim 1
function(trial, milestone_name){   d <- trial$get_locked_data(          milestone_name)   ## response rate per basket   for(b in futile_baskets(d))     trial$remove_arms(       paste0(c('trt_', 'pbo_'), b),       additional_followup = 0) }
D2 shows no response: both of its arms are removed, the rest carry on.
interim 2
when = eventNumber('orr', n = 120)  → month 20
D3 closed
Same action, second milestone
milestone('interim 1',   when = eventNumber('orr', 60),   action = basket_action,   milestone_name = 'interim 1') milestone('interim 2',   when = eventNumber('orr', 120),   action = basket_action,   milestone_name = 'interim 2')
Extra arguments of milestone() are passed on to the action, so one function serves both interims. This time D3 closes.
final
when = eventNumber('orr', n = 200)  → month 32
Action · final
function(trial){   d <- trial$get_locked_data(          'final')   ## test every basket still open   trial$save(..., name = 'final') }
A basket trial is arms that close independently: remove_arms() at each interim, one reusable action.
In code · response-adaptive randomization
Response-adaptive: shift patients to better doses
dose 0
dose 20
dose 25
dose 30
dose 35
bar thickness = share of new patients
0
6
12
18
24 mo
stage 1
when = eventNumber('fev1', n = 50)  → month 7
Action · stage 1 and stage 2
function(trial, milestone_name){   d <- trial$get_locked_data(          milestone_name)   r <- compute_sample_ratio(d)   trial$update_sample_ratio(     arm_names = c('0.0', '20.0',       '25.0', '30.0', '35.0'),     sample_ratios = r) }
Placebo keeps 20%; the remaining 80% follows the observed dose response.
stage 2
when = eventNumber('fev1', n = 120)  → month 13
Same action, new ratios
dose        0   20   25   30   35 start     .20  .20  .20  .20  .20 stage1    .20  .08  .17  .27  .28 stage2    .20  .04  .10  .32  .34
The second milestone passes milestone_name = 'stage 2' to the same function. Ratios shown are illustrative.
final
when = eventNumber('fev1', n = 200)  → month 20
Action · final
function(trial){   d <- trial$get_locked_data(          'final')   trial$save(     multiple_contrast_test(d),     name = 'MC_test') }
Randomization is a setting, not code: update_sample_ratio() changes it between milestones.
Designs · summary
Every design: milestones + actions, one engine
Design
Milestones
Adaptation inside the actions
Fixed
final
none: analyze and save
Group sequential
interim, final
update_milestone() to stop early
Seamless dose selection
dose selection, interim, final
remove_arms()
Dose ranging
interim, final
add_arms()
Adaptive enrichment
interim, stage 1, final
update_generator(), update_accrual_rate(), resize(), update_milestone(), stop_followup()
Basket
interim 1, interim 2, final
remove_arms(), one action reused
Response-adaptive
stage 1, stage 2, final
update_sample_ratio()
Compose, don't rewrite
A new design reuses arms, endpoints and conditions; only milestones and actions change.
Read it like the protocol
Each “when” and “then” in the protocol maps to one milestone() and one action, easy to review.
The engine owns the risk
Trigger timing, data lock, enrollment, dropout and replication are never re-coded per design.
Chapter · Saving results
Save and retrieve intermediate results
Keep what an action finds, then pull it back: in a later action, or after all replicates.
Save
Retrieve
trial$save()
trial$get_output() controller$get_output()
→
trial$save_custom_data() trial$bind()
trial$get()
→
trial(global_data = list(...))
trial$get_global_data()
→
Action functions · saving results
Two places to keep what an action finds
one simulated trial
interim action
final action
start
replicate ends
output
one row per replicate
trial$save()
milestone_time_<interim>
n_events_<interim>_<pfs>
p_interim
milestone_time_<final>
n_events_<final>_<pfs>
p_final
reject
blue: saved automatically when the milestone triggers
orange: written by trial$save() in the action
custom data
trial$save_custom_data()
this replicate only
note = 'hello, final'
saved at the interim
trial$get('note')
read at the final
✕ cleared
before the next replicate; never part of the output
after 1,000 replicates
rows stacked into one data frame
1
2
3
⋮
1000
controller$get_output()
power = mean(output$reject) = 77.7%
interim and final times, events, … come from the blue columns
Action functions · saving results
Save for the summary, or communicate across milestones
trial <- trial(..., global_data = list(alpha = 0.025))   interim_action <- function(trial){   d   <- trial$get_locked_data('interim')   fit <- fitLogrank(Surv(pfs, pfs_event) ~ arm, placebo = 'placebo',                     data = d, alternative = 'less')   trial$save(value = fit$p, name = 'p_interim')   trial$save_custom_data(value = 'hello, final', name = 'note') }   final_action <- function(trial){   d   <- trial$get_locked_data('final')   fit <- fitLogrank(Surv(pfs, pfs_event) ~ arm, placebo = 'placebo',                     data = d, alternative = 'less')   note  <- trial$get('note')               # 'hello, final'   alpha <- trial$get_global_data('alpha')  # 0.025   trial$save(value = fit$p, name = 'p_final')   trial$save(value = fit$p < alpha, name = 'reject') }
Custom data passed from the interim action to the final action
custom data
trial$save(value, name)
A scalar or a one-row data frame becomes a column of the output, kept for the summary after the run. A later action can read it with trial$get_output().
trial$save_custom_data(value, name)
Any R object, here just a string. A later action of the same replicate reads it with trial$get(name), the short alias of trial$get_custom_data(name). Cleared before the next replicate.
global_data
Read-only and identical in every replicate; defined once in trial(), read with trial$get_global_data(name).
Action functions · saving results
controller$get_output(): one row per replicate
output
Filter
1 2 3 4 5 6
milestone_time_<interim>
13.07 12.66 12.97 12.85 13.24 12.60
n_events_<interim>_<pfs>
150 150 150 150 150 150
milestone_time_<final>
18.05 18.67 18.08 19.29 18.34 18.50
n_events_<final>_<pfs>
250 250 250 250 250 250
n_events_<final>_<patient_id>
400 400 400 400 400 400
p_interim
0.1010 0.4840 0.0158 0.0090 0.0001 0.3578
p_final
0.0795 0.2125 0.0130 0.0088 0.0000 0.0798
reject
FALSE FALSE TRUE TRUE TRUE FALSE
Showing 1 to 6 of 1,000 entries, 14 total columns
> View(output[, cols])
Saved automatically at every milestone: the trigger time, the events of each endpoint, the enrolled patients and a per-arm table. Almost every summary needs them, without a line of code.
Saved by trial$save(): p_interim, p_final, reject. The power over 1,000 replicates is simply mean(output$reject) = 77.7%.
Not needed? controller$run(tidy = TRUE) skips the per-arm table, the costliest of them (1,000 replicates of this example: 21.4 s instead of 22.5 s); controller$get_output(tidy = TRUE) drops all standard columns from what it returns.
n_events_<final>_<arms>
one data frame per cell, row 1
arm
placebo treatment
pfs
132 118
patient_id
200 200
QR code linking to the vignette on action functions
vignette: action functions
Action functions · graphical testing
A graph that updates itself between looks
Other packages: functions you call with everything
gMCP
gMCP(graph, pvalues, ...)
Graphs for fixed designs; one call tests all p-values at once.
gMCPLite
hGraph(...) + gsDesign
Draws the graph; group sequential bounds are re-derived by hand after each rejection.
graphicalMCP
graph_test_shortcut_gsd(graph, p, alpha, info_frac, spending_fn)
Group sequential testing, but the p-values of every analysis so far go in as one matrix, at every call.
TrialSimulator: one object that remembers
gt <- GraphicalTesting$new(...)
the graph: α and weights
α spending, planned information
rejections, α spent so far
the trajectory of every test
analysis 1
newest p-values only
→
gt$test(stats)
H5 rejected; its α moves to H6
analysis 2
newest p-values only
→
gt$test(stats)
H1, H3 rejected; α moves to H2, H4
analysis 3
newest p-values only
→
gt$test(stats)
H2 not rejected; testing ends
the same gt every time: nothing to re-enter, nothing to re-derive
Pass only the newest results. The object moves the α, recomputes every boundary and keeps the history.
Coming in November
The built-in GraphicalTesting is slow today; the November update targets ~100× faster.
Action functions · graphical testing
Three looks, three calls, one object
gt <- GraphicalTesting$new(alpha, transition,          alpha_spending, planned_max_info, hypotheses)   gt$test(stats1)        # analysis 1: its p-values only gt$test(stats2)        # analysis 2 gt$test(stats3)        # analysis 3   gt$get_current_decision()
Example 2 of ?GraphicalTesting; inputs omitted.
Edges of the testing graph
1
0.5
0.5
1
0.5
0.5
1
0.5
0.5
Edges of the testing graph
1
0.5
0.5
1
1
0.5
0.5
Edges of the testing graph
1
1
1
H1: OS sub
α = 0.01
rejected
H2: OS all
α = 0.01
α = 0.02
not rejected
H3: PFS sub
α = 0.004
rejected
H4: PFS all
α = 0
α = 0.004
not rejected
H5: ORR sub
α = 0.0005
rejected
H6: ORR all
α = 0.0005
α = 0.001
not rejected
> gt$test(stats1)      # rows tested at this look hypothesis  obs_p_value max_allocated_alpha decision H1: OS sub  0.03        0.01                accept H2: OS all  0.2         0.01                accept H3: PFS sub 0.2         0.004               accept H6: ORR all 0.1         0.001               accept H5: ORR sub 1e-05       0.0005              reject
> gt$test(stats2)      # rows tested at this look hypothesis  obs_p_value max_allocated_alpha decision H2: OS all  0.15        0.02                accept H1: OS sub  1e-04       0.01                reject H3: PFS sub 0.001       0.004               reject H4: PFS all 0.2         0.004               accept
> gt$test(stats3)      # rows tested at this look hypothesis  obs_p_value max_allocated_alpha decision H2: OS all  0.1         0.02                accept
> gt$get_current_decision()  H1: OS sub  H2: OS all H3: PFS sub     "reject"    "accept"    "reject"  H4: PFS all H5: ORR sub H6: ORR all     "accept"    "reject"    "accept" 
H5 rejected at analysis 1; its α (0.0005) moves to H6.
H1 and H3 rejected; H2 now holds 0.02 and H4 0.004.
H2 at its final look: p = 0.1, not rejected; the graph stays.
Three calls, each with one analysis; the object did the bookkeeping.
Action functions · graphical testing
One object, carried from the interim to the final
trial <- trial(...,   global_data = list(gt = GraphicalTesting$new(     alpha, transition, alpha_spending, planned_max_info, hypotheses)))   interim_action <- function(trial){   d     <- trial$get_locked_data('interim')   stats <- ...                          # p-values and information from d   gt    <- trial$get_global_data('gt')  # fresh copy of the template   gt$test(stats)   trial$save_custom_data(gt, name = 'gt') }   final_action <- function(trial){   d     <- trial$get_locked_data('final')   stats <- ...                          # only the new p-values   gt    <- trial$get('gt')              # the same object, updated   gt$test(stats)   trial$save(gt$get_current_decision()[['H1']] == 'reject',              name = 'reject_H1') }
1  Template
Defined once as global data: read-only and identical in every replicate.
2  Interim
Take a fresh copy, test the interim p-values, keep the object as custom data.
3  Final
Read the same object back and pass only the final p-values; the graph already reflects the interim.
4  Output
Save the decisions you will summarize. The object is cleared before the next replicate.
Trial, listener, controller · signatures
milestone() and listener(): when to look
milestone(name, when, action = doNothing, ...) listener(silent = FALSE) listener$add_milestones(...)
Arguments
milestone()
name
Name used by get_locked_data() and in the output columns.
when
Trigger: calendarTime(), enrollment(), eventNumber(), or a combination with & and |.
action
Function of trial, run at the trigger. doNothing (default) only records the time and the counts.
...
Further arguments of action.
listener()
silent
TRUE mutes messages.
listener$add_milestones()
...
Milestones, checked and triggered in this order.
futility <- milestone(name = 'futility',                       when = eventNumber('orr', n = 150),                       action = futility_action)   interim  <- milestone(name = 'interim',                       when = eventNumber('pfs', n = 250),                       action = interim_action)   final    <- milestone(name = 'final',                       when = eventNumber('os', n = 300),                       action = final_action)   listener <- listener() listener$add_milestones(futility, interim, final)
The name is how the action finds its data: get_locked_data('futility').
150 ORR readouts, 250 PFS events, 300 deaths: whichever time each takes in this replicate.
Each action is a function of trial, as in the chapter on action functions.
A listener watches the trial and fires its milestones.
> listener$add_milestones(futility, interim, final) A milestone <futility> is registered. A milestone <interim> is registered. A milestone <final> is registered.
Trial, listener, controller · signatures
controller(): run the replicates, collect the output
controller(trial, listener) controller$run(n = 1, n_workers = 1, plot_event = TRUE,                silent = FALSE, tidy = FALSE) controller$get_output(cols = NULL, simplify = TRUE,                       tidy = FALSE)
Arguments
controller()
trial
Object returned by trial().
listener
Object returned by listener().
controller$run()
n
Number of replicates.
n_workers
More than 1: replicates run in parallel workers (package mirai).
plot_event
Event plot of the trial; forced to FALSE when n > 1.
silent
TRUE mutes messages; a long run shows a progress bar.
tidy
TRUE: the per-arm event table is not saved at milestones.
controller$get_output()
cols
Columns to return; NULL for all.
simplify
One column comes back as a vector.
tidy
TRUE: drop the standard milestone columns.
controller <- controller(trial, listener) controller$run(n = 1000, n_workers = 1,                plot_event = FALSE, silent = TRUE)   out <- controller$get_output()
> dim(out) [1] 1000   24 > mean(out$p_os < 0.025) [1] 0.861 > summary(out[['milestone_time_<final>']])    Min. 1st Qu.  Median    Mean 3rd Qu.    Max.   33.06   37.33   39.05   39.29   40.84   54.17
The controller brings the trial and the listener together.
With n_workers > 1 each worker gets a copy of trial and listener; for large studies, the targets package runs many designs as a pipeline.
1,000 rows, 24 columns: 18 saved at the milestones, 3 by the actions. OS power 86.1%.
Trial, listener, controller · signatures
One # switches the futility look off
trial <- trial(..., seed = 2026)   # same patients twice trial$add_arms(sample_ratio = c(1, 1), pbo, trt)   listener <- listener() listener$add_milestones(   futility,   interim,   final )   controller <- controller(trial, listener) controller$run(n = 1)   # plot_event = TRUE by default
trial <- trial(..., seed = 2026)   # same patients twice trial$add_arms(sample_ratio = c(1, 1), pbo, trt)   listener <- listener() listener$add_milestones(   # futility,   interim,   final )   controller <- controller(trial, listener) controller$run(n = 1)   # plot_event = TRUE by default
A milestone <futility> is registered. A milestone <interim> is registered. A milestone <final> is registered. Data is locked at time = <10> for milestone <futility>. Data is locked at time = <18.7781514986108> for milestone <interim>. Data is locked at time = <44.1616864179882> for milestone <final>.
A milestone <interim> is registered. A milestone <final> is registered. Data is locked at time = <18.7781514986108> for milestone <interim>. Data is locked at time = <44.1616864179882> for milestone <final>.
Event plot of one simulated trial with the futility milestone: cumulative ORR readouts, deaths and PFS events by arm over calendar time, dashed lines at the data locks
Dashed lines: data locks at 10 (futility), 18.8 (interim), 44.2 (final).
Event plot of one simulated trial without the futility milestone: cumulative ORR readouts, deaths and PFS events by arm over calendar time, dashed lines at the data locks
Dashed lines: 18.8 (interim) and 44.2 (final), the same times as before.
One replicate, three looks: run(n = 1) draws the event plot with a dashed line at each data lock.
To drop the futility look, comment out one line.
Two milestones registered; the interim and the final lock at the same times, because the seed and the patients are the same.
The futility milestone still exists but is never checked: no orr_diff, 17 output columns instead of 24. Delete the # and it is back.
Designs with and without a look differ by one character, so both can be simulated from the same script.
Chapter · From words to code
Complete examples, start to finish
Describe the design in plain words first; the script then grows one piece at a time.
endpoint()
arm()
trial()
endpoint() into arm() into trial()
add_endpoints()
add_arms()
function(trial){ … }
milestone()
listener()
action function into milestone() into listener()
action =
add_milestones()
controller()
$run()
trial() and listener() into controller()
The examples
1
Two-arm design
2
Seamless dose selection
3
Response-adaptive randomization
4
Dose ranging
5
Adaptive enrichment
From words to code · two-arm design
Describe the design. The code follows.
two_arm.R
Trial design chat
Describe your trial design…
web embed
web embed
web embed
web embed
web embed
web embed
web embed
web embed
locked_data
×
Filter
1 2 3 4 5 6 7 8
patient_id
1 2 3 4 5 6 7 8
arm
placebo treatment placebo treatment treatment placebo placebo treatment
enroll_time
0.04 0.08 0.12 0.16 0.20 0.24 0.28 0.32
dropout_time
231.55 22.89 231.20 165.07 119.20 96.31 65.65 80.56
pfs
1.91 3.55 11.80 6.86 11.72 4.87 8.79 2.78
pfs_event
1 1 0 1 0 1 1 1
Showing 1 to 8 of 298 entries, 6 total columns
Browse[1]> View(locked_data)
From words to code · two-arm design
Describe the design. The code follows.
two_arm.R
interim_action.R
final_action.R
1 2 3 4 5 6 7
final_action <- function(trial){   locked_data <- trial$get_locked_data('final')   fit <- fitLogrank(Surv(pfs, pfs_event) ~ arm,                     placebo = 'placebo', data = locked_data,                     alternative = 'less')   trial$save(value = fit$p, name = 'p_final') }
Trial design chat
Add a treatment arm, same as placebo but with a median PFS of 7.5 months.
Enroll 400 patients at 25 per month, randomized 1:1. About 10% drop out by month 12.
Interim analysis at 150 PFS events. Stop for efficacy if the one-sided log-rank p-value is below 0.005.
Final analysis at 250 PFS events, with the same log-rank test.
Describe your trial design…
web embed
From words to code · seamless adaptive design
Seamless: select a dose, keep the trial
seamless.R
Trial design chat
Describe your trial design…
web embed
web embed
web embed
web embed
web embed
web embed
web embed
web embed
From words to code · response-adaptive randomization
Response-adaptive: shift patients to doses that work
response_adaptive.R
Trial design chat
Describe your trial design…
web embed
web embed
web embed
web embed
web embed
web embed
web embed
From words to code · dose-ranging study
Dose ranging: open more doses after a signal
dose_ranging.R
Trial design chat
Describe your trial design…
web embed
web embed
web embed
web embed
web embed
web embed
From words to code · adaptive enrichment
Enrichment: follow the biomarker, re-size the trial
enrichment.R
Trial design chat
Describe your trial design…
web embed
web embed
web embed
web embed
web embed
web embed
web embed
web embed
Chapter · Crossover
Three kinds of crossover, none replaces another
Planned, with wash-out
Who decides
The protocol: each sequence is fixed at randomization.
arm(name = 'ABCD') endpoint(readout = ...)
Periods and wash-outs are readout times.
QR code linking to the vignette Crossover with wash-out periods
Vignette: Crossover with wash-out periods
Dynamic treatment switching
Who decides
Each patient's own course: progression, non-response, deterioration.
regimen(what, when, how) trial$add_regimen()
Applied as patients are generated (T = 0).
QR code linking to the vignette Dynamic treatment switching
Vignette: Dynamic treatment switching
Crossover at a milestone
Who decides
The trial: an interim result opens switching for patients still on study.
trial$crossover(what, how,   when, delay)
Opens at T = milestone time + delay.
QR code linking to the vignette Crossover at a milestone
Vignette: Crossover at a milestone
The last two share one engine: what() → when() → how(), opened at time T.
Crossover · planned, with wash-out
A planned crossover is a longitudinal design
readouts <- c(baseline1 = 0,    ep1 = 3,               baseline2 = 4,    ep2 = 8,               baseline3 = 10,   ep3 = 15,               baseline4 = 17.5, ep4 = 19.5)   eps <- endpoint(name = names(readouts),                 type = rep('non-tte', 8),                 readout = readouts,                 generator = rng, means = rep(c(0, .5), 4))   arm1 <- arm(name = 'ABCD') arm1$add_endpoints(eps) ## BDAC, CADB, DCBA: same code, other means trial$add_arms(sample_ratio = c(1, 1, 1, 1),                arm1, arm2, arm3, arm4)   final <- milestone(name = 'final',   when = enrollment(n = 60, min_treatment_duration = 19.5),   action = action)
Balanced Latin square, 60 patients
ABCD
A
B
C
D
BDAC
B
D
A
C
CADB
C
A
D
B
DCBA
D
C
B
A
0
5
10
15
20
time from randomization; gaps are wash-outs
baseline1 = 0
baseline2 = 4
baseline3 = 10
baseline4 = 17.5
Markers on the timeline
ep1 = 3
ep2 = 8
ep3 = 15
ep4 = 19.5
Markers on the timeline
Each period starts with a baseline: baseline1 … baseline4, read out at 0, 4, 10 and 17.5.
Each period ends with an endpoint: ep1 … ep4, read out at 3, 8, 15 and 19.5. Eight non-tte endpoints, each with its readout time.
Each sequence is an arm; its generator can carry period and carry-over effects.
final: last patient at 19.5
Markers on the timeline
One milestone: all 60 enrolled and treated for 19.5. No switching rule is needed: the sequence is the switch.
Crossover · dynamic treatment switching
Three questions, three functions
what <- function(patient_data){   switch_to <- sample(c('low', 'high', 'stay'), nrow(patient_data),                       replace = TRUE, prob = c(.3, .4, .3))   is_switcher <- patient_data$arm == 'placebo' &     patient_data$pfs < patient_data$os & switch_to != 'stay'   sw <- patient_data[is_switcher, ]   data.frame(patient_id = sw$patient_id,     new_treatment = ifelse(switch_to[is_switcher] == 'low',                            'low dose', 'high dose')) } when <- function(patient_data){   data.frame(patient_id = patient_data$patient_id,              switch_time = patient_data$pfs) } how <- function(patient_data){   f <- ifelse(patient_data$new_treatment == 'low dose', 1.1, 1.15)   data.frame(patient_id = patient_data$patient_id,     os = patient_data$switch_time +          f * (patient_data$os - patient_data$switch_time)) } trial <- trial(...) trial$add_regimen(regimen(what, when, how))  # before add_arms()
what()
Who switches, and to what
Placebo patients who progress before death; 30% to low dose, 40% to high, 30% stay. Returns patient_id and new_treatment.
when()
When, from enrollment
At progression. Returns patient_id and switch_time.
how()
What changes after
Only post-switch outcomes: residual OS × 1.1 (low) or × 1.15 (high). Returns patient_id and the changed endpoints.
Patient 9, placebo, from this run
placebo · progression at 2.68 = switch
OS 21.65 →
24.50
before the switch: kept
high dose after the switch at 2.68
Registered before add_arms(): the triplet runs on every batch of patients as it is generated (T = 0).
Crossover · at a milestone
Crossover opened by an interim decision
action <- function(trial){   locked <- trial$get_locked_data('interim')   ## ... analysis and decision ...   trial$crossover(what = what, when = when,                   how = how, delay = 0) } what <- function(patient_data){   sw <- patient_data[patient_data$arm == 'control', ]   data.frame(patient_id = sw$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)) }
#29 control
died before T
not passed to what()
#1 control
dropped out before T
not passed to what()
#5 control
progressed before T
#8 control
progresses after T
#4 trt
treatment arm
eligible, not selected
#182 control
enrolls after T
0
6
12
18
24
30
calendar time (months)
T = 12: interim
The interim opens crossover at T. Patients with nothing left to change by T never reach what().
what(): control patients still on study, and those who enroll later, are selected.
when(): at progression, but not before T.
how(): only survival after the switch is stretched (×1.3).
Crossover · what the trio receives
Each function sees only the patients it should
funnel band
200
funnel band
175
funnel band
86
funnel band
86
Trial data at the interim, T = 12
180 enrolled and 20 who enroll later; values as generated.
Eligible → passed to what()
The package drops 25 with nothing left to change (15 died, 10 dropped out) and keeps later enrollees. You add only the clinical rule: arm == 'control'.
Switchers → passed to when()
Exactly the 86 rows what() returned. One switch_time each, not before T (earliest_crossover_time_from_enrollment is there to help).
Switchers, timed → passed to how()
The same 86 rows, with new_treatment and switch_time added. Change post-switch outcomes only; dropout and data cuts are re-applied for you.
−25
Same machinery at enrollment (add_regimen, T = 0): 300 → 300 → 48 → 48. Everyone generated is eligible; what() does all the selecting.
Crossover · what the trio returns
Outputs are checked on the way back
what()
returns
✓  a data frame with patient_id, new_treatment ✓  no duplicated patient_id ✓  only patients from the eligible pool ✓  labels without '@' or ';' ✓  NA new_treatment: no switch
when()
returns
✓  patient_id, switch_time ✓  no duplicated patient_id ✓  a time for every switcher, no NA ✓  enroll_time + switch_time ≥ T
how()
returns
✓  patient_id and the changed endpoints ✓  only switchers ✓  arm, enroll_time, dropout_time and the history are protected ✓  a changed pre-switch value is an error ✓  dropout and data cuts re-applied by the package
> how <- function(patient_data) >   data.frame(patient_id = patient_data$patient_id, >              pfs = patient_data$pfs * 0.9)   # a mistake
Error: how() returned a changed value for a pre-switch/locked cell on endpoint <pfs> for patient(s) <5, 9, 12, 13, 22, 28, 31, 35, 49, 52>. Only post-switch outcomes may be altered.
A wrong trio stops the run and names the patients, instead of quietly biasing every operating characteristic.
Chapter · An agent skill
clinical-trial-simulation: an agent skill
A trial brief in plain words goes in; code, a simulation run and a QC-ready report come out. The agent brings the engineering; the skill brings TrialSimulator.
SKILL.md
A folder of instructions and references. The agent reads it when a task needs it.
install
Ask the agent in plain words: it fetches the folder and puts it where it looks for skills.
design
A thinking framework, not a script: the plan comes first, questions are asked in clinical terms, no step is silent, and the report is written for QC.
QR code linking to the clinical-trial-simulation skill on GitHub
clinical-trial-simulation · scan to open the skill on GitHub
https://github.com/RConsortium/pharma-skills/tree/main/clinical-trial-simulation
R Consortium pharma-skills · version 0.4.2 · needs TrialSimulator ≥ 1.39.0 · MIT licence · pre-release, under active development
Then: what a skill is, how to install it, its design, a real run replayed, its report, and how to benchmark it.
Illustration: Linus Torvalds and a robot labelled Claude at a laptop with a Tux sticker, above a GitHub panel of torvalds/linux showing the branch noref-filemap-read-with-llm
Linus is now vibe coding
“
The code, the test programs, the review behind the list above and the first draft of this text were done with an LLM coding assistant (Claude Code) under my direction. The measurements were run on my machine.”
— Linus Torvalds, commit message, 10 Oct 2026
Branch noref-filemap-read-with-llm, “mm: read from the page cache without folio references”. Eight threads reading one cached file: about 10 million → 30–33 million reads per second on his laptop.
Illustration adapted from 新智元 (WeChat); background replaced.
An agent skill · what a skill is
A skill is a folder the agent reads when needed
clinical-trial-simulation/ ├── SKILL.md                 # framework, conversation, build order ├── references/ │   ├── building_blocks.md   # endpoint, arm, trial, milestone, … │   ├── helpers.md           # generators, solvers, wrappers, gotchas │   ├── report.md            # how to write the report (editable) │   ├── code_snippets.md     # GSD, promising zone, MAMS, … │   ├── graphical_testing.md # if graphical testing is chosen │   └── parallel_targets.md  # if a run must be parallel └── scripts/     └── conversation_log.R   # renders the conversation log
--- name: clinical-trial-simulation description: >   Design and simulate clinical trials using the   TrialSimulator R package and produce a QC-ready   build-order-spine report … metadata:   version: 0.4.2   trialsimulator_min_version: "1.39.0" ---
Always in view
Only the frontmatter above: the agent matches tasks against the description.
Read at load
SKILL.md (787 lines) and three core references: building blocks, helpers, report (500, 491 and 582 lines).
Read on demand
Worked designs, graphical testing, parallel runs: only when the design needs them.
Run, not read
Scripts: here, the one that renders the conversation log for the report.
An agent skill is a folder with a SKILL.md: a name and a description on top, instructions below, other files beside it. The format is open, the Agent Skills specification, and Claude Code reads it, as do compatible agents.
Loading in layers keeps the context small: the agent pays for what the task needs, and report.md is the place where an organization encodes its own reporting standards.
An agent skill · install
Install it by asking
Ask in plain words
> Install the clinical-trial-simulation skill from   https://github.com/RConsortium/pharma-skills/tree/main/   clinical-trial-simulation
Where it goes
The agent fetches the folder and puts it where it looks for skills: for Claude Code, ~/.claude/skills/ for every project, or .claude/skills/ inside one project.
What it needs
R ≥ 4.1; TrialSimulator ≥ 1.39.0 from GitHub; survival; rpact or gsDesign; markdown. Only for some designs: simdata, DoseFinding.
How to use it
Describe a trial: the agent loads the skill when the task matches its description, or ask for it by name.
At load it checks the installed TrialSimulator, without a network call, and says so first:
● clinical-trial-simulation v0.4.2 loaded — TrialSimulator 1.39.0, R 4.5.2
Older or missing: it offers remotes::install_github("zhangh12/TrialSimulator") and waits for a yes.
QR code linking to the clinical-trial-simulation skill on GitHub
github.com/RConsortium/pharma-skills clinical-trial-simulation
The skill on GitHub
R Consortium pharma-skills · v0.4.2 · MIT
An agent skill · design
A thinking framework, not a script
“This skill is a thinking framework, a cached API reference, a TrialSimulator-specific function catalog, and a report-writing guide. It is not a script.” — SKILL.md
1
Blocks, not templates
There is no design-type object. Listen for what the user wants to learn, pick the blocks that answer it, then collect their arguments.
2
Clinical words
Questions and confirmations are in clinical and statistical terms; package vocabulary is kept for the report, debugging and pasted code.
3
The plan comes first
Then three gates: the plan, the resolved parameter table, and the save plan, which value is saved where for each question asked.
4
Nothing silent
Every step is announced. Unused inputs are called out. A referenced SAP or protocol is not read without asking.
5
No replicate stops early
Every replicate runs all milestones; stopping is applied afterwards from saved flags, so one run scores several stopping rules.
6
Validate in rounds
Sanity (3–5 replicates), calibration (20–50), production (1,000+ for power, 10,000+ for type I error). Parallel only through targets.
An agent skill · build order
One build order for every script
“Always assemble in this order. Each step depends on the previous.” — SKILL.md
1
endpoint()
define each endpoint per arm × endpoint
2
arm()
one per treatment arm
arm$add_endpoints()
attach endpoints
3
trial()
sample size, accrual, dropout, stratification
regimen()
(optional) build a treatment-switching regimen
trial$add_regimen()
(optional) attach it; must precede add_arms
trial$add_arms()
attach arms with sample ratios
4
milestone() × M
one per action point, in chronological order
5
listener()
listener$add_milestones()
6
controller(trial, listener)
7
controller$run()
8
controller$get_output()
retrieve simulation results
An agent skill · design
The user hears statistics, not package names
Avoid: package vocabulary
Prefer: clinical and statistical terms
use fitLogrank for the OS test
→
OS is tested with a one-sided log-rank test
milestone fires at enrollment(n=500, min_treatment_duration=6)
→
the analysis is performed 6 months after the last patient is enrolled
boundary z = 2.523 from asOF spending
→
interim efficacy boundary z = 2.523 (Lan-DeMets O'Brien-Fleming spending, IF = 0.71)
StaggeredRecruiter with accrual_rate = data.frame(...)
→
piecewise-constant accrual: 5/mo for the first 3 months, 15/mo for the next 3, then 25/mo until enrollment completes
use CorrelatedPfsAndOs2
→
PFS and OS are modeled jointly via a Gumbel copula with Kendall's τ = 0.5 and exponential margins
Code words are for three places only: the report, a debugging session, and code the user pasted.
An agent skill · the run
One run: four inputs, twelve minutes
01:59
02:01
02:03
02:05
02:07
02:09
02:11
user
agent
01:59  /simulate
01:59  the brief, pasted
02:00  the plan
parameter table v0, 4 questions, 1 unused input
02:04  answers + “go”
02:04  boundaries, dropout fit
rpact: 200 / 333 PFS events
02:05  gates 2 and 3
τ = 0.523; parameter table; save plan
02:06  main.R, actions.R
sanity run, 5 per scenario
02:09  simulation done
2 × 1,000 replicates in 25 s
02:11  report opened
Q1–Q3 answered
02:11  /cost pasted, report updated
12 m 42 s
wall clock
7 m 07 s
API time
$2.60
total cost
650
lines of code added
4
user inputs
Agentic skill · the run, replayed
Load the skill: a version check, then questions
claude — ~/pharma-skills
✻ Welcome to Claude Code!
  /help for help, /status for your current setup
  cwd: ~/pharma-skills
> 
? for shortcuts
web embed
web embed
web embed
web embed
Agentic skill · the run, replayed
Paste the brief. The first answer is the plan.
claude — ~/pharma-skills
✻ Welcome to Claude Code!
  /help for help, /status for your current setup
  cwd: ~/pharma-skills
> 
? for shortcuts
web embed
web embed
web embed
web embed
web embed
web embed
web embed
web embed
Agentic skill · the run, replayed
Paste the brief. The first answer is the plan.
claude — ~/pharma-skills
  │ One-sided α                 │ 0.025                             │ protocol (derived): two-sided 0.05 │
  │                             │                                   │ / 2                                │
  ├─────────────────────────────┼───────────────────────────────────┼────────────────────────────────────┤
  │ PFS final event count       │ ≈ 335 for 90% power at HR 0.70    │ derived (pending), boundary script │
  ├─────────────────────────────┼───────────────────────────────────┼────────────────────────────────────┤
  │ PFS interim event count     │ ≈ 201 (60% of final)              │ derived (pending)                  │
  ├─────────────────────────────┼───────────────────────────────────┼────────────────────────────────────┤
  │ PFS efficacy boundaries (z) │ interim / final                   │ derived (pending): LD-OBF spending │
  ├─────────────────────────────┼───────────────────────────────────┼────────────────────────────────────┤
  │ OS analysis                 │ at last patient randomized + 24   │ protocol                           │
  │                             │ mo, z vs 1.960, only if PFS       │                                    │
  │                             │ rejected                          │                                    │
  ├─────────────────────────────┼───────────────────────────────────┼────────────────────────────────────┤
  │ Scenarios                   │ alternative (0.70 / 0.80), global │ protocol                           │
  │                             │ null (1 / 1), 1000 replicates     │                                    │
  │                             │ each                              │                                    │
> 
? for shortcuts
web embed
web embed
web embed
web embed
Agentic skill · the run, replayed
Answer four questions, then it runs to the report
claude — ~/pharma-skills
✻ Welcome to Claude Code!
  /help for help, /status for your current setup
  cwd: ~/pharma-skills
> 
? for shortcuts
web embed
web embed
web embed
web embed
web embed
web embed
web embed
web embed
Agentic skill · the run, replayed
Answer four questions, then it runs to the report
claude — ~/pharma-skills
  │                                 │ censors both endpoints)         │                                  │
  ├─────────────────────────────────┼─────────────────────────────────┼──────────────────────────────────┤
  │ One-sided α                     │ 0.025                           │ protocol (derived)               │
  ├─────────────────────────────────┼─────────────────────────────────┼──────────────────────────────────┤
  │ PFS interim / final             │ 200 / 333 events (IF = 0.6006)  │ derived (rpact)                  │
  ├─────────────────────────────────┼─────────────────────────────────┼──────────────────────────────────┤
  │ PFS efficacy boundaries         │ z ≥ 2.6671 at the interim, z ≥  │ derived (rpact, LD-OBF)          │
  │                                 │ 1.9811 at the final             │                                  │
  ├─────────────────────────────────┼─────────────────────────────────┼──────────────────────────────────┤
  │ OS analysis                     │ last patient randomized + 24    │ protocol                         │
  │                                 │ mo, z ≥ 1.9600, only if PFS was │                                  │
  │                                 │ rejected                        │                                  │
  └─────────────────────────────────┴─────────────────────────────────┴──────────────────────────────────┘
  Gate 3: what gets recorded at each analysis
> 
? for shortcuts
web embed
web embed
web embed
web embed
web embed
web embed
web embed
Agentic skill · the run, replayed
Paste /cost: the report records what the run cost
claude — ~/pharma-skills
✻ Welcome to Claude Code!
  /help for help, /status for your current setup
  cwd: ~/pharma-skills
> 
? for shortcuts
web embed
web embed
An agent skill · the report
The report follows the build order
runs/mcrc_pfs_os/report.html
Simulation Report: Phase 3 First-Line mCRC, PFS Primary with Hierarchical OS
Table of Contents
§0 Resource Utilization
§0.5 Output Files and Reproduction
§1 Design Rationale
§2 Design Parameters
§2.5 Decision Boundary Derivation
§3 Treatment Arms and Endpoints
§4 Trial Configuration
§5 Milestones
§6 Milestone Actions
§7 Operating Characteristics
§8 Limitations and Assumptions
Cost, versions, files
Tokens, cost ($2.60), time (12:42), TrialSimulator 1.39.0, R 4.5.2, skill 0.4.2, rpact 4.4.0; the file tree and the five commands that reproduce the run.
One section per building block
In the build order: rationale, parameters with sources, boundaries, arms and endpoints, trial, milestones, actions. Code is copied verbatim from the scripts, next to its rationale.
Answers next to the questions
Each research question is quoted above the table that answers it, with Monte Carlo SEs and the saved value behind each number.
Limitations
Every assumption the user confirmed or the agent made, in one place.
An agent skill · the report
Every literal carries its source
runs/mcrc_pfs_os/report.html#sec:2-design-parameters
§2 Design Parameters
Parameter
Value
Source / Notes
Accrual
12/mo for months 0–3, 24/mo for 3–6, 28.5/mo thereafter; 450th patient at month 18
assumed: illustrative ramp-up; accepted at design confirmation
OS, SOC
exponential, 70% alive at 12 months → median 23.3203 months
protocol (derived)
PFS–OS dependence
Kendall's τ = 0.523; uncensored Pearson 0.602 (SOC) / 0.597 (experimental)
derived from the protocol's Pearson 0.6 — see pfs_os_correlation
Loss to follow-up
Weibull, shape 0.83188, scale 213.195 months; one dropout time censors PFS and OS
derived from 5% by month 6 and 15% by month 24 — see dropout
One-sided α
0.025
protocol (derived): two-sided 0.05 / 2
PFS final / interim events
333 / 200 (IF = 0.6006)
derived from boundaries.R (rpact)
PFS efficacy boundary
interim z ≤ −2.6671; final z ≤ −1.9811
derived from boundaries.R (rpact, LD-OBF)
OS test
one-sided log-rank at z ≤ −1.9600, only if PFS rejected
protocol (fixed-sequence hierarchy)
protocol
stated in the brief, or computed from it
assumed
chosen by the agent and confirmed by the user
derived
computed by a script; the script and its rendered supplement are linked
An agent skill · the report
Code next to its rationale
action_pfs_fa <- function(trial, ...) {   # Data locked at the 333rd PFS event   data <- trial$get_locked_data(milestone_name = "pfs_fa")     lr <- fitLogrank(     formula     = Surv(pfs, pfs_event) ~ arm,     placebo     = "soc",     data        = data,     alternative = "less"   )     # Final efficacy decision against z = 1.9811 (nominal one-sided p = 0.02379)   reject_fa <- as.integer(lr$z <= -1.9811)   trial$save(value = lr$z, name = "z_pfs_fa")   trial$save(value = reject_fa, name = "reject_pfs_fa")     # PFS is significant if rejected at the interim OR (having continued)   # at the final. Read the interim decision saved earlier in this replicate.   # Feeds PFS power and gates the OS test.   reject_ia <- trial$get_output(cols = "reject_pfs_ia", simplify = TRUE)   trial$save(value = as.integer(reject_ia == 1L | reject_fa == 1L), name = "reject_pfs") }
Trigger and data lock
333 PFS events; all 450 patients randomized; PFS observed up to the lock.
Analysis
Unstratified one-sided log-rank test of PFS, compared with z = −1.9811.
Saved
z_pfs_fa (diagnostic) and reject_pfs_fa, the final decision in every replicate.
Saved, for the hierarchy
reject_pfs: rejected at the interim or the final. Feeds PFS power and gates the OS test.
An agent skill · the report
Each answer sits under its question
Q1
“The power for PFS, and the probability of stopping for efficacy at the PFS interim analysis.”
PFS power (rejected at interim or final)
0.896
MCSE 0.0097
reject_pfs
P(stop for efficacy at PFS interim)
0.438
MCSE 0.0157
reject_pfs_ia
rpact, analytic: 0.900 and 0.4424, within 1 MCSE
Q2
“The power for OS under this hierarchical testing strategy.”
OS power, hierarchical
0.398
MCSE 0.0155
reject_os
OS rejection ignoring the PFS gate
0.403
MCSE 0.0155
reject_os_raw
about 233 deaths by month 42: OS is underpowered at HR 0.80
Q3
“The expected calendar times of the PFS interim analysis, the PFS final analysis, and the OS final analysis.”
PFS interim (mean, months)
18.35
MCSE 0.02
milestone time
PFS final, trials not stopped at the interim
29.41
MCSE 0.06
milestone time
OS final
42.00
MCSE 0.00
milestone time
the PFS final happens only in the 56% of trials that continue
Global null: PFS type I error 0.031 (MCSE 0.0055); OS under the hierarchy 0.006. At 1,000 replicates error control is checked only coarsely, and the report says so.
Thank you
Questions, ideas and bug reports are all welcome.
QR code linking to the TrialSimulator issue tracker on GitHub
Open an issue
Found a bug, missing a feature, stuck on a design?
github.com/zhangh12/TrialSimulator/issues
Scan the code, or type the address.
Han Zhang
Innovative Statistical Science, Astellas
zhangh12.github.io/TrialSimulator
TrialSimulator hex logo
Go to page