|
| 1 | +#' Condition: Stateful trigger and analysis unit |
| 2 | +#' |
| 3 | +#' @description |
| 4 | +#' A `Condition` encapsulates a single trigger rule that is evaluated against |
| 5 | +#' a data snapshot at each simulated timepoint. It combines three concerns: |
| 6 | +#' |
| 7 | +#' \enumerate{ |
| 8 | +#' \item **Filtering** — a `dplyr::filter()` expression selects the rows |
| 9 | +#' relevant to this condition (e.g. "only enrolled subjects in arm A"). |
| 10 | +#' \item **Analysis** — an optional function transforms the filtered snapshot |
| 11 | +#' into a result (e.g. a t-test, a subject count, a Go/No-Go decision). |
| 12 | +#' \item **Trigger bookkeeping** — the condition fires only when the |
| 13 | +#' filtered data is non-empty, the cooldown period has elapsed since the |
| 14 | +#' last trigger, and the maximum trigger count has not been reached. |
| 15 | +#' } |
| 16 | +#' |
| 17 | +#' `Condition` objects are stored in `trial$conditions` and evaluated by |
| 18 | +#' [`Trial`]`$run()` at each timepoint. |
| 19 | +#' |
| 20 | +#' @details |
| 21 | +#' **Three-gate logic.** A trigger fires only when all three gates pass: |
| 22 | +#' \enumerate{ |
| 23 | +#' \item The filtered snapshot contains at least one row. |
| 24 | +#' \item `current_time - last_trigger_time >= cooldown` (or the condition |
| 25 | +#' has never fired before). |
| 26 | +#' \item `trigger_count < max_triggers`. |
| 27 | +#' } |
| 28 | +#' If any gate fails, `check_conditions()` returns an empty list and state |
| 29 | +#' is not updated. |
| 30 | +#' |
| 31 | +#' On a successful trigger, the condition calls |
| 32 | +#' `analysis(filtered_data, current_time)` and stores the result under |
| 33 | +#' `name` (or `1L` when no name is set). If no analysis function is |
| 34 | +#' provided, the filtered data frame is returned as-is with a warning. |
| 35 | +#' |
| 36 | +#' @section Fields: |
| 37 | +#' \describe{ |
| 38 | +#' \item{\code{where}}{`list` of quosures (from `rlang::quos()`) used as |
| 39 | +#' `dplyr::filter()` predicates. Pass `NULL` or an empty list to skip |
| 40 | +#' filtering and pass the full snapshot to the analysis.} |
| 41 | +#' \item{\code{analysis}}{`function` or `NULL`. Called as |
| 42 | +#' `analysis(filtered_data, current_time)` on a successful trigger. |
| 43 | +#' Should return a `data.frame` or named list. If `NULL`, the filtered |
| 44 | +#' data frame is returned with a warning.} |
| 45 | +#' \item{\code{name}}{`character` or `NULL`. Key used to label the result |
| 46 | +#' in the returned list. Falls back to `1L` when `NULL`.} |
| 47 | +#' \item{\code{cooldown}}{`numeric`. Minimum time units that must elapse |
| 48 | +#' between consecutive triggers. Default `0` (no cooldown).} |
| 49 | +#' \item{\code{max_triggers}}{`integer`. Maximum number of times this |
| 50 | +#' condition may fire. Use `Inf` for unlimited. Default `1L`.} |
| 51 | +#' \item{\code{trigger_count}}{`integer`. Number of successful triggers so |
| 52 | +#' far. Initialised to `0L`.} |
| 53 | +#' \item{\code{last_trigger_time}}{`numeric`. Calendar time of the most |
| 54 | +#' recent successful trigger. Initialised to `NA_real_`.} |
| 55 | +#' } |
| 56 | +#' |
| 57 | +#' @section Methods: |
| 58 | +#' \describe{ |
| 59 | +#' \item{\code{$new(where, analysis, name, cooldown, max_triggers)}}{ |
| 60 | +#' Construct a new `Condition`. All arguments except `where` are |
| 61 | +#' optional. `cooldown` must be a single non-negative number; |
| 62 | +#' `max_triggers` must be a single non-negative integer or `Inf`.} |
| 63 | +#' \item{\code{$check_conditions(locked_data, current_time)}}{ |
| 64 | +#' Evaluate the condition against `locked_data` at `current_time`. |
| 65 | +#' Returns a named `list` containing the analysis result (or filtered |
| 66 | +#' data frame) if the condition fires, or an empty `list` otherwise. |
| 67 | +#' On a successful trigger, `trigger_count` is incremented and |
| 68 | +#' `last_trigger_time` is updated.} |
| 69 | +#' } |
| 70 | +#' |
| 71 | +#' @seealso |
| 72 | +#' \itemize{ |
| 73 | +#' \item [`Timer`] for managing trial timepoints |
| 74 | +#' \item [`Trial`] for running the simulation and iterating over conditions |
| 75 | +#' \item [`trigger_by_calendar()`] and [`trigger_by_fraction()`] for |
| 76 | +#' convenient `Condition` constructors |
| 77 | +#' \item [`dplyr::filter()`] for predicate syntax |
| 78 | +#' } |
| 79 | +#' |
| 80 | +#' @examples |
| 81 | +#' # Build a snapshot data frame |
| 82 | +#' snapshot <- data.frame( |
| 83 | +#' arm = c("A", "A", "A", "B"), |
| 84 | +#' status = c("active", "active", "active", "active"), |
| 85 | +#' stringsAsFactors = FALSE |
| 86 | +#' ) |
| 87 | +#' |
| 88 | +#' # Analysis function: count active subjects per arm |
| 89 | +#' count_fn <- function(dat, current_time) { |
| 90 | +#' data.frame(n_active = nrow(dat), fired_at = current_time) |
| 91 | +#' } |
| 92 | +#' |
| 93 | +#' # Condition fires once when arm A has active subjects (max_triggers = 1) |
| 94 | +#' cond <- Condition$new( |
| 95 | +#' where = rlang::quos(arm == "A", status == "active"), |
| 96 | +#' analysis = count_fn, |
| 97 | +#' name = "interim_A", |
| 98 | +#' cooldown = 0, |
| 99 | +#' max_triggers = 1L |
| 100 | +#' ) |
| 101 | +#' |
| 102 | +#' # First call: fires and returns analysis result |
| 103 | +#' res <- cond$check_conditions(snapshot, current_time = 5) |
| 104 | +#' res[["interim_A"]] # data.frame(n_active = 3, fired_at = 5) |
| 105 | +#' |
| 106 | +#' # Second call: does not fire (max_triggers already reached) |
| 107 | +#' res2 <- cond$check_conditions(snapshot, current_time = 6) |
| 108 | +#' length(res2) # 0 |
| 109 | +#' |
| 110 | +#' @importFrom dplyr filter |
| 111 | +#' @export |
| 112 | +Condition <- R6::R6Class( |
| 113 | + classname = "Condition", |
| 114 | + |
| 115 | + public = list( |
| 116 | + # --- fields --- |
| 117 | + #' @field where `list` of quosures (`rlang::quos()`) used as `dplyr::filter()` |
| 118 | + #' predicates. `NULL` or empty list passes the full snapshot. |
| 119 | + where = NULL, |
| 120 | + |
| 121 | + #' @field analysis `function` or `NULL`. Called as |
| 122 | + #' `analysis(filtered_data, current_time)` on a successful trigger. |
| 123 | + analysis = NULL, |
| 124 | + |
| 125 | + #' @field name `character` or `NULL`. Key labelling the result in the output |
| 126 | + #' list. Falls back to `1L` when `NULL`. |
| 127 | + name = NULL, |
| 128 | + |
| 129 | + #' @field cooldown `numeric`. Minimum time units between consecutive |
| 130 | + #' triggers. Default `0`. |
| 131 | + cooldown = 0, |
| 132 | + |
| 133 | + #' @field max_triggers `integer` or `Inf`. Maximum number of times this |
| 134 | + #' condition may fire. Default `1L`. |
| 135 | + max_triggers = 1L, |
| 136 | + |
| 137 | + #' @field trigger_count `integer`. Number of successful triggers so far. |
| 138 | + #' Initialised to `0L`. |
| 139 | + trigger_count = 0L, |
| 140 | + |
| 141 | + #' @field last_trigger_time `numeric`. Calendar time of the most recent |
| 142 | + #' successful trigger. `NA_real_` until first trigger. |
| 143 | + last_trigger_time = NA_real_, |
| 144 | + |
| 145 | + # --- constructor --- |
| 146 | + #' @description |
| 147 | + #' Create a new `Condition` instance. |
| 148 | + #' |
| 149 | + #' @param where `list` of quosures (from `rlang::quos()`) used as filter |
| 150 | + #' predicates. Pass `NULL` or omit to use the full snapshot. |
| 151 | + #' @param analysis `function` or `NULL`. Called as |
| 152 | + #' `analysis(filtered_data, current_time)` on a successful trigger. |
| 153 | + #' @param name `character` or `NULL`. Result key. Defaults to `1L`. |
| 154 | + #' @param cooldown `numeric`. Minimum time between triggers. Default `0`. |
| 155 | + #' @param max_triggers `integer`. Maximum trigger count. Default `1L`. |
| 156 | + #' Use `Inf` for unlimited. |
| 157 | + #' |
| 158 | + #' @return A new `Condition` instance. |
| 159 | + initialize = function( |
| 160 | + where = NULL, |
| 161 | + analysis = NULL, |
| 162 | + name = NULL, |
| 163 | + cooldown = 0, |
| 164 | + max_triggers = 1L |
| 165 | + ) { |
| 166 | + self$where <- where |
| 167 | + self$analysis <- analysis |
| 168 | + self$name <- name |
| 169 | + |
| 170 | + cooldown <- as.numeric(cooldown) |
| 171 | + if (length(cooldown) != 1L || cooldown < 0 || is.na(cooldown)) { |
| 172 | + stop("`cooldown` must be a single non-negative number.") |
| 173 | + } |
| 174 | + |
| 175 | + if (length(max_triggers) == 1L && is.infinite(max_triggers) && max_triggers > 0) { |
| 176 | + # Inf means unlimited — keep as-is |
| 177 | + } else { |
| 178 | + max_triggers <- as.integer(max_triggers) |
| 179 | + if (length(max_triggers) != 1L || is.na(max_triggers) || max_triggers < 0L) { |
| 180 | + stop("`max_triggers` must be a non-negative integer (use Inf for unlimited).") |
| 181 | + } |
| 182 | + } |
| 183 | + |
| 184 | + self$cooldown <- cooldown |
| 185 | + self$max_triggers <- max_triggers |
| 186 | + }, |
| 187 | + |
| 188 | + # --- methods --- |
| 189 | + |
| 190 | + #' @description |
| 191 | + #' Evaluate this condition against a data snapshot. |
| 192 | + #' |
| 193 | + #' Applies the three-gate logic: non-empty filter result, cooldown |
| 194 | + #' elapsed, and trigger count below `max_triggers`. Returns the analysis |
| 195 | + #' result (or filtered data) on a successful trigger, or an empty list |
| 196 | + #' otherwise. |
| 197 | + #' |
| 198 | + #' @param locked_data `data.frame` The trial snapshot at the current time. |
| 199 | + #' @param current_time `numeric` Calendar time of the current timepoint. |
| 200 | + #' |
| 201 | + #' @return Named `list` with one entry (the analysis result) on success, |
| 202 | + #' or an empty `list` if the condition did not fire. |
| 203 | + check_conditions = function(locked_data, current_time) { |
| 204 | + stopifnot(is.data.frame(locked_data)) |
| 205 | + |
| 206 | + results <- list() |
| 207 | + |
| 208 | + key <- if (!is.null(self$name) && nzchar(self$name)) self$name else 1L |
| 209 | + |
| 210 | + # Filter snapshot (dplyr semantics: NA in predicates drops rows) |
| 211 | + df_i <- if (!is.null(self$where) && length(self$where) > 0) { |
| 212 | + dplyr::filter(locked_data, !!!self$where) |
| 213 | + } else { |
| 214 | + locked_data |
| 215 | + } |
| 216 | + |
| 217 | + # Gate 1: non-empty match |
| 218 | + if (nrow(df_i) == 0L) return(results) |
| 219 | + |
| 220 | + # Gate 2: hard cap on number of triggers |
| 221 | + if (is.finite(self$max_triggers) && self$trigger_count >= self$max_triggers) { |
| 222 | + return(results) |
| 223 | + } |
| 224 | + |
| 225 | + # Gate 3: cooldown |
| 226 | + if (is.finite(self$last_trigger_time)) { |
| 227 | + if ((current_time - self$last_trigger_time) < self$cooldown) { |
| 228 | + return(results) |
| 229 | + } |
| 230 | + } |
| 231 | + |
| 232 | + if (is.function(self$analysis)) { |
| 233 | + results[[key]] <- self$analysis(df_i, current_time) |
| 234 | + } else { |
| 235 | + results[[key]] <- df_i |
| 236 | + warning( |
| 237 | + sprintf( |
| 238 | + " returning filtered data as is because condition '%s' has no applicable analysis \n", |
| 239 | + key |
| 240 | + ), |
| 241 | + call. = FALSE |
| 242 | + ) |
| 243 | + } |
| 244 | + |
| 245 | + # Update trigger state after a successful trigger |
| 246 | + self$trigger_count <- self$trigger_count + 1L |
| 247 | + self$last_trigger_time <- current_time |
| 248 | + |
| 249 | + results |
| 250 | + } |
| 251 | + |
| 252 | + ) # end public |
| 253 | +) # end class |
0 commit comments