| 1 |
.evidence_migrations_path <- function() {
|
|
| 2 | 100x |
path <- system.file( |
| 3 | 100x |
"sql", "migrations", |
| 4 | 100x |
package = "ADaMViz.SubmissionSync" |
| 5 |
) |
|
| 6 | ! |
if (!nzchar(path)) path <- fs::path("inst", "sql", "migrations")
|
| 7 | 100x |
path |
| 8 |
} |
|
| 9 | ||
| 10 |
.read_sql_statements <- function(path) {
|
|
| 11 | 152x |
sql <- paste(readLines(path, warn = FALSE), collapse = "\n") |
| 12 | 152x |
trimws(strsplit(sql, ";", fixed = TRUE)[[1]]) |
| 13 |
} |
|
| 14 | ||
| 15 |
.sqlite_connect <- function(path) {
|
|
| 16 | 1711x |
con <- DBI::dbConnect(RSQLite::SQLite(), path) |
| 17 | 1711x |
DBI::dbExecute(con, "PRAGMA foreign_keys = ON") |
| 18 | 1711x |
DBI::dbExecute(con, "PRAGMA busy_timeout = 5000") |
| 19 | 1711x |
con |
| 20 |
} |
|
| 21 | ||
| 22 |
.initialize_evidence_database <- function(path) {
|
|
| 23 | 100x |
fs::dir_create(fs::path_dir(path), recurse = TRUE) |
| 24 | 100x |
con <- .sqlite_connect(path) |
| 25 | 100x |
on.exit(DBI::dbDisconnect(con), add = TRUE) |
| 26 | ||
| 27 | 100x |
DBI::dbExecute(con, paste( |
| 28 | 100x |
"CREATE TABLE IF NOT EXISTS schema_migrations(",
|
| 29 | 100x |
"version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)" |
| 30 |
)) |
|
| 31 | 100x |
migrations <- fs::dir_ls( |
| 32 | 100x |
.evidence_migrations_path(), |
| 33 | 100x |
regexp = "[0-9]+-.*[.]sql$", |
| 34 | 100x |
type = "file" |
| 35 |
) |
|
| 36 | 100x |
versions <- as.integer(sub("-.*$", "", fs::path_file(migrations)))
|
| 37 | 100x |
if (!length(migrations) || anyNA(versions) || anyDuplicated(versions)) {
|
| 38 | ! |
cli::cli_abort("Evidence migrations must have unique numeric versions")
|
| 39 |
} |
|
| 40 | 100x |
migrations <- migrations[order(versions)] |
| 41 | 100x |
versions <- sort(versions) |
| 42 | 100x |
applied <- DBI::dbGetQuery( |
| 43 | 100x |
con, |
| 44 | 100x |
"SELECT version FROM schema_migrations" |
| 45 | 100x |
)$version |
| 46 | ||
| 47 | 100x |
for (index in seq_along(migrations)) {
|
| 48 | 48x |
if (versions[[index]] %in% applied) next |
| 49 | 151x |
DBI::dbWithTransaction(con, {
|
| 50 | 151x |
statements <- .read_sql_statements(migrations[[index]]) |
| 51 | 151x |
for (statement in statements[nzchar(statements)]) {
|
| 52 | 1651x |
DBI::dbExecute(con, statement) |
| 53 |
} |
|
| 54 | 151x |
DBI::dbExecute( |
| 55 | 151x |
con, |
| 56 | 151x |
paste( |
| 57 | 151x |
"INSERT INTO schema_migrations(version, applied_at)", |
| 58 | 151x |
"VALUES (?, ?)" |
| 59 |
), |
|
| 60 | 151x |
params = list(versions[[index]], .utc_timestamp()) |
| 61 |
) |
|
| 62 |
}) |
|
| 63 |
} |
|
| 64 |
} |
|
| 65 | ||
| 66 |
.with_evidence_connection <- function(path, code) {
|
|
| 67 | 1137x |
con <- .sqlite_connect(path) |
| 68 | 1137x |
on.exit(DBI::dbDisconnect(con), add = TRUE) |
| 69 | 1137x |
code(con) |
| 70 |
} |
|
| 71 | ||
| 72 |
.with_repository_lock <- function(database_path, code) {
|
|
| 73 | 464x |
lock_path <- paste0(database_path, ".lock") |
| 74 | 464x |
fs::dir_create(fs::path_dir(lock_path), recurse = TRUE) |
| 75 | 464x |
con <- .sqlite_connect(lock_path) |
| 76 | 464x |
on.exit(DBI::dbDisconnect(con), add = TRUE) |
| 77 | 464x |
DBI::dbExecute( |
| 78 | 464x |
con, |
| 79 | 464x |
paste0( |
| 80 | 464x |
"CREATE TABLE IF NOT EXISTS repository_lock(id INTEGER PRIMARY KEY ", |
| 81 | 464x |
"CHECK(id = 1))" |
| 82 |
) |
|
| 83 |
) |
|
| 84 | 464x |
DBI::dbExecute(con, "INSERT OR IGNORE INTO repository_lock(id) VALUES (1)") |
| 85 | 464x |
DBI::dbExecute(con, "BEGIN IMMEDIATE") |
| 86 | 464x |
completed <- FALSE |
| 87 | 464x |
on.exit( |
| 88 |
{
|
|
| 89 | 44x |
if (!completed) try(DBI::dbExecute(con, "ROLLBACK"), silent = TRUE) |
| 90 |
}, |
|
| 91 | 464x |
add = TRUE |
| 92 |
) |
|
| 93 | 464x |
result <- code() |
| 94 | 420x |
DBI::dbExecute(con, "COMMIT") |
| 95 | 420x |
completed <- TRUE |
| 96 | 420x |
result |
| 97 |
} |
|
| 98 | ||
| 99 |
.register_command <- function(con, key, command) {
|
|
| 100 | 369x |
.validate_identifier(key, "idempotency_key") |
| 101 | 369x |
command_hash <- canonical_hash(command) |
| 102 | 369x |
prior <- DBI::dbGetQuery( |
| 103 | 369x |
con, |
| 104 | 369x |
"SELECT command_hash FROM command_keys WHERE idempotency_key = ?", |
| 105 | 369x |
params = list(key) |
| 106 |
) |
|
| 107 | 369x |
if (nrow(prior)) {
|
| 108 | 3x |
if (!identical(prior$command_hash[[1]], command_hash)) {
|
| 109 | ! |
cli::cli_abort("Idempotency key was already used for a different command")
|
| 110 |
} |
|
| 111 | 3x |
return(FALSE) |
| 112 |
} |
|
| 113 | 366x |
DBI::dbExecute( |
| 114 | 366x |
con, |
| 115 | 366x |
paste( |
| 116 | 366x |
"INSERT INTO command_keys(idempotency_key, command_hash, created_at)", |
| 117 | 366x |
"VALUES (?, ?, ?)" |
| 118 |
), |
|
| 119 | 366x |
params = list(key, command_hash, .utc_timestamp()) |
| 120 |
) |
|
| 121 | 366x |
TRUE |
| 122 |
} |
|
| 123 | ||
| 124 |
.current_revision <- function(con, revision_id) {
|
|
| 125 | 625x |
result <- DBI::dbGetQuery(con, paste( |
| 126 | 625x |
"SELECT r.*, p.status, p.version FROM revisions r", |
| 127 | 625x |
"JOIN revision_projection p USING(revision_id) WHERE r.revision_id = ?" |
| 128 | 625x |
), params = list(revision_id)) |
| 129 | 625x |
if (!nrow(result)) {
|
| 130 | 1x |
cli::cli_abort("Plot revision {.val {revision_id}} was not found")
|
| 131 |
} |
|
| 132 | 624x |
as.list(result[1, , drop = FALSE]) |> |
| 133 | 624x |
lapply(\(x) x[[1]]) |
| 134 |
} |
|
| 135 | ||
| 136 |
.append_lifecycle_event <- function( |
|
| 137 |
con, |
|
| 138 |
revision, |
|
| 139 |
event_type, |
|
| 140 |
payload, |
|
| 141 |
idempotency_key |
|
| 142 |
) {
|
|
| 143 | 171x |
last <- DBI::dbGetQuery( |
| 144 | 171x |
con, |
| 145 | 171x |
paste( |
| 146 | 171x |
"SELECT event_sequence, chain_hash FROM lifecycle_events", |
| 147 | 171x |
"WHERE plot_id = ? ORDER BY event_sequence DESC LIMIT 1" |
| 148 |
), |
|
| 149 | 171x |
params = list(revision$plot_id) |
| 150 |
) |
|
| 151 | 171x |
sequence <- if (nrow(last)) last$event_sequence[[1]] + 1L else 1L |
| 152 | 171x |
previous <- if (nrow(last)) {
|
| 153 | 94x |
last$chain_hash[[1]] |
| 154 |
} else {
|
|
| 155 | 77x |
paste(rep("0", 64L), collapse = "")
|
| 156 |
} |
|
| 157 | 171x |
created_at <- .utc_timestamp() |
| 158 | 171x |
payload_json <- canonical_serialize(payload) |
| 159 | 171x |
chain_hash <- canonical_hash(list( |
| 160 | 171x |
plot_id = revision$plot_id, |
| 161 | 171x |
revision_id = revision$revision_id, |
| 162 | 171x |
event_sequence = sequence, |
| 163 | 171x |
event_type = event_type, |
| 164 | 171x |
payload_json = payload_json, |
| 165 | 171x |
previous_hash = previous, |
| 166 | 171x |
created_at = created_at |
| 167 |
)) |
|
| 168 | 171x |
event_id <- paste0("evt-", substr(canonical_hash(idempotency_key), 1L, 24L))
|
| 169 | 171x |
DBI::dbExecute(con, paste( |
| 170 | 171x |
"INSERT INTO lifecycle_events(event_id, plot_id, revision_id,", |
| 171 | 171x |
"event_sequence, event_type,", |
| 172 | 171x |
"payload_json, previous_hash, chain_hash, created_at, idempotency_key)", |
| 173 | 171x |
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" |
| 174 | 171x |
), params = list( |
| 175 | 171x |
event_id, revision$plot_id, revision$revision_id, sequence, event_type, |
| 176 | 171x |
payload_json, previous, chain_hash, created_at, idempotency_key |
| 177 |
)) |
|
| 178 | 171x |
list( |
| 179 | 171x |
plot_id = revision$plot_id, |
| 180 | 171x |
sequence = sequence, |
| 181 | 171x |
chain_hash = chain_hash |
| 182 |
) |
|
| 183 |
} |
|
| 184 | ||
| 185 |
.read_chain_roots <- function(path) {
|
|
| 186 | 405x |
if (!fs::file_exists(path)) {
|
| 187 | 150x |
return(list()) |
| 188 |
} |
|
| 189 | 255x |
jsonlite::fromJSON(path, simplifyVector = TRUE) |
| 190 |
} |
|
| 191 | ||
| 192 |
.write_chain_root <- function(path, root) {
|
|
| 193 | 173x |
roots <- .read_chain_roots(path) |
| 194 | 173x |
prior <- roots[[root$plot_id]] |
| 195 | 173x |
if (!is.null(prior) && prior$sequence > root$sequence) {
|
| 196 | ! |
cli::cli_abort("External chain root is newer than the database history")
|
| 197 |
} |
|
| 198 | 173x |
if (!is.null(prior) && prior$sequence == root$sequence && |
| 199 | 173x |
!identical(prior$chain_hash, root$chain_hash)) {
|
| 200 | ! |
cli::cli_abort("External chain root conflicts with the database history")
|
| 201 |
} |
|
| 202 | 173x |
if (!is.null(prior) && prior$sequence == root$sequence) {
|
| 203 | 2x |
return(invisible(path)) |
| 204 |
} |
|
| 205 | 171x |
roots[[root$plot_id]] <- list( |
| 206 | 171x |
sequence = root$sequence, |
| 207 | 171x |
chain_hash = root$chain_hash |
| 208 |
) |
|
| 209 | 171x |
fs::dir_create(fs::path_dir(path), recurse = TRUE) |
| 210 | 171x |
temporary <- fs::file_temp( |
| 211 | 171x |
tmp_dir = fs::path_dir(path), |
| 212 | 171x |
pattern = "chain-root-" |
| 213 |
) |
|
| 214 | 171x |
writeLines(canonical_serialize(roots), temporary, useBytes = TRUE) |
| 215 | 171x |
fs::file_move(temporary, path) |
| 216 |
} |
|
| 217 | ||
| 218 |
.chain_tip <- function(con, plot_id) {
|
|
| 219 | 2x |
tip <- DBI::dbGetQuery( |
| 220 | 2x |
con, |
| 221 | 2x |
paste( |
| 222 | 2x |
"SELECT event_sequence, chain_hash FROM lifecycle_events", |
| 223 | 2x |
"WHERE plot_id = ? ORDER BY event_sequence DESC LIMIT 1" |
| 224 |
), |
|
| 225 | 2x |
params = list(plot_id) |
| 226 |
) |
|
| 227 | 2x |
if (!nrow(tip)) {
|
| 228 | ! |
return(NULL) |
| 229 |
} |
|
| 230 | 2x |
list( |
| 231 | 2x |
plot_id = plot_id, |
| 232 | 2x |
sequence = tip$event_sequence[[1]], |
| 233 | 2x |
chain_hash = tip$chain_hash[[1]] |
| 234 |
) |
|
| 235 |
} |
|
| 236 | ||
| 237 |
.reconcile_chain_roots <- function(database_path, chain_root_path) {
|
|
| 238 | 1x |
tips <- .with_evidence_connection(database_path, function(con) {
|
| 239 | 1x |
DBI::dbGetQuery(con, paste( |
| 240 | 1x |
"SELECT e.plot_id, e.event_sequence AS sequence, e.chain_hash", |
| 241 | 1x |
"FROM lifecycle_events e", |
| 242 | 1x |
"JOIN (SELECT plot_id, MAX(event_sequence) AS sequence", |
| 243 | 1x |
"FROM lifecycle_events GROUP BY plot_id) latest", |
| 244 | 1x |
"ON e.plot_id = latest.plot_id AND e.event_sequence = latest.sequence" |
| 245 |
)) |
|
| 246 |
}) |
|
| 247 | 1x |
if (nrow(tips)) {
|
| 248 | 1x |
for (index in seq_len(nrow(tips))) {
|
| 249 | 1x |
.write_chain_root(chain_root_path, as.list(tips[index, ])) |
| 250 |
} |
|
| 251 |
} |
|
| 252 | 1x |
invisible(chain_root_path) |
| 253 |
} |
|
| 254 | ||
| 255 |
.chain_roots_match <- function(database_path, chain_root_path) {
|
|
| 256 | 198x |
tips <- .with_evidence_connection(database_path, function(con) {
|
| 257 | 198x |
DBI::dbGetQuery(con, paste( |
| 258 | 198x |
"SELECT e.plot_id, e.event_sequence AS sequence, e.chain_hash", |
| 259 | 198x |
"FROM lifecycle_events e", |
| 260 | 198x |
"JOIN (SELECT plot_id, MAX(event_sequence) AS sequence", |
| 261 | 198x |
"FROM lifecycle_events GROUP BY plot_id) latest", |
| 262 | 198x |
"ON e.plot_id = latest.plot_id AND e.event_sequence = latest.sequence" |
| 263 |
)) |
|
| 264 |
}) |
|
| 265 | 198x |
roots <- .read_chain_roots(chain_root_path) |
| 266 | 198x |
root_plot_ids <- names(roots) %||% character() |
| 267 | 198x |
if (!identical(sort(root_plot_ids), sort(tips$plot_id))) {
|
| 268 | 3x |
return(FALSE) |
| 269 |
} |
|
| 270 | 195x |
for (index in seq_len(nrow(tips))) {
|
| 271 | 145x |
root <- roots[[tips$plot_id[[index]]]] |
| 272 | 145x |
if (!identical( |
| 273 | 145x |
as.integer(root$sequence), as.integer(tips$sequence[[index]]) |
| 274 |
) || |
|
| 275 | 145x |
!identical(root$chain_hash, tips$chain_hash[[index]])) {
|
| 276 | ! |
return(FALSE) |
| 277 |
} |
|
| 278 |
} |
|
| 279 | 195x |
TRUE |
| 280 |
} |
|
| 281 | ||
| 282 |
.review_decision_hash <- function(record) {
|
|
| 283 | 59x |
canonical_hash(record[c( |
| 284 | 59x |
"decision_id", "revision_id", "actor_id", "role", "decision", |
| 285 | 59x |
"authorization_json", "comment", "code_hash", "image_hash", |
| 286 | 59x |
"analytical_hash", "created_at", "idempotency_key" |
| 287 |
)]) |
|
| 288 |
} |
|
| 289 | ||
| 290 |
.database_table_counts <- function(path) {
|
|
| 291 | 9x |
.with_evidence_connection(path, function(con) {
|
| 292 | 9x |
tables <- DBI::dbListTables(con) |
| 293 | 9x |
stats::setNames( |
| 294 | 9x |
lapply(tables, function(table) {
|
| 295 | 108x |
DBI::dbGetQuery( |
| 296 | 108x |
con, |
| 297 | 108x |
paste0( |
| 298 | 108x |
"SELECT COUNT(*) AS n FROM ", |
| 299 | 108x |
DBI::dbQuoteIdentifier(con, table) |
| 300 |
) |
|
| 301 | 108x |
)$n[[1]] |
| 302 |
}), |
|
| 303 | 9x |
tables |
| 304 |
) |
|
| 305 |
}) |
|
| 306 |
} |
|
| 307 | ||
| 308 |
.verify_backup_manifest <- function(backup_path) {
|
|
| 309 | 5x |
manifest_path <- fs::path(backup_path, "backup-manifest.json") |
| 310 | 5x |
if (!fs::file_exists(manifest_path)) {
|
| 311 | ! |
cli::cli_abort("Backup completion manifest is missing")
|
| 312 |
} |
|
| 313 | 5x |
manifest <- jsonlite::fromJSON(manifest_path, simplifyVector = FALSE) |
| 314 | 5x |
database_path <- fs::path(backup_path, "evidence.sqlite") |
| 315 | 5x |
chain_root_path <- fs::path(backup_path, "chain-root.json") |
| 316 | 5x |
if (!fs::file_exists(database_path) || !fs::file_exists(chain_root_path)) {
|
| 317 | ! |
cli::cli_abort("Backup is incomplete")
|
| 318 |
} |
|
| 319 | 5x |
database_hash <- digest::digest(file = database_path, algo = "sha256") |
| 320 | 5x |
chain_root_hash <- digest::digest(file = chain_root_path, algo = "sha256") |
| 321 | 5x |
if (!identical(database_hash, manifest$database_hash) || |
| 322 | 5x |
!identical(chain_root_hash, manifest$chain_root_hash)) {
|
| 323 | 1x |
cli::cli_abort("Backup file hashes do not match the completion manifest")
|
| 324 |
} |
|
| 325 | 4x |
counts <- .database_table_counts(database_path) |
| 326 | 4x |
expected_counts <- unlist(manifest$table_counts, use.names = TRUE) |
| 327 | 4x |
actual_counts <- unlist(counts[names(expected_counts)], use.names = TRUE) |
| 328 | 4x |
if (!identical(as.numeric(actual_counts), as.numeric(expected_counts)) || |
| 329 | 4x |
!identical(names(actual_counts), names(expected_counts))) {
|
| 330 | ! |
cli::cli_abort("Backup row counts do not match the completion manifest")
|
| 331 |
} |
|
| 332 | 4x |
invisible(manifest) |
| 333 |
} |
|
| 334 | ||
| 335 |
.project_revision_history <- function(revisions, events) {
|
|
| 336 | 32x |
if (!nrow(revisions)) {
|
| 337 | 4x |
return(data.frame( |
| 338 | 4x |
revision_id = character(), |
| 339 | 4x |
status = character(), |
| 340 | 4x |
version = integer(), |
| 341 | 4x |
updated_at = character() |
| 342 |
)) |
|
| 343 |
} |
|
| 344 | 28x |
events_by_revision <- split(events, events$revision_id) |
| 345 | 28x |
projections <- lapply(seq_len(nrow(revisions)), \(index) {
|
| 346 | 28x |
revision_id <- revisions$revision_id[[index]] |
| 347 | 28x |
revision_events <- events_by_revision[[revision_id]] |
| 348 | 28x |
if (is.null(revision_events) || !nrow(revision_events)) {
|
| 349 | ! |
cli::cli_abort( |
| 350 | ! |
"Revision {.val {revision_id}} has no lifecycle history"
|
| 351 |
) |
|
| 352 |
} |
|
| 353 | 28x |
status <- revisions$initial_status[[index]] |
| 354 | 28x |
for (payload_json in revision_events$payload_json) {
|
| 355 | 55x |
payload <- jsonlite::fromJSON(payload_json, simplifyVector = TRUE) |
| 356 | 41x |
if (!is.null(payload$status)) status <- payload$status |
| 357 | 14x |
if (!is.null(payload$to)) status <- payload$to |
| 358 |
} |
|
| 359 | 28x |
data.frame( |
| 360 | 28x |
revision_id = revision_id, |
| 361 | 28x |
status = status, |
| 362 | 28x |
version = as.integer(nrow(revision_events)), |
| 363 | 28x |
updated_at = utils::tail(revision_events$created_at, 1L) |
| 364 |
) |
|
| 365 |
}) |
|
| 366 | 28x |
result <- do.call(rbind, projections) |
| 367 | 28x |
rownames(result) <- NULL |
| 368 | 28x |
result |
| 369 |
} |
|
| 370 | ||
| 371 |
sqlite_evidence_repository <- function( |
|
| 372 |
database_path, |
|
| 373 |
chain_root_path, |
|
| 374 |
artifact_store = NULL |
|
| 375 |
) {
|
|
| 376 | 98x |
checkmate::assert_string(database_path, min.chars = 1) |
| 377 | 98x |
checkmate::assert_string(chain_root_path, min.chars = 1) |
| 378 | 98x |
.initialize_evidence_database(database_path) |
| 379 | ||
| 380 | 98x |
transact <- function(idempotency_key, command, code) {
|
| 381 | 197x |
.with_repository_lock(database_path, function() {
|
| 382 | 197x |
if (!.chain_roots_match(database_path, chain_root_path)) {
|
| 383 | 3x |
prior <- .with_evidence_connection(database_path, function(con) {
|
| 384 | 3x |
DBI::dbGetQuery( |
| 385 | 3x |
con, |
| 386 | 3x |
"SELECT command_hash FROM command_keys WHERE idempotency_key = ?", |
| 387 | 3x |
params = list(idempotency_key) |
| 388 |
) |
|
| 389 |
}) |
|
| 390 | 3x |
is_exact_replay <- nrow(prior) == 1L && identical( |
| 391 | 3x |
prior$command_hash[[1]], |
| 392 | 3x |
canonical_hash(command) |
| 393 |
) |
|
| 394 | 3x |
if (!is_exact_replay) {
|
| 395 | 2x |
cli::cli_abort( |
| 396 | 2x |
"External chain root must be reconciled before a new command" |
| 397 |
) |
|
| 398 |
} |
|
| 399 | 1x |
.reconcile_chain_roots(database_path, chain_root_path) |
| 400 | 1x |
if (!.chain_roots_match(database_path, chain_root_path)) {
|
| 401 | ! |
cli::cli_abort("External chain root cannot be reconciled safely")
|
| 402 |
} |
|
| 403 |
} |
|
| 404 | 195x |
root <- .with_evidence_connection(database_path, function(con) {
|
| 405 | 195x |
DBI::dbWithTransaction(con, code(con)) |
| 406 |
}) |
|
| 407 | 173x |
if (is.list(root) && |
| 408 | 173x |
all(c("plot_id", "sequence", "chain_hash") %in% names(root))) {
|
| 409 | 173x |
.write_chain_root(chain_root_path, root) |
| 410 |
} |
|
| 411 | 172x |
invisible(root) |
| 412 |
}) |
|
| 413 |
} |
|
| 414 | ||
| 415 | 98x |
write_transaction <- function(code) {
|
| 416 | 228x |
.with_repository_lock(database_path, function() {
|
| 417 | 228x |
.with_evidence_connection(database_path, function(con) {
|
| 418 | 228x |
DBI::dbWithTransaction(con, code(con)) |
| 419 |
}) |
|
| 420 |
}) |
|
| 421 |
} |
|
| 422 | ||
| 423 | 98x |
create_revision <- function( |
| 424 | 98x |
plot_id, |
| 425 | 98x |
revision_id, |
| 426 | 98x |
revision_number, |
| 427 | 98x |
creator_id, |
| 428 | 98x |
spec_hash, |
| 429 | 98x |
code_hash, |
| 430 | 98x |
image_hash, |
| 431 | 98x |
analytical_hash, |
| 432 | 98x |
idempotency_key, |
| 433 | 98x |
initial_status = "Draft", |
| 434 | 98x |
parent_revision_id = NULL, |
| 435 | 98x |
correction_rationale = NULL, |
| 436 | 98x |
correction_provenance = NULL |
| 437 |
) {
|
|
| 438 | 88x |
lapply( |
| 439 | 88x |
list( |
| 440 | 88x |
plot_id = plot_id, |
| 441 | 88x |
revision_id = revision_id, |
| 442 | 88x |
creator_id = creator_id |
| 443 |
), |
|
| 444 | 88x |
\(x) .validate_identifier(x, "identifier") |
| 445 |
) |
|
| 446 | 88x |
checkmate::assert_int(revision_number, lower = 1L) |
| 447 | 88x |
if (!initial_status %in% c("Draft", "Experimental/Draft")) {
|
| 448 | ! |
cli::cli_abort("Invalid initial revision status")
|
| 449 |
} |
|
| 450 | 88x |
if (!is.null(artifact_store) && !is.null(code_hash)) {
|
| 451 | 34x |
artifact_store$verify(code_hash) |
| 452 |
} |
|
| 453 | 88x |
if (!is.null(artifact_store) && !is.null(image_hash)) {
|
| 454 | 25x |
artifact_store$verify(image_hash) |
| 455 |
} |
|
| 456 | 88x |
command <- list( |
| 457 | 88x |
plot_id = plot_id, |
| 458 | 88x |
revision_id = revision_id, |
| 459 | 88x |
revision_number = revision_number, |
| 460 | 88x |
creator_id = creator_id, |
| 461 | 88x |
spec_hash = spec_hash, |
| 462 | 88x |
code_hash = code_hash, |
| 463 | 88x |
image_hash = image_hash, |
| 464 | 88x |
analytical_hash = analytical_hash, |
| 465 | 88x |
initial_status = initial_status, |
| 466 | 88x |
parent_revision_id = parent_revision_id, |
| 467 | 88x |
correction_rationale = correction_rationale, |
| 468 | 88x |
correction_provenance = correction_provenance |
| 469 |
) |
|
| 470 | 88x |
transact(idempotency_key, command, function(con) {
|
| 471 | 87x |
if (!.register_command(con, idempotency_key, command)) {
|
| 472 | 2x |
existing <- .current_revision(con, revision_id) |
| 473 | 2x |
return(.chain_tip(con, existing$plot_id)) |
| 474 |
} |
|
| 475 | 85x |
if (!is.null(parent_revision_id)) {
|
| 476 | 5x |
parent <- .current_revision(con, parent_revision_id) |
| 477 | 5x |
if (!identical(parent$plot_id, plot_id)) {
|
| 478 | ! |
cli::cli_abort( |
| 479 | ! |
"A correction successor must belong to its parent plot" |
| 480 |
) |
|
| 481 |
} |
|
| 482 | 5x |
if (!parent$status %in% c("Rejected", "Reviewed")) {
|
| 483 | 1x |
cli::cli_abort( |
| 484 | 1x |
"Only a closed revision can have a correction successor" |
| 485 |
) |
|
| 486 |
} |
|
| 487 | 4x |
if (!checkmate::test_string(correction_rationale, min.chars = 1) || |
| 488 | 4x |
!checkmate::test_string(correction_provenance, min.chars = 1)) {
|
| 489 | ! |
cli::cli_abort( |
| 490 | ! |
"A correction successor requires rationale and provenance" |
| 491 |
) |
|
| 492 |
} |
|
| 493 | 80x |
} else if (!is.null(correction_rationale) || |
| 494 | 80x |
!is.null(correction_provenance)) {
|
| 495 | 1x |
cli::cli_abort( |
| 496 | 1x |
"Correction rationale and provenance require a parent revision" |
| 497 |
) |
|
| 498 |
} |
|
| 499 | 83x |
DBI::dbExecute( |
| 500 | 83x |
con, |
| 501 | 83x |
"INSERT OR IGNORE INTO plots(plot_id, created_at) VALUES (?, ?)", |
| 502 | 83x |
params = list(plot_id, .utc_timestamp()) |
| 503 |
) |
|
| 504 | 83x |
created_at <- .utc_timestamp() |
| 505 | 83x |
nullable <- \(x) if (is.null(x)) NA_character_ else x |
| 506 | 83x |
DBI::dbExecute(con, paste( |
| 507 | 83x |
"INSERT INTO revisions(revision_id, plot_id, revision_number,", |
| 508 | 83x |
"creator_id, parent_revision_id,", |
| 509 | 83x |
paste( |
| 510 | 83x |
"correction_rationale, correction_provenance, spec_hash,", |
| 511 | 83x |
"code_hash, image_hash, analytical_hash, initial_status, created_at)" |
| 512 |
), |
|
| 513 | 83x |
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" |
| 514 | 83x |
), params = list( |
| 515 | 83x |
revision_id, plot_id, revision_number, creator_id, |
| 516 | 83x |
nullable(parent_revision_id), nullable(correction_rationale), |
| 517 | 83x |
nullable(correction_provenance), spec_hash, code_hash, |
| 518 | 83x |
nullable(image_hash), nullable(analytical_hash), |
| 519 | 83x |
initial_status, created_at |
| 520 |
)) |
|
| 521 | 83x |
DBI::dbExecute( |
| 522 | 83x |
con, |
| 523 | 83x |
paste( |
| 524 | 83x |
"INSERT INTO revision_projection(revision_id, status, version,", |
| 525 | 83x |
"updated_at) VALUES (?, ?, 1, ?)" |
| 526 |
), |
|
| 527 | 83x |
params = list(revision_id, initial_status, created_at) |
| 528 |
) |
|
| 529 | 83x |
revision <- list(plot_id = plot_id, revision_id = revision_id) |
| 530 | 83x |
.append_lifecycle_event( |
| 531 | 83x |
con, |
| 532 | 83x |
revision, |
| 533 | 83x |
"revision_created", |
| 534 | 83x |
list(status = initial_status), |
| 535 | 83x |
idempotency_key |
| 536 |
) |
|
| 537 |
}) |
|
| 538 |
} |
|
| 539 | ||
| 540 | 98x |
list_revisions <- function(plot_id) {
|
| 541 | 21x |
.with_evidence_connection(database_path, \(con) {
|
| 542 | 21x |
DBI::dbGetQuery( |
| 543 | 21x |
con, |
| 544 | 21x |
paste( |
| 545 | 21x |
"SELECT * FROM revisions WHERE plot_id = ?", |
| 546 | 21x |
"ORDER BY revision_number" |
| 547 |
), |
|
| 548 | 21x |
params = list(plot_id) |
| 549 |
) |
|
| 550 |
}) |
|
| 551 |
} |
|
| 552 | 98x |
list_events <- function(plot_id) {
|
| 553 | 7x |
.with_evidence_connection(database_path, \(con) {
|
| 554 | 7x |
DBI::dbGetQuery( |
| 555 | 7x |
con, |
| 556 | 7x |
paste( |
| 557 | 7x |
"SELECT * FROM lifecycle_events WHERE plot_id = ?", |
| 558 | 7x |
"ORDER BY event_sequence" |
| 559 |
), |
|
| 560 | 7x |
params = list(plot_id) |
| 561 |
) |
|
| 562 |
}) |
|
| 563 |
} |
|
| 564 | 98x |
get_revision <- function(revision_id) {
|
| 565 | 294x |
.with_evidence_connection( |
| 566 | 294x |
database_path, |
| 567 | 294x |
\(con) .current_revision(con, revision_id) |
| 568 |
) |
|
| 569 |
} |
|
| 570 | ||
| 571 | 98x |
record_attempt <- function(attempt_id, revision_id, run_id) {
|
| 572 | 54x |
write_transaction(function(con) {
|
| 573 | 54x |
.current_revision(con, revision_id) |
| 574 | 54x |
prior <- DBI::dbGetQuery( |
| 575 | 54x |
con, |
| 576 | 54x |
"SELECT revision_id, run_id FROM attempts WHERE attempt_id = ?", |
| 577 | 54x |
params = list(attempt_id) |
| 578 |
) |
|
| 579 | 54x |
if (nrow(prior)) {
|
| 580 | 3x |
if (!identical(prior$revision_id[[1]], revision_id) || |
| 581 | 3x |
!identical(prior$run_id[[1]], run_id)) {
|
| 582 | 2x |
cli::cli_abort( |
| 583 | 2x |
"Attempt identifier was already used for a different command" |
| 584 |
) |
|
| 585 |
} |
|
| 586 | 1x |
return(invisible(NULL)) |
| 587 |
} |
|
| 588 | 51x |
DBI::dbExecute( |
| 589 | 51x |
con, |
| 590 | 51x |
paste( |
| 591 | 51x |
"INSERT INTO attempts(attempt_id, revision_id, run_id, created_at)", |
| 592 | 51x |
"VALUES (?, ?, ?, ?)" |
| 593 |
), |
|
| 594 | 51x |
params = list(attempt_id, revision_id, run_id, .utc_timestamp()) |
| 595 |
) |
|
| 596 |
}) |
|
| 597 | 52x |
invisible(attempt_id) |
| 598 |
} |
|
| 599 | 98x |
list_attempts <- function(revision_id) {
|
| 600 | 23x |
.with_evidence_connection(database_path, \(con) {
|
| 601 | 23x |
DBI::dbGetQuery( |
| 602 | 23x |
con, paste( |
| 603 | 23x |
"SELECT a.*, o.outcome, o.created_at AS completed_at FROM attempts a", |
| 604 | 23x |
paste( |
| 605 | 23x |
"LEFT JOIN attempt_outcomes o USING(attempt_id)", |
| 606 | 23x |
"WHERE a.revision_id = ? ORDER BY a.created_at, a.attempt_id" |
| 607 |
) |
|
| 608 |
), |
|
| 609 | 23x |
params = list(revision_id) |
| 610 |
) |
|
| 611 |
}) |
|
| 612 |
} |
|
| 613 | 98x |
complete_attempt <- function(attempt_id, outcome, idempotency_key) {
|
| 614 | 50x |
if (!outcome %in% c("succeeded", "failed", "cancelled", "timed_out")) {
|
| 615 | ! |
cli::cli_abort("Invalid terminal attempt outcome")
|
| 616 |
} |
|
| 617 | 50x |
write_transaction(function(con) {
|
| 618 | 50x |
command <- list(attempt_id = attempt_id, outcome = outcome) |
| 619 | 50x |
if (!.register_command(con, idempotency_key, command)) {
|
| 620 | ! |
return(invisible(NULL)) |
| 621 |
} |
|
| 622 | 50x |
prior <- DBI::dbGetQuery( |
| 623 | 50x |
con, |
| 624 | 50x |
"SELECT 1 FROM attempt_outcomes WHERE attempt_id = ?", |
| 625 | 50x |
params = list(attempt_id) |
| 626 |
) |
|
| 627 | 50x |
if (nrow(prior)) {
|
| 628 | 1x |
cli::cli_abort("An attempt can have only one terminal outcome")
|
| 629 |
} |
|
| 630 | 49x |
attempt_exists <- DBI::dbGetQuery( |
| 631 | 49x |
con, |
| 632 | 49x |
"SELECT 1 FROM attempts WHERE attempt_id = ?", |
| 633 | 49x |
params = list(attempt_id) |
| 634 |
) |
|
| 635 | 49x |
if (!nrow(attempt_exists)) {
|
| 636 | ! |
cli::cli_abort("Execution attempt was not found")
|
| 637 |
} |
|
| 638 | 49x |
DBI::dbExecute( |
| 639 | 49x |
con, |
| 640 | 49x |
"INSERT INTO attempt_outcomes VALUES (?, ?, ?, ?)", |
| 641 | 49x |
params = list( |
| 642 | 49x |
attempt_id, outcome, .utc_timestamp(), idempotency_key |
| 643 |
) |
|
| 644 |
) |
|
| 645 |
}) |
|
| 646 | 49x |
invisible(attempt_id) |
| 647 |
} |
|
| 648 | 98x |
record_evidence <- function( |
| 649 | 98x |
evidence_id, |
| 650 | 98x |
revision_id, |
| 651 | 98x |
evidence_type, |
| 652 | 98x |
outcome, |
| 653 | 98x |
idempotency_key, |
| 654 | 98x |
details = list() |
| 655 |
) {
|
|
| 656 | 59x |
if (!checkmate::test_list(details, names = "unique")) {
|
| 657 | 2x |
cli::cli_abort("Evidence details must be a named list")
|
| 658 |
} |
|
| 659 | 57x |
write_transaction(function(con) {
|
| 660 | 57x |
command <- list( |
| 661 | 57x |
evidence_id = evidence_id, |
| 662 | 57x |
revision_id = revision_id, |
| 663 | 57x |
evidence_type = evidence_type, |
| 664 | 57x |
outcome = outcome, |
| 665 | 57x |
details = details |
| 666 |
) |
|
| 667 | 57x |
if (!.register_command(con, idempotency_key, command)) {
|
| 668 | ! |
return(invisible(NULL)) |
| 669 |
} |
|
| 670 | 57x |
.current_revision(con, revision_id) |
| 671 | 56x |
details_json <- canonical_serialize(details) |
| 672 | 56x |
DBI::dbExecute( |
| 673 | 56x |
con, |
| 674 | 56x |
"INSERT INTO evidence_entries VALUES (?, ?, ?, ?, ?, ?, ?, ?)", |
| 675 | 56x |
params = list( |
| 676 | 56x |
evidence_id, revision_id, evidence_type, outcome, details_json, |
| 677 | 56x |
canonical_hash(details), .utc_timestamp(), idempotency_key |
| 678 |
) |
|
| 679 |
) |
|
| 680 |
}) |
|
| 681 | 56x |
invisible(evidence_id) |
| 682 |
} |
|
| 683 | 98x |
list_evidence <- function(revision_id) {
|
| 684 | 7x |
.with_evidence_connection(database_path, \(con) {
|
| 685 | 7x |
DBI::dbGetQuery( |
| 686 | 7x |
con, |
| 687 | 7x |
paste( |
| 688 | 7x |
"SELECT * FROM evidence_entries WHERE revision_id = ?", |
| 689 | 7x |
"ORDER BY created_at, evidence_id" |
| 690 |
), |
|
| 691 | 7x |
params = list(revision_id) |
| 692 |
) |
|
| 693 |
}) |
|
| 694 |
} |
|
| 695 | 98x |
accept_artifact_bundle <- function( |
| 696 | 98x |
bundle_id, |
| 697 | 98x |
revision_id, |
| 698 | 98x |
code_hash, |
| 699 | 98x |
image_hash, |
| 700 | 98x |
analytical_hash, |
| 701 | 98x |
idempotency_key |
| 702 |
) {
|
|
| 703 | 54x |
write_transaction(function(con) {
|
| 704 | 54x |
command <- list( |
| 705 | 54x |
bundle_id = bundle_id, |
| 706 | 54x |
revision_id = revision_id, |
| 707 | 54x |
code_hash = code_hash, |
| 708 | 54x |
image_hash = image_hash, |
| 709 | 54x |
analytical_hash = analytical_hash |
| 710 |
) |
|
| 711 | 54x |
if (!.register_command(con, idempotency_key, command)) {
|
| 712 | ! |
return(invisible(NULL)) |
| 713 |
} |
|
| 714 | 54x |
revision <- .current_revision(con, revision_id) |
| 715 | 54x |
if (is.na(revision$image_hash) && is.na(revision$analytical_hash)) {
|
| 716 | 8x |
if (!is.null(artifact_store)) {
|
| 717 | 7x |
artifact_store$verify(code_hash) |
| 718 | 7x |
artifact_store$verify(image_hash) |
| 719 |
} |
|
| 720 | 8x |
DBI::dbExecute( |
| 721 | 8x |
con, |
| 722 | 8x |
paste( |
| 723 | 8x |
"UPDATE revisions SET image_hash = ?, analytical_hash = ?", |
| 724 | 8x |
paste( |
| 725 | 8x |
"WHERE revision_id = ? AND image_hash IS NULL", |
| 726 | 8x |
"AND analytical_hash IS NULL" |
| 727 |
) |
|
| 728 |
), |
|
| 729 | 8x |
params = list(image_hash, analytical_hash, revision_id) |
| 730 |
) |
|
| 731 | 8x |
revision$image_hash <- image_hash |
| 732 | 8x |
revision$analytical_hash <- analytical_hash |
| 733 |
} |
|
| 734 | 54x |
expected_hashes <- unlist( |
| 735 | 54x |
revision[c("code_hash", "image_hash", "analytical_hash")]
|
| 736 |
) |
|
| 737 | 54x |
supplied_hashes <- c( |
| 738 | 54x |
code_hash = code_hash, |
| 739 | 54x |
image_hash = image_hash, |
| 740 | 54x |
analytical_hash = analytical_hash |
| 741 |
) |
|
| 742 | 54x |
if (!identical(supplied_hashes, expected_hashes)) {
|
| 743 | 1x |
cli::cli_abort( |
| 744 | 1x |
"Artifact bundle hashes do not match the immutable revision" |
| 745 |
) |
|
| 746 |
} |
|
| 747 | 53x |
if (!is.null(artifact_store)) {
|
| 748 | 28x |
artifact_store$verify(code_hash) |
| 749 | 28x |
artifact_store$verify(image_hash) |
| 750 |
} |
|
| 751 | 52x |
prior <- DBI::dbGetQuery( |
| 752 | 52x |
con, |
| 753 | 52x |
"SELECT 1 FROM artifact_bundles WHERE revision_id = ?", |
| 754 | 52x |
params = list(revision_id) |
| 755 |
) |
|
| 756 | 52x |
if (nrow(prior)) {
|
| 757 | 1x |
cli::cli_abort("A revision can have only one accepted artifact bundle")
|
| 758 |
} |
|
| 759 | 51x |
DBI::dbExecute( |
| 760 | 51x |
con, |
| 761 | 51x |
"INSERT INTO artifact_bundles VALUES (?, ?, ?, ?, ?, ?, ?)", |
| 762 | 51x |
params = list( |
| 763 | 51x |
bundle_id, revision_id, code_hash, image_hash, analytical_hash, |
| 764 | 51x |
.utc_timestamp(), idempotency_key |
| 765 |
) |
|
| 766 |
) |
|
| 767 |
}) |
|
| 768 | 51x |
invisible(bundle_id) |
| 769 |
} |
|
| 770 | ||
| 771 | 98x |
get_artifact_bundle <- function(revision_id) {
|
| 772 | 39x |
.with_evidence_connection(database_path, function(con) {
|
| 773 | 39x |
.current_revision(con, revision_id) |
| 774 | 39x |
result <- DBI::dbGetQuery( |
| 775 | 39x |
con, |
| 776 | 39x |
paste( |
| 777 | 39x |
"SELECT * FROM artifact_bundles WHERE revision_id = ?", |
| 778 | 39x |
"ORDER BY accepted_at DESC LIMIT 1" |
| 779 |
), |
|
| 780 | 39x |
params = list(revision_id) |
| 781 |
) |
|
| 782 | 39x |
if (!nrow(result)) {
|
| 783 | 19x |
cli::cli_abort( |
| 784 | 19x |
"Revision {.val {revision_id}} has no accepted artifact bundle"
|
| 785 |
) |
|
| 786 |
} |
|
| 787 | 20x |
as.list(result[1, , drop = FALSE]) |> |
| 788 | 20x |
lapply(\(value) value[[1]]) |
| 789 |
}) |
|
| 790 |
} |
|
| 791 | ||
| 792 | 98x |
mark_verified <- function(revision_id, expected_version, idempotency_key) {
|
| 793 | 49x |
command <- list( |
| 794 | 49x |
revision_id = revision_id, |
| 795 | 49x |
expected_version = expected_version, |
| 796 | 49x |
action = "mark_verified" |
| 797 |
) |
|
| 798 | 49x |
transact(idempotency_key, command, function(con) {
|
| 799 | 48x |
if (!.register_command(con, idempotency_key, command)) {
|
| 800 | ! |
existing <- .current_revision(con, revision_id) |
| 801 | ! |
return(.chain_tip(con, existing$plot_id)) |
| 802 |
} |
|
| 803 | 48x |
revision <- .current_revision(con, revision_id) |
| 804 | 48x |
if (!identical( |
| 805 | 48x |
as.integer(revision$version), as.integer(expected_version) |
| 806 |
)) {
|
|
| 807 | 1x |
cli::cli_abort("The revision token is stale")
|
| 808 |
} |
|
| 809 | 47x |
if (!identical(revision$status, "Draft")) {
|
| 810 | 1x |
cli::cli_abort( |
| 811 | 1x |
"Only a Draft revision can become Verified" |
| 812 |
) |
|
| 813 |
} |
|
| 814 | 46x |
passing_execution <- DBI::dbGetQuery( |
| 815 | 46x |
con, |
| 816 | 46x |
paste( |
| 817 | 46x |
"SELECT 1 FROM attempts a", |
| 818 | 46x |
"JOIN attempt_outcomes o USING(attempt_id)", |
| 819 | 46x |
"JOIN evidence_entries e ON e.revision_id = a.revision_id", |
| 820 | 46x |
"JOIN artifact_bundles b ON b.revision_id = a.revision_id", |
| 821 | 46x |
"WHERE a.revision_id = ? AND o.outcome = 'succeeded'", |
| 822 | 46x |
"AND e.evidence_type = 'execution_verification'", |
| 823 | 46x |
"AND e.outcome = 'passed'", |
| 824 | 46x |
paste0( |
| 825 | 46x |
"AND json_extract(e.details_json, '$.attempt_id') = ", |
| 826 | 46x |
"a.attempt_id LIMIT 1" |
| 827 |
) |
|
| 828 |
), |
|
| 829 | 46x |
params = list(revision_id) |
| 830 |
) |
|
| 831 | 46x |
if (!nrow(passing_execution)) {
|
| 832 | 5x |
cli::cli_abort( |
| 833 | 5x |
"A revision requires passing execution evidence before verification" |
| 834 |
) |
|
| 835 |
} |
|
| 836 | 41x |
DBI::dbExecute( |
| 837 | 41x |
con, |
| 838 | 41x |
paste( |
| 839 | 41x |
"UPDATE revision_projection SET status = ?, version = version + 1,", |
| 840 | 41x |
"updated_at = ? WHERE revision_id = ?" |
| 841 |
), |
|
| 842 | 41x |
params = list("Verified", .utc_timestamp(), revision_id)
|
| 843 |
) |
|
| 844 | 41x |
.append_lifecycle_event( |
| 845 | 41x |
con, |
| 846 | 41x |
revision, |
| 847 | 41x |
"revision_verified", |
| 848 | 41x |
list(from = "Draft", to = "Verified"), |
| 849 | 41x |
idempotency_key |
| 850 |
) |
|
| 851 |
}) |
|
| 852 |
} |
|
| 853 | ||
| 854 | 98x |
record_review_decision <- function( |
| 855 | 98x |
revision_id, |
| 856 | 98x |
actor, |
| 857 | 98x |
decision, |
| 858 | 98x |
expected_version, |
| 859 | 98x |
idempotency_key, |
| 860 | 98x |
hashes, |
| 861 | 98x |
comment = NULL, |
| 862 | 98x |
role = NULL |
| 863 |
) {
|
|
| 864 | 60x |
command <- list( |
| 865 | 60x |
revision_id = revision_id, |
| 866 | 60x |
actor = actor, |
| 867 | 60x |
decision = decision, |
| 868 | 60x |
expected_version = expected_version, |
| 869 | 60x |
hashes = hashes, |
| 870 | 60x |
comment = comment, |
| 871 | 60x |
role = role |
| 872 |
) |
|
| 873 | 60x |
transact(idempotency_key, command, function(con) {
|
| 874 | 60x |
if (!.register_command(con, idempotency_key, command)) {
|
| 875 | ! |
existing <- .current_revision(con, revision_id) |
| 876 | ! |
return(.chain_tip(con, existing$plot_id)) |
| 877 |
} |
|
| 878 | 60x |
revision <- .current_revision(con, revision_id) |
| 879 | 60x |
if (revision$status == "Reviewed") {
|
| 880 | 2x |
cli::cli_abort("Reviewed revisions are immutable")
|
| 881 |
} |
|
| 882 | 58x |
if (!identical( |
| 883 | 58x |
as.integer(revision$version), as.integer(expected_version) |
| 884 |
)) {
|
|
| 885 | 3x |
cli::cli_abort("The revision token is stale")
|
| 886 |
} |
|
| 887 | 55x |
if (!revision$status %in% c("Verified")) {
|
| 888 | 1x |
cli::cli_abort("Only a Verified revision can be reviewed")
|
| 889 |
} |
|
| 890 | 54x |
expected_hashes <- unlist( |
| 891 | 54x |
revision[c("code_hash", "image_hash", "analytical_hash")]
|
| 892 |
) |
|
| 893 | 54x |
if (!identical(unlist(hashes[names(expected_hashes)]), expected_hashes)) {
|
| 894 | 1x |
cli::cli_abort( |
| 895 | 1x |
"Review hashes do not match the immutable revision hashes" |
| 896 |
) |
|
| 897 |
} |
|
| 898 | 53x |
if (identical(actor$id, revision$creator_id)) {
|
| 899 | 1x |
cli::cli_abort("A revision creator cannot review their own revision")
|
| 900 |
} |
|
| 901 | 52x |
if (!isTRUE(actor$active)) {
|
| 902 | 1x |
cli::cli_abort("The authenticated reviewer must be active")
|
| 903 |
} |
|
| 904 | 51x |
eligible <- intersect( |
| 905 | 51x |
actor$roles, |
| 906 | 51x |
c("statistical_programmer", "biostatistician")
|
| 907 |
) |
|
| 908 | 51x |
if (!length(eligible)) {
|
| 909 | ! |
cli::cli_abort("The authenticated actor is not an eligible reviewer")
|
| 910 |
} |
|
| 911 | 51x |
if (is.null(role) && length(eligible) > 1L) {
|
| 912 | 1x |
cli::cli_abort(paste0( |
| 913 | 1x |
"A reviewer with multiple eligible roles must select ", |
| 914 | 1x |
"one role" |
| 915 |
)) |
|
| 916 |
} |
|
| 917 | 50x |
role <- role %||% eligible[[1]] |
| 918 | 50x |
if (!checkmate::test_choice(role, eligible)) {
|
| 919 | ! |
cli::cli_abort(paste0( |
| 920 | ! |
"The claimed reviewer role is not authorized for this actor" |
| 921 |
)) |
|
| 922 |
} |
|
| 923 | 50x |
if (decision == "rejected" && |
| 924 | 50x |
!checkmate::test_string(comment, min.chars = 1)) {
|
| 925 | 1x |
cli::cli_abort("A rejection rationale is required")
|
| 926 |
} |
|
| 927 | 49x |
prior <- DBI::dbGetQuery( |
| 928 | 49x |
con, |
| 929 | 49x |
"SELECT actor_id FROM review_decisions WHERE revision_id = ?", |
| 930 | 49x |
params = list(revision_id) |
| 931 |
) |
|
| 932 | 49x |
if (actor$id %in% prior$actor_id) {
|
| 933 | 2x |
cli::cli_abort( |
| 934 | 2x |
"Review decisions require distinct authenticated identities" |
| 935 |
) |
|
| 936 |
} |
|
| 937 | 47x |
decision_id <- paste0( |
| 938 | 47x |
"decision-", |
| 939 | 47x |
substr(canonical_hash(idempotency_key), 1L, 24L) |
| 940 |
) |
|
| 941 | 47x |
created_at <- .utc_timestamp() |
| 942 | 47x |
authorization_json <- canonical_serialize(list( |
| 943 | 47x |
actor_id = actor$id, |
| 944 | 47x |
roles = actor$roles, |
| 945 | 47x |
active = actor$active |
| 946 |
)) |
|
| 947 | 47x |
stored_comment <- if (is.null(comment)) NA_character_ else comment |
| 948 | 47x |
decision_record <- list( |
| 949 | 47x |
decision_id = decision_id, |
| 950 | 47x |
revision_id = revision_id, |
| 951 | 47x |
actor_id = actor$id, |
| 952 | 47x |
role = role, |
| 953 | 47x |
decision = decision, |
| 954 | 47x |
authorization_json = authorization_json, |
| 955 | 47x |
comment = stored_comment, |
| 956 | 47x |
code_hash = revision$code_hash, |
| 957 | 47x |
image_hash = revision$image_hash, |
| 958 | 47x |
analytical_hash = revision$analytical_hash, |
| 959 | 47x |
created_at = created_at, |
| 960 | 47x |
idempotency_key = idempotency_key |
| 961 |
) |
|
| 962 | 47x |
decision_hash <- .review_decision_hash(decision_record) |
| 963 | 47x |
DBI::dbExecute(con, paste( |
| 964 | 47x |
"INSERT INTO review_decisions(",
|
| 965 | 47x |
"decision_id, revision_id, actor_id, role, decision,", |
| 966 | 47x |
"authorization_json, comment,", |
| 967 | 47x |
paste( |
| 968 | 47x |
"code_hash, image_hash, analytical_hash, created_at,", |
| 969 | 47x |
"idempotency_key, record_hash)" |
| 970 |
), |
|
| 971 | 47x |
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" |
| 972 | 47x |
), params = list( |
| 973 | 47x |
decision_id, revision_id, actor$id, role, decision, |
| 974 | 47x |
authorization_json, stored_comment, revision$code_hash, |
| 975 | 47x |
revision$image_hash, revision$analytical_hash, created_at, |
| 976 | 47x |
idempotency_key, decision_hash |
| 977 |
)) |
|
| 978 | 47x |
next_status <- revision$status |
| 979 | 4x |
if (decision == "rejected") next_status <- "Rejected" |
| 980 | 47x |
if (decision == "approved") {
|
| 981 | 43x |
approvals <- DBI::dbGetQuery( |
| 982 | 43x |
con, |
| 983 | 43x |
paste( |
| 984 | 43x |
"SELECT actor_id, role FROM review_decisions", |
| 985 | 43x |
"WHERE revision_id = ? AND decision = 'approved'" |
| 986 |
), |
|
| 987 | 43x |
params = list(revision_id) |
| 988 |
) |
|
| 989 | 43x |
if (nrow(approvals) >= 2L && |
| 990 | 43x |
length(unique(approvals$actor_id)) >= 2L && |
| 991 | 43x |
all(c( |
| 992 | 43x |
"statistical_programmer", "biostatistician" |
| 993 | 43x |
) %in% approvals$role)) {
|
| 994 | 20x |
next_status <- "Reviewed" |
| 995 |
} |
|
| 996 |
} |
|
| 997 | 47x |
DBI::dbExecute( |
| 998 | 47x |
con, |
| 999 | 47x |
paste( |
| 1000 | 47x |
"UPDATE revision_projection SET status = ?, version = version + 1,", |
| 1001 | 47x |
"updated_at = ? WHERE revision_id = ?" |
| 1002 |
), |
|
| 1003 | 47x |
params = list(next_status, .utc_timestamp(), revision_id) |
| 1004 |
) |
|
| 1005 | 47x |
.append_lifecycle_event( |
| 1006 | 47x |
con, |
| 1007 | 47x |
revision, |
| 1008 | 47x |
paste0("review_", decision),
|
| 1009 | 47x |
list( |
| 1010 | 47x |
status = next_status, |
| 1011 | 47x |
actor_id = actor$id, |
| 1012 | 47x |
role = role, |
| 1013 | 47x |
decision_id = decision_id, |
| 1014 | 47x |
decision_hash = decision_hash |
| 1015 |
), |
|
| 1016 | 47x |
idempotency_key |
| 1017 |
) |
|
| 1018 |
}) |
|
| 1019 |
} |
|
| 1020 | ||
| 1021 | 98x |
list_review_decisions <- function(revision_id) {
|
| 1022 | 23x |
.with_evidence_connection(database_path, \(con) {
|
| 1023 | 23x |
DBI::dbGetQuery( |
| 1024 | 23x |
con, |
| 1025 | 23x |
paste( |
| 1026 | 23x |
"SELECT * FROM review_decisions WHERE revision_id = ?", |
| 1027 | 23x |
"ORDER BY created_at, decision_id" |
| 1028 |
), |
|
| 1029 | 23x |
params = list(revision_id) |
| 1030 |
) |
|
| 1031 |
}) |
|
| 1032 |
} |
|
| 1033 | ||
| 1034 | 98x |
record_export_receipt <- function( |
| 1035 | 98x |
receipt_id, |
| 1036 | 98x |
revision_id, |
| 1037 | 98x |
destination_id, |
| 1038 | 98x |
code_hash, |
| 1039 | 98x |
image_hash, |
| 1040 | 98x |
idempotency_key |
| 1041 |
) {
|
|
| 1042 | 13x |
write_transaction(function(con) {
|
| 1043 | 13x |
command <- list( |
| 1044 | 13x |
receipt_id = receipt_id, |
| 1045 | 13x |
revision_id = revision_id, |
| 1046 | 13x |
destination_id = destination_id, |
| 1047 | 13x |
code_hash = code_hash, |
| 1048 | 13x |
image_hash = image_hash |
| 1049 |
) |
|
| 1050 | 13x |
if (!.register_command(con, idempotency_key, command)) {
|
| 1051 | 1x |
return(invisible(NULL)) |
| 1052 |
} |
|
| 1053 | 12x |
revision <- .current_revision(con, revision_id) |
| 1054 | 12x |
if (!identical(code_hash, revision$code_hash) || |
| 1055 | 12x |
!identical(image_hash, revision$image_hash)) {
|
| 1056 | 1x |
cli::cli_abort( |
| 1057 | 1x |
"Export receipt hashes do not match the immutable revision" |
| 1058 |
) |
|
| 1059 |
} |
|
| 1060 | 11x |
DBI::dbExecute( |
| 1061 | 11x |
con, |
| 1062 | 11x |
"INSERT INTO export_receipts VALUES (?, ?, ?, ?, ?, ?, ?)", |
| 1063 | 11x |
params = list( |
| 1064 | 11x |
receipt_id, revision_id, destination_id, code_hash, image_hash, |
| 1065 | 11x |
.utc_timestamp(), idempotency_key |
| 1066 |
) |
|
| 1067 |
) |
|
| 1068 |
}) |
|
| 1069 | 12x |
invisible(receipt_id) |
| 1070 |
} |
|
| 1071 | 98x |
list_export_receipts <- function(revision_id) {
|
| 1072 | 49x |
.with_evidence_connection(database_path, \(con) {
|
| 1073 | 49x |
DBI::dbGetQuery( |
| 1074 | 49x |
con, |
| 1075 | 49x |
paste( |
| 1076 | 49x |
"SELECT * FROM export_receipts WHERE revision_id = ?", |
| 1077 | 49x |
"ORDER BY created_at, receipt_id" |
| 1078 |
), |
|
| 1079 | 49x |
params = list(revision_id) |
| 1080 |
) |
|
| 1081 |
}) |
|
| 1082 |
} |
|
| 1083 | ||
| 1084 | 98x |
rebuild_projections <- function() {
|
| 1085 | 3x |
.with_repository_lock(database_path, function() {
|
| 1086 | 3x |
.with_evidence_connection(database_path, function(con) {
|
| 1087 | 3x |
DBI::dbWithTransaction(con, {
|
| 1088 | 3x |
revisions <- DBI::dbGetQuery( |
| 1089 | 3x |
con, |
| 1090 | 3x |
"SELECT revision_id, initial_status, created_at FROM revisions" |
| 1091 |
) |
|
| 1092 | 3x |
events <- DBI::dbGetQuery( |
| 1093 | 3x |
con, |
| 1094 | 3x |
paste( |
| 1095 | 3x |
"SELECT revision_id, payload_json, created_at", |
| 1096 | 3x |
"FROM lifecycle_events ORDER BY plot_id, event_sequence" |
| 1097 |
) |
|
| 1098 |
) |
|
| 1099 | 3x |
projections <- .project_revision_history(revisions, events) |
| 1100 | 3x |
DBI::dbExecute(con, "DELETE FROM revision_projection") |
| 1101 | 3x |
if (nrow(projections)) {
|
| 1102 | 3x |
DBI::dbAppendTable(con, "revision_projection", projections) |
| 1103 |
} |
|
| 1104 |
}) |
|
| 1105 |
}) |
|
| 1106 |
}) |
|
| 1107 |
} |
|
| 1108 | ||
| 1109 | 98x |
verify_integrity_unlocked <- function(require_artifacts = FALSE) {
|
| 1110 | 34x |
.with_evidence_connection(database_path, function(con) {
|
| 1111 | 34x |
foreign_key_violations <- DBI::dbGetQuery( |
| 1112 | 34x |
con, |
| 1113 | 34x |
"PRAGMA foreign_key_check" |
| 1114 |
) |
|
| 1115 | 34x |
if (nrow(foreign_key_violations) > 0L) {
|
| 1116 | ! |
cli::cli_abort("Foreign-key integrity check failed")
|
| 1117 |
} |
|
| 1118 | 34x |
plots <- DBI::dbGetQuery(con, "SELECT plot_id FROM plots")$plot_id |
| 1119 | 34x |
events <- DBI::dbGetQuery( |
| 1120 | 34x |
con, |
| 1121 | 34x |
"SELECT * FROM lifecycle_events ORDER BY plot_id, event_sequence" |
| 1122 |
) |
|
| 1123 | 34x |
roots <- .read_chain_roots(chain_root_path) |
| 1124 | 34x |
root_plot_ids <- names(roots) %||% character() |
| 1125 | 34x |
if (!identical(sort(root_plot_ids), sort(plots))) {
|
| 1126 | 1x |
cli::cli_abort("External chain roots do not match persisted plots")
|
| 1127 |
} |
|
| 1128 | 33x |
for (plot_id in plots) {
|
| 1129 | 29x |
plot_events <- events[events$plot_id == plot_id, , drop = FALSE] |
| 1130 | 29x |
previous <- paste(rep("0", 64L), collapse = "")
|
| 1131 | 29x |
for (i in seq_len(nrow(plot_events))) {
|
| 1132 | 56x |
if (plot_events$event_sequence[[i]] != i || |
| 1133 | 56x |
!identical(plot_events$previous_hash[[i]], previous)) {
|
| 1134 | 2x |
cli::cli_abort("Lifecycle event chain order is invalid")
|
| 1135 |
} |
|
| 1136 | 54x |
calculated <- canonical_hash(list( |
| 1137 | 54x |
plot_id = plot_id, |
| 1138 | 54x |
revision_id = plot_events$revision_id[[i]], |
| 1139 | 54x |
event_sequence = i, |
| 1140 | 54x |
event_type = plot_events$event_type[[i]], |
| 1141 | 54x |
payload_json = plot_events$payload_json[[i]], |
| 1142 | 54x |
previous_hash = previous, |
| 1143 | 54x |
created_at = plot_events$created_at[[i]] |
| 1144 |
)) |
|
| 1145 | 54x |
if (!identical(calculated, plot_events$chain_hash[[i]])) {
|
| 1146 | 1x |
cli::cli_abort("Lifecycle event chain hash is invalid")
|
| 1147 |
} |
|
| 1148 | 53x |
previous <- calculated |
| 1149 |
} |
|
| 1150 | 26x |
root <- roots[[plot_id]] |
| 1151 | 26x |
if (is.null(root) || root$sequence != nrow(plot_events) || |
| 1152 | 26x |
!identical(root$chain_hash, previous)) {
|
| 1153 | 1x |
cli::cli_abort( |
| 1154 | 1x |
"External chain root does not match persisted history" |
| 1155 |
) |
|
| 1156 |
} |
|
| 1157 |
} |
|
| 1158 | 29x |
projections <- DBI::dbGetQuery( |
| 1159 | 29x |
con, |
| 1160 | 29x |
"SELECT * FROM revision_projection ORDER BY revision_id" |
| 1161 |
) |
|
| 1162 | 29x |
revisions <- DBI::dbGetQuery( |
| 1163 | 29x |
con, |
| 1164 | 29x |
paste( |
| 1165 | 29x |
"SELECT revision_id, initial_status, created_at", |
| 1166 | 29x |
"FROM revisions ORDER BY revision_id" |
| 1167 |
) |
|
| 1168 |
) |
|
| 1169 | 29x |
expected <- .project_revision_history(revisions, events) |
| 1170 | 29x |
if (require_artifacts && nrow(revisions) && is.null(artifact_store)) {
|
| 1171 | 1x |
cli::cli_abort( |
| 1172 | 1x |
"Complete integrity verification requires an artifact store" |
| 1173 |
) |
|
| 1174 |
} |
|
| 1175 | 28x |
actual <- projections[ |
| 1176 | 28x |
, c("revision_id", "status", "version"),
|
| 1177 | 28x |
drop = FALSE |
| 1178 |
] |
|
| 1179 | 28x |
expected <- expected[ |
| 1180 | 28x |
, c("revision_id", "status", "version"),
|
| 1181 | 28x |
drop = FALSE |
| 1182 |
] |
|
| 1183 | 28x |
if (!identical(actual, expected)) {
|
| 1184 | 1x |
cli::cli_abort( |
| 1185 | 1x |
"Revision projection does not match append-only history" |
| 1186 |
) |
|
| 1187 |
} |
|
| 1188 | 27x |
evidence <- DBI::dbGetQuery( |
| 1189 | 27x |
con, |
| 1190 | 27x |
"SELECT evidence_id, details_json, content_hash FROM evidence_entries" |
| 1191 |
) |
|
| 1192 | 27x |
for (index in seq_len(nrow(evidence))) {
|
| 1193 | 14x |
calculated <- digest::digest( |
| 1194 | 14x |
evidence$details_json[[index]], |
| 1195 | 14x |
algo = "sha256", |
| 1196 | 14x |
serialize = FALSE |
| 1197 |
) |
|
| 1198 | 14x |
if (!identical(calculated, evidence$content_hash[[index]])) {
|
| 1199 | 1x |
cli::cli_abort( |
| 1200 | 1x |
paste0( |
| 1201 | 1x |
"Evidence content hash is invalid for ", |
| 1202 | 1x |
"{.val {evidence$evidence_id[[index]]}}"
|
| 1203 |
) |
|
| 1204 |
) |
|
| 1205 |
} |
|
| 1206 |
} |
|
| 1207 | 26x |
bundle_mismatches <- DBI::dbGetQuery(con, paste( |
| 1208 | 26x |
"SELECT COUNT(*) AS n FROM artifact_bundles b", |
| 1209 | 26x |
"JOIN revisions r USING(revision_id)", |
| 1210 | 26x |
"WHERE b.code_hash <> r.code_hash", |
| 1211 | 26x |
"OR b.image_hash <> r.image_hash", |
| 1212 | 26x |
"OR b.analytical_hash <> r.analytical_hash" |
| 1213 | 26x |
))$n[[1]] |
| 1214 | 26x |
if (bundle_mismatches > 0L) {
|
| 1215 | 1x |
cli::cli_abort( |
| 1216 | 1x |
"Accepted artifact bundle hashes do not match their revisions" |
| 1217 |
) |
|
| 1218 |
} |
|
| 1219 | 25x |
review_mismatches <- DBI::dbGetQuery(con, paste( |
| 1220 | 25x |
"SELECT COUNT(*) AS n FROM review_decisions d", |
| 1221 | 25x |
"JOIN revisions r USING(revision_id)", |
| 1222 | 25x |
"WHERE d.code_hash <> r.code_hash", |
| 1223 | 25x |
"OR d.image_hash <> r.image_hash", |
| 1224 | 25x |
"OR d.analytical_hash <> r.analytical_hash" |
| 1225 | 25x |
))$n[[1]] |
| 1226 | 25x |
if (review_mismatches > 0L) {
|
| 1227 | ! |
cli::cli_abort( |
| 1228 | ! |
"Reviewer decision hashes do not match their revisions" |
| 1229 |
) |
|
| 1230 |
} |
|
| 1231 | 25x |
decisions <- DBI::dbGetQuery( |
| 1232 | 25x |
con, |
| 1233 | 25x |
"SELECT * FROM review_decisions ORDER BY decision_id" |
| 1234 |
) |
|
| 1235 | 25x |
decision_events <- events[ |
| 1236 | 25x |
events$event_type %in% c("review_approved", "review_rejected"), ,
|
| 1237 | 25x |
drop = FALSE |
| 1238 |
] |
|
| 1239 | 25x |
event_decisions <- lapply( |
| 1240 | 25x |
decision_events$payload_json, |
| 1241 | 25x |
jsonlite::fromJSON, |
| 1242 | 25x |
simplifyVector = TRUE |
| 1243 |
) |
|
| 1244 | 25x |
event_ids <- vapply(event_decisions, `[[`, character(1), "decision_id") |
| 1245 | 25x |
if (!identical(sort(decisions$decision_id), sort(event_ids))) {
|
| 1246 | ! |
cli::cli_abort("Reviewer decisions do not match lifecycle history")
|
| 1247 |
} |
|
| 1248 | 25x |
for (index in seq_len(nrow(decisions))) {
|
| 1249 | 12x |
record <- as.list(decisions[index, , drop = FALSE]) |> |
| 1250 | 12x |
lapply(\(value) value[[1]]) |
| 1251 | 12x |
calculated <- .review_decision_hash(record) |
| 1252 | 12x |
event_index <- match(record$decision_id, event_ids) |
| 1253 | 12x |
if (!identical(calculated, record$record_hash) || |
| 1254 | 12x |
!identical( |
| 1255 | 12x |
calculated, |
| 1256 | 12x |
event_decisions[[event_index]]$decision_hash |
| 1257 |
)) {
|
|
| 1258 | 1x |
cli::cli_abort("Reviewer decision integrity check failed")
|
| 1259 |
} |
|
| 1260 |
} |
|
| 1261 | 24x |
receipt_mismatches <- DBI::dbGetQuery(con, paste( |
| 1262 | 24x |
"SELECT COUNT(*) AS n FROM export_receipts e", |
| 1263 | 24x |
"JOIN revisions r USING(revision_id)", |
| 1264 | 24x |
"WHERE e.code_hash <> r.code_hash", |
| 1265 | 24x |
"OR e.image_hash <> r.image_hash" |
| 1266 | 24x |
))$n[[1]] |
| 1267 | 24x |
if (receipt_mismatches > 0L) {
|
| 1268 | ! |
cli::cli_abort( |
| 1269 | ! |
"Export receipt hashes do not match their revisions" |
| 1270 |
) |
|
| 1271 |
} |
|
| 1272 | 24x |
if (!is.null(artifact_store)) {
|
| 1273 | 14x |
hashes <- DBI::dbGetQuery( |
| 1274 | 14x |
con, |
| 1275 | 14x |
"SELECT code_hash, image_hash FROM revisions" |
| 1276 |
) |
|
| 1277 | 28x |
for (hash in unique(unlist(hashes))) artifact_store$verify(hash) |
| 1278 |
} |
|
| 1279 | 23x |
TRUE |
| 1280 |
}) |
|
| 1281 |
} |
|
| 1282 | ||
| 1283 | 98x |
verify_integrity <- function(require_artifacts = FALSE) {
|
| 1284 | 28x |
checkmate::assert_flag(require_artifacts) |
| 1285 | 28x |
.with_repository_lock( |
| 1286 | 28x |
database_path, |
| 1287 | 28x |
\() verify_integrity_unlocked(require_artifacts) |
| 1288 |
) |
|
| 1289 |
} |
|
| 1290 | ||
| 1291 | 98x |
foreign_keys_enabled <- function() {
|
| 1292 | 1x |
.with_evidence_connection(database_path, \(con) {
|
| 1293 | 1x |
identical(DBI::dbGetQuery(con, "PRAGMA foreign_keys")[[1]][[1]], 1L) |
| 1294 |
}) |
|
| 1295 |
} |
|
| 1296 | ||
| 1297 | 98x |
cleanup_orphan_artifacts <- function(minimum_age_seconds = 3600) {
|
| 1298 | 2x |
checkmate::assert_number( |
| 1299 | 2x |
minimum_age_seconds, |
| 1300 | 2x |
lower = 0, |
| 1301 | 2x |
finite = TRUE |
| 1302 |
) |
|
| 1303 | 2x |
if (is.null(artifact_store)) {
|
| 1304 | ! |
cli::cli_abort("Artifact cleanup requires an artifact store")
|
| 1305 |
} |
|
| 1306 | 2x |
.with_repository_lock(database_path, function() {
|
| 1307 | 2x |
referenced_hashes <- .with_evidence_connection( |
| 1308 | 2x |
database_path, |
| 1309 | 2x |
function(con) {
|
| 1310 | 2x |
hashes <- DBI::dbGetQuery( |
| 1311 | 2x |
con, |
| 1312 | 2x |
"SELECT code_hash, image_hash FROM revisions" |
| 1313 |
) |
|
| 1314 | 2x |
unique(unlist(hashes, use.names = FALSE)) |
| 1315 |
} |
|
| 1316 |
) |
|
| 1317 | 2x |
artifact_store$.cleanup_orphans( |
| 1318 | 2x |
referenced_hashes, |
| 1319 | 2x |
minimum_age_seconds |
| 1320 |
) |
|
| 1321 |
}) |
|
| 1322 |
} |
|
| 1323 | ||
| 1324 | 98x |
backup <- function(destination) {
|
| 1325 | 6x |
.with_repository_lock(database_path, function() {
|
| 1326 | 6x |
if (fs::dir_exists(destination) && |
| 1327 | 6x |
length(fs::dir_ls(destination, fail = FALSE))) {
|
| 1328 | ! |
cli::cli_abort("Backup destination must be empty")
|
| 1329 |
} |
|
| 1330 | 6x |
verify_integrity_unlocked(require_artifacts = TRUE) |
| 1331 | 5x |
parent <- fs::path_dir(destination) |
| 1332 | 5x |
fs::dir_create(parent, recurse = TRUE) |
| 1333 | 5x |
staging <- fs::file_temp(tmp_dir = parent, pattern = "evidence-backup-") |
| 1334 | 5x |
fs::dir_create(staging) |
| 1335 | 5x |
completed <- FALSE |
| 1336 | 5x |
on.exit( |
| 1337 |
{
|
|
| 1338 | ! |
if (!completed && fs::dir_exists(staging)) fs::dir_delete(staging) |
| 1339 |
}, |
|
| 1340 | 5x |
add = TRUE |
| 1341 |
) |
|
| 1342 | 5x |
source <- .sqlite_connect(database_path) |
| 1343 | 5x |
target <- .sqlite_connect(fs::path(staging, "evidence.sqlite")) |
| 1344 | 5x |
on.exit( |
| 1345 |
{
|
|
| 1346 | ! |
if (DBI::dbIsValid(source)) DBI::dbDisconnect(source) |
| 1347 |
}, |
|
| 1348 | 5x |
add = TRUE |
| 1349 |
) |
|
| 1350 | 5x |
on.exit( |
| 1351 |
{
|
|
| 1352 | ! |
if (DBI::dbIsValid(target)) DBI::dbDisconnect(target) |
| 1353 |
}, |
|
| 1354 | 5x |
add = TRUE |
| 1355 |
) |
|
| 1356 | 5x |
RSQLite::sqliteCopyDatabase(source, target) |
| 1357 | 5x |
DBI::dbDisconnect(target) |
| 1358 | 5x |
DBI::dbDisconnect(source) |
| 1359 | 5x |
staged_chain_root <- fs::path(staging, "chain-root.json") |
| 1360 | 5x |
if (fs::file_exists(chain_root_path)) {
|
| 1361 | 4x |
fs::file_copy(chain_root_path, staged_chain_root) |
| 1362 |
} else {
|
|
| 1363 | 1x |
writeLines( |
| 1364 | 1x |
canonical_serialize(list()), |
| 1365 | 1x |
staged_chain_root, |
| 1366 | 1x |
useBytes = TRUE |
| 1367 |
) |
|
| 1368 |
} |
|
| 1369 | 5x |
if (!is.null(artifact_store)) {
|
| 1370 | 4x |
fs::dir_copy( |
| 1371 | 4x |
artifact_store$root, |
| 1372 | 4x |
fs::path(staging, "artifacts") |
| 1373 |
) |
|
| 1374 |
} |
|
| 1375 | 5x |
copied_store <- if (fs::dir_exists(fs::path(staging, "artifacts"))) {
|
| 1376 | 4x |
local_artifact_store(fs::path(staging, "artifacts")) |
| 1377 |
} else {
|
|
| 1378 | 1x |
NULL |
| 1379 |
} |
|
| 1380 | 5x |
copied <- sqlite_evidence_repository( |
| 1381 | 5x |
fs::path(staging, "evidence.sqlite"), |
| 1382 | 5x |
fs::path(staging, "chain-root.json"), |
| 1383 | 5x |
copied_store |
| 1384 |
) |
|
| 1385 | 5x |
copied$verify_integrity() |
| 1386 | 5x |
manifest <- list( |
| 1387 | 5x |
database_hash = digest::digest( |
| 1388 | 5x |
file = fs::path(staging, "evidence.sqlite"), |
| 1389 | 5x |
algo = "sha256" |
| 1390 |
), |
|
| 1391 | 5x |
chain_root_hash = digest::digest( |
| 1392 | 5x |
file = fs::path(staging, "chain-root.json"), |
| 1393 | 5x |
algo = "sha256" |
| 1394 |
), |
|
| 1395 | 5x |
table_counts = .database_table_counts( |
| 1396 | 5x |
fs::path(staging, "evidence.sqlite") |
| 1397 |
), |
|
| 1398 | 5x |
completed_at = .utc_timestamp() |
| 1399 |
) |
|
| 1400 | 5x |
writeLines( |
| 1401 | 5x |
canonical_serialize(manifest), |
| 1402 | 5x |
fs::path(staging, "backup-manifest.json"), |
| 1403 | 5x |
useBytes = TRUE |
| 1404 |
) |
|
| 1405 | ! |
if (fs::dir_exists(destination)) fs::dir_delete(destination) |
| 1406 | 5x |
fs::file_move(staging, destination) |
| 1407 | 5x |
completed <- TRUE |
| 1408 | 5x |
invisible(destination) |
| 1409 |
}) |
|
| 1410 |
} |
|
| 1411 | ||
| 1412 | 98x |
structure( |
| 1413 | 98x |
list( |
| 1414 | 98x |
database_path = fs::path_abs(database_path), |
| 1415 | 98x |
chain_root_path = fs::path_abs(chain_root_path), |
| 1416 | 98x |
artifact_store = artifact_store, |
| 1417 | 98x |
create_revision = create_revision, |
| 1418 | 98x |
list_revisions = list_revisions, |
| 1419 | 98x |
get_revision = get_revision, |
| 1420 | 98x |
list_events = list_events, |
| 1421 | 98x |
record_attempt = record_attempt, |
| 1422 | 98x |
list_attempts = list_attempts, |
| 1423 | 98x |
complete_attempt = complete_attempt, |
| 1424 | 98x |
record_evidence = record_evidence, |
| 1425 | 98x |
list_evidence = list_evidence, |
| 1426 | 98x |
accept_artifact_bundle = accept_artifact_bundle, |
| 1427 | 98x |
get_artifact_bundle = get_artifact_bundle, |
| 1428 | 98x |
mark_verified = mark_verified, |
| 1429 | 98x |
record_review_decision = record_review_decision, |
| 1430 | 98x |
list_review_decisions = list_review_decisions, |
| 1431 | 98x |
record_export_receipt = record_export_receipt, |
| 1432 | 98x |
list_export_receipts = list_export_receipts, |
| 1433 | 98x |
rebuild_projections = rebuild_projections, |
| 1434 | 98x |
verify_integrity = verify_integrity, |
| 1435 | 98x |
foreign_keys_enabled = foreign_keys_enabled, |
| 1436 | 98x |
cleanup_orphan_artifacts = cleanup_orphan_artifacts, |
| 1437 | 98x |
backup = backup, |
| 1438 | 98x |
update_revision = function(...) {
|
| 1439 | 1x |
cli::cli_abort("Updating historical revisions is not available")
|
| 1440 |
}, |
|
| 1441 | 98x |
delete_revision = function(...) {
|
| 1442 | 1x |
cli::cli_abort("Deleting historical revisions is not available")
|
| 1443 |
} |
|
| 1444 |
), |
|
| 1445 | 98x |
class = "sqlite_evidence_repository" |
| 1446 |
) |
|
| 1447 |
} |
|
| 1448 | ||
| 1449 |
restore_evidence_repository <- function(backup_path, destination) {
|
|
| 1450 | 5x |
checkmate::assert_directory_exists(backup_path) |
| 1451 | 5x |
.verify_backup_manifest(backup_path) |
| 1452 | 4x |
if (fs::dir_exists(destination) && |
| 1453 | 4x |
length(fs::dir_ls(destination, fail = FALSE))) {
|
| 1454 | ! |
cli::cli_abort("Restore destination must be empty")
|
| 1455 |
} |
|
| 1456 | 4x |
parent <- fs::path_dir(destination) |
| 1457 | 4x |
fs::dir_create(parent, recurse = TRUE) |
| 1458 | 4x |
staging <- fs::file_temp(tmp_dir = parent, pattern = "evidence-restore-") |
| 1459 | 4x |
fs::dir_create(staging) |
| 1460 | 4x |
completed <- FALSE |
| 1461 | 4x |
on.exit( |
| 1462 |
{
|
|
| 1463 | 1x |
if (!completed && fs::dir_exists(staging)) fs::dir_delete(staging) |
| 1464 |
}, |
|
| 1465 | 4x |
add = TRUE |
| 1466 |
) |
|
| 1467 | 4x |
fs::file_copy( |
| 1468 | 4x |
fs::path(backup_path, "evidence.sqlite"), |
| 1469 | 4x |
fs::path(staging, "evidence.sqlite") |
| 1470 |
) |
|
| 1471 | 4x |
fs::file_copy( |
| 1472 | 4x |
fs::path(backup_path, "chain-root.json"), |
| 1473 | 4x |
fs::path(staging, "chain-root.json") |
| 1474 |
) |
|
| 1475 | 4x |
store <- NULL |
| 1476 | 4x |
if (fs::dir_exists(fs::path(backup_path, "artifacts"))) {
|
| 1477 | 3x |
fs::dir_copy( |
| 1478 | 3x |
fs::path(backup_path, "artifacts"), |
| 1479 | 3x |
fs::path(staging, "artifacts") |
| 1480 |
) |
|
| 1481 | 3x |
store <- local_artifact_store(fs::path(staging, "artifacts")) |
| 1482 |
} |
|
| 1483 | 4x |
repo <- sqlite_evidence_repository( |
| 1484 | 4x |
fs::path(staging, "evidence.sqlite"), |
| 1485 | 4x |
fs::path(staging, "chain-root.json"), |
| 1486 | 4x |
store |
| 1487 |
) |
|
| 1488 | 4x |
repo$verify_integrity() |
| 1489 | ! |
if (fs::dir_exists(destination)) fs::dir_delete(destination) |
| 1490 | 3x |
fs::file_move(staging, destination) |
| 1491 | 3x |
completed <- TRUE |
| 1492 | 3x |
restored_store <- if (fs::dir_exists(fs::path(destination, "artifacts"))) {
|
| 1493 | 2x |
local_artifact_store(fs::path(destination, "artifacts")) |
| 1494 |
} else {
|
|
| 1495 | 1x |
NULL |
| 1496 |
} |
|
| 1497 | 3x |
sqlite_evidence_repository( |
| 1498 | 3x |
fs::path(destination, "evidence.sqlite"), |
| 1499 | 3x |
fs::path(destination, "chain-root.json"), |
| 1500 | 3x |
restored_store |
| 1501 |
) |
|
| 1502 |
} |
| 1 |
#' The application server-side |
|
| 2 |
#' |
|
| 3 |
#' @param input,output,session Internal parameters for {shiny}.
|
|
| 4 |
#' DO NOT REMOVE. |
|
| 5 |
#' @param runtime_config Injected runtime configuration. |
|
| 6 |
#' @import shiny |
|
| 7 |
#' @noRd |
|
| 8 |
app_server <- function( |
|
| 9 |
input, |
|
| 10 |
output, |
|
| 11 |
session, |
|
| 12 |
runtime_config = new_runtime_config() |
|
| 13 |
) {
|
|
| 14 | 8x |
workspace_root <- fs::path( |
| 15 | 8x |
fs::path_temp(), |
| 16 | 8x |
"adamviz-submissionsync-workspace", |
| 17 | 8x |
runtime_config$workspace |
| 18 |
) |
|
| 19 | 8x |
fs::dir_create(workspace_root, recurse = TRUE) |
| 20 | 8x |
workspace_provider <- local_workspace_provider( |
| 21 | 8x |
stats::setNames(workspace_root, runtime_config$workspace) |
| 22 |
) |
|
| 23 | 8x |
fs::dir_create(runtime_config$evidence_root, recurse = TRUE) |
| 24 | 8x |
current_revision <- shiny::reactiveVal(.empty_assurance_state()) |
| 25 | ||
| 26 | 8x |
execute_revision <- function(fields) {
|
| 27 | 7x |
state <- current_revision() |
| 28 | 7x |
if (is.null(state$pending)) {
|
| 29 | ! |
cli::cli_abort("Create an inspectable specification before execution")
|
| 30 |
} |
|
| 31 | 7x |
current_revision(.execute_assurance_revision( |
| 32 | 7x |
snapshot = state$pending$snapshot, |
| 33 | 7x |
prompt = state$pending$prompt, |
| 34 | 7x |
fields = fields, |
| 35 | 7x |
repository_root = runtime_config$evidence_root |
| 36 |
)) |
|
| 37 | 7x |
invisible(current_revision()) |
| 38 |
} |
|
| 39 | ||
| 40 | 8x |
create_correction <- function(fields, rationale, provenance) {
|
| 41 | ! |
current_revision(.execute_correction_revision( |
| 42 | ! |
state = current_revision(), |
| 43 | ! |
fields = fields, |
| 44 | ! |
rationale = rationale, |
| 45 | ! |
provenance = provenance |
| 46 |
)) |
|
| 47 | ! |
invisible(current_revision()) |
| 48 |
} |
|
| 49 | ||
| 50 | 8x |
mod_plot_generation_server( |
| 51 | 8x |
"plot_generation", |
| 52 | 8x |
current_revision = current_revision, |
| 53 | 8x |
execute_revision = execute_revision, |
| 54 | 8x |
create_correction = create_correction, |
| 55 | 8x |
workspace_provider = workspace_provider |
| 56 |
) |
|
| 57 |
} |
|
| 58 | ||
| 59 |
.empty_assurance_state <- function() {
|
|
| 60 | 21x |
list( |
| 61 | 21x |
pending = NULL, |
| 62 | 21x |
plot_id = NULL, |
| 63 | 21x |
repository = NULL, |
| 64 | 21x |
identity_provider = NULL, |
| 65 | 21x |
review_service = NULL, |
| 66 | 21x |
revision = NULL, |
| 67 | 21x |
spec = NULL, |
| 68 | 21x |
snapshot = NULL, |
| 69 | 21x |
prompt = NULL, |
| 70 | 21x |
choices = NULL, |
| 71 | 21x |
profile = NULL, |
| 72 | 21x |
script = NULL, |
| 73 | 21x |
artifact = NULL, |
| 74 | 21x |
verification = NULL |
| 75 |
) |
|
| 76 |
} |
|
| 77 | ||
| 78 |
.create_assurance_candidate <- function(provider, dataset_id, prompt) {
|
|
| 79 | 10x |
if (!checkmate::test_string(dataset_id, min.chars = 1L)) {
|
| 80 | ! |
cli::cli_abort("Select one authorized BDS dataset")
|
| 81 |
} |
|
| 82 | 10x |
snapshot <- pin_study_snapshot(provider, dataset_id) |
| 83 | 10x |
if (!is.null(provider$source_metadata)) {
|
| 84 | 8x |
snapshot$source_metadata <- provider$source_metadata |
| 85 |
} |
|
| 86 | 10x |
context <- build_prompt_context(snapshot) |
| 87 | 10x |
interpretation <- interpret_prompt(prompt, context, mock_prompt_interpreter()) |
| 88 | 10x |
spec <- interpretation$candidate %||% new_plot_spec(dataset_id = dataset_id) |
| 89 | 10x |
choices <- .spec_choices_from_context(context) |
| 90 | 10x |
list( |
| 91 | 10x |
pending = list( |
| 92 | 10x |
snapshot = snapshot, |
| 93 | 10x |
prompt = prompt, |
| 94 | 10x |
choices = choices |
| 95 |
), |
|
| 96 | 10x |
plot_id = NULL, |
| 97 | 10x |
repository = NULL, |
| 98 | 10x |
identity_provider = NULL, |
| 99 | 10x |
review_service = NULL, |
| 100 | 10x |
revision = NULL, |
| 101 | 10x |
spec = spec, |
| 102 | 10x |
snapshot = snapshot, |
| 103 | 10x |
prompt = prompt, |
| 104 | 10x |
choices = choices, |
| 105 | 10x |
profile = NULL, |
| 106 | 10x |
script = NULL, |
| 107 | 10x |
artifact = NULL, |
| 108 | 10x |
verification = NULL |
| 109 |
) |
|
| 110 |
} |
|
| 111 | ||
| 112 |
.spec_choices_from_context <- function(context) {
|
|
| 113 | 19x |
profile <- context$aggregate_profile |
| 114 | 19x |
list( |
| 115 | 19x |
parameters = names(profile$parameters), |
| 116 | 19x |
units = lapply(profile$parameters, `[[`, "units"), |
| 117 | 19x |
y_variables = profile$y_variables, |
| 118 | 19x |
treatment_variables = profile$treatment_variables, |
| 119 | 19x |
treatment_levels = profile$treatment_levels, |
| 120 | 19x |
visits = vapply(profile$visits, `[[`, "", "label"), |
| 121 | 19x |
scale_modes = profile$scale_modes |
| 122 |
) |
|
| 123 |
} |
|
| 124 | ||
| 125 |
.execute_assurance_revision <- function( |
|
| 126 |
snapshot, |
|
| 127 |
prompt, |
|
| 128 |
fields, |
|
| 129 |
repository_root = NULL |
|
| 130 |
) {
|
|
| 131 | 6x |
spec <- .confirmed_spec_from_fields(snapshot$dataset_id, fields) |
| 132 | 6x |
profile <- validate_bds_profile( |
| 133 | 6x |
snapshot, |
| 134 | 6x |
spec$fields[setdiff(.plot_spec_fields, c("dataset_id", "scale_mode"))]
|
| 135 |
) |
|
| 136 | 6x |
if (profile$blocked) {
|
| 137 | ! |
diagnostic <- profile$blocking_diagnostics[[1]] |
| 138 | ! |
cli::cli_abort(c( |
| 139 | ! |
"The selected records are outside the supported BDS profile", |
| 140 | ! |
"x" = paste0(diagnostic$code, ": ", diagnostic$message) |
| 141 |
)) |
|
| 142 |
} |
|
| 143 | 6x |
.materialize_revision(snapshot, prompt, spec, profile, repository_root) |
| 144 |
} |
|
| 145 | ||
| 146 |
.execute_correction_revision <- function(state, fields, rationale, provenance) {
|
|
| 147 | 1x |
if (is.null(state$repository) || is.null(state$revision) || |
| 148 | 1x |
is.null(state$snapshot)) {
|
| 149 | ! |
cli::cli_abort("Create a revision before starting a correction")
|
| 150 |
} |
|
| 151 | 1x |
parent <- state$repository$get_revision(state$revision$revision_id) |
| 152 | 1x |
if (!parent$status %in% c("Rejected", "Reviewed")) {
|
| 153 | ! |
cli::cli_abort( |
| 154 | ! |
"Only a Rejected or Reviewed revision can start a correction" |
| 155 |
) |
|
| 156 |
} |
|
| 157 | 1x |
if (!checkmate::test_string(rationale, min.chars = 1L) || |
| 158 | 1x |
!checkmate::test_string(provenance, min.chars = 1L)) {
|
| 159 | ! |
cli::cli_abort("Correction rationale and provenance are required")
|
| 160 |
} |
|
| 161 | 1x |
spec <- .confirmed_spec_from_fields(state$snapshot$dataset_id, fields) |
| 162 | 1x |
profile <- validate_bds_profile( |
| 163 | 1x |
state$snapshot, |
| 164 | 1x |
spec$fields[setdiff(.plot_spec_fields, c("dataset_id", "scale_mode"))]
|
| 165 |
) |
|
| 166 | 1x |
if (profile$blocked) {
|
| 167 | ! |
diagnostic <- profile$blocking_diagnostics[[1]] |
| 168 | ! |
cli::cli_abort(c( |
| 169 | ! |
"The selected records are outside the supported BDS profile", |
| 170 | ! |
"x" = paste0(diagnostic$code, ": ", diagnostic$message) |
| 171 |
)) |
|
| 172 |
} |
|
| 173 | ||
| 174 | 1x |
.materialize_correction( |
| 175 | 1x |
state, |
| 176 | 1x |
spec, |
| 177 | 1x |
profile, |
| 178 | 1x |
rationale, |
| 179 | 1x |
provenance |
| 180 |
) |
|
| 181 |
} |
|
| 182 | ||
| 183 |
.create_assurance_revision <- function( |
|
| 184 |
provider, |
|
| 185 |
dataset_id, |
|
| 186 |
prompt, |
|
| 187 |
confirm_free_scale |
|
| 188 |
) {
|
|
| 189 | ! |
snapshot <- pin_study_snapshot(provider, dataset_id) |
| 190 | ! |
context <- build_prompt_context(snapshot) |
| 191 | ! |
interpretation <- interpret_prompt( |
| 192 | ! |
prompt, |
| 193 | ! |
context, |
| 194 | ! |
mock_prompt_interpreter(), |
| 195 | ! |
snapshot = NULL |
| 196 |
) |
|
| 197 | ! |
if (is.null(interpretation$candidate)) {
|
| 198 | ! |
cli::cli_abort("The prompt could not create an executable candidate")
|
| 199 |
} |
|
| 200 | ! |
fields <- interpretation$candidate$fields |
| 201 | ! |
if (identical(fields$scale_mode, "free") && !isTRUE(confirm_free_scale)) {
|
| 202 | ! |
cli::cli_abort( |
| 203 | ! |
paste0( |
| 204 | ! |
"Free Y scales require confirmation because they create an ", |
| 205 | ! |
"Experimental/Draft revision" |
| 206 |
) |
|
| 207 |
) |
|
| 208 |
} |
|
| 209 | ! |
.execute_assurance_revision(snapshot, prompt, fields) |
| 210 |
} |
|
| 211 | ||
| 212 |
.confirmed_spec_from_fields <- function(dataset_id, fields) {
|
|
| 213 | 11x |
if (!checkmate::test_string(fields$paramcd, min.chars = 1L) || |
| 214 | 11x |
!checkmate::test_choice(fields$y_variable, c("AVAL", "CHG", "PCHG")) ||
|
| 215 | 11x |
!checkmate::test_string(fields$unit, min.chars = 1L) || |
| 216 | 11x |
!checkmate::test_string(fields$treatment_variable, min.chars = 1L) || |
| 217 | 11x |
!checkmate::test_character( |
| 218 | 11x |
fields$treatment_levels, |
| 219 | 11x |
min.len = 1L, |
| 220 | 11x |
any.missing = FALSE, |
| 221 | 11x |
unique = TRUE |
| 222 |
) || |
|
| 223 | 11x |
!checkmate::test_character( |
| 224 | 11x |
fields$visits, |
| 225 | 11x |
min.len = 1L, |
| 226 | 11x |
any.missing = FALSE, |
| 227 | 11x |
unique = TRUE |
| 228 |
)) {
|
|
| 229 | ! |
cli::cli_abort(paste0( |
| 230 | ! |
"Confirm one parameter, one Y variable, one unit, one treatment facet, ", |
| 231 | ! |
"and at least one treatment level and visit" |
| 232 |
)) |
|
| 233 |
} |
|
| 234 | 11x |
provenance <- as.list(stats::setNames( |
| 235 | 11x |
rep("user_confirmed", length(.plot_spec_fields)),
|
| 236 | 11x |
.plot_spec_fields |
| 237 |
)) |
|
| 238 | 11x |
provenance$dataset_id <- "metadata" |
| 239 | 11x |
spec <- new_plot_spec( |
| 240 | 11x |
dataset_id = dataset_id, |
| 241 | 11x |
paramcd = fields$paramcd, |
| 242 | 11x |
y_variable = fields$y_variable, |
| 243 | 11x |
unit = fields$unit, |
| 244 | 11x |
treatment_variable = fields$treatment_variable, |
| 245 | 11x |
treatment_levels = fields$treatment_levels, |
| 246 | 11x |
visits = fields$visits, |
| 247 | 11x |
scale_mode = fields$scale_mode %||% "fixed", |
| 248 | 11x |
provenance = as.list(provenance), |
| 249 | 11x |
state = "candidate" |
| 250 |
) |
|
| 251 | 11x |
confirm_plot_spec(spec) |
| 252 |
} |
|
| 253 | ||
| 254 |
.materialize_revision <- function( |
|
| 255 |
snapshot, |
|
| 256 |
prompt, |
|
| 257 |
spec, |
|
| 258 |
profile, |
|
| 259 |
repository_root = NULL |
|
| 260 |
) {
|
|
| 261 | 8x |
script <- compile_boxplot_script(spec, profile$low_n_policy) |
| 262 | 8x |
request <- new_execution_request( |
| 263 | 8x |
spec = spec, |
| 264 | 8x |
script_hash = .execution_hash_text(script), |
| 265 | 8x |
snapshot_id = profile$snapshot_id, |
| 266 | 8x |
snapshot_hash = canonical_hash(profile$analysis_data), |
| 267 | 8x |
harness_version = "execution-harness-v1" |
| 268 |
) |
|
| 269 | 8x |
root <- repository_root %||% fs::file_temp(pattern = "assurance-session-") |
| 270 | 8x |
artifact_store <- local_artifact_store(fs::path(root, "artifacts")) |
| 271 | 8x |
repository <- sqlite_evidence_repository( |
| 272 | 8x |
fs::path(root, "evidence.sqlite"), |
| 273 | 8x |
fs::path(root, "chain-root.json"), |
| 274 | 8x |
artifact_store |
| 275 |
) |
|
| 276 | 8x |
revision_suffix <- substr( |
| 277 | 8x |
canonical_hash(list(spec$hash, Sys.time(), tempfile("revision-"))),
|
| 278 | 8x |
1L, |
| 279 | 8x |
12L |
| 280 |
) |
|
| 281 | 8x |
revision_id <- paste0("rev-", revision_suffix)
|
| 282 | 8x |
plot_id <- paste0("plot-", revision_suffix)
|
| 283 | 8x |
initial_status <- if (identical(spec$fields$scale_mode, "free")) {
|
| 284 | 2x |
"Experimental/Draft" |
| 285 |
} else {
|
|
| 286 | 6x |
"Draft" |
| 287 |
} |
|
| 288 | ||
| 289 | 8x |
code_hash <- artifact_store$put(.execution_text_bytes(script)) |
| 290 | 8x |
repository$create_revision( |
| 291 | 8x |
plot_id = plot_id, |
| 292 | 8x |
revision_id = revision_id, |
| 293 | 8x |
revision_number = 1L, |
| 294 | 8x |
creator_id = "creator", |
| 295 | 8x |
spec_hash = spec$hash, |
| 296 | 8x |
code_hash = code_hash, |
| 297 | 8x |
image_hash = NULL, |
| 298 | 8x |
analytical_hash = NULL, |
| 299 | 8x |
idempotency_key = paste0("create:", revision_id),
|
| 300 | 8x |
initial_status = initial_status |
| 301 |
) |
|
| 302 | ||
| 303 | 8x |
verification <- NULL |
| 304 | 8x |
if (identical(initial_status, "Draft")) {
|
| 305 | 6x |
execution_service <- new_execution_service( |
| 306 | 6x |
repository, |
| 307 | 6x |
artifact_store = artifact_store |
| 308 |
) |
|
| 309 | 6x |
execution <- execution_service$submit( |
| 310 | 6x |
revision_id = revision_id, |
| 311 | 6x |
request = request, |
| 312 | 6x |
script = script, |
| 313 | 6x |
analysis_data = profile$analysis_data, |
| 314 | 6x |
idempotency_key = paste0("execute:", revision_id)
|
| 315 |
) |
|
| 316 | 6x |
verification <- execution$verification |
| 317 | 6x |
runner_result <- execution$runner_result |
| 318 |
} |
|
| 319 | ||
| 320 | 8x |
identities <- local_identity_provider(list( |
| 321 | 8x |
new_actor("creator", "clinical_scientist"),
|
| 322 | 8x |
new_actor("stat-programmer", "statistical_programmer"),
|
| 323 | 8x |
new_actor("biostatistician", "biostatistician")
|
| 324 |
)) |
|
| 325 | 8x |
analysis <- calculate_boxplot_statistics( |
| 326 | 8x |
profile$display_data, |
| 327 | 8x |
treatment_variable = spec$fields$treatment_variable, |
| 328 | 8x |
y_variable = spec$fields$y_variable, |
| 329 | 8x |
facet_levels = profile$facet_levels, |
| 330 | 8x |
visit_levels = profile$visit_levels, |
| 331 | 8x |
low_n_policy = profile$low_n_policy |
| 332 |
) |
|
| 333 | 8x |
artifact <- assemble_boxplot( |
| 334 | 8x |
analysis, |
| 335 | 8x |
scale_mode = spec$fields$scale_mode, |
| 336 | 8x |
unit = spec$fields$unit |
| 337 |
) |
|
| 338 | 8x |
context <- build_prompt_context(snapshot) |
| 339 | ||
| 340 | 8x |
state <- list( |
| 341 | 8x |
pending = NULL, |
| 342 | 8x |
plot_id = plot_id, |
| 343 | 8x |
repository = repository, |
| 344 | 8x |
identity_provider = identities, |
| 345 | 8x |
review_service = new_review_service(repository, identities), |
| 346 | 8x |
revision = repository$get_revision(revision_id), |
| 347 | 8x |
spec = spec, |
| 348 | 8x |
snapshot = snapshot, |
| 349 | 8x |
prompt = prompt, |
| 350 | 8x |
choices = .spec_choices_from_context(context), |
| 351 | 8x |
profile = profile, |
| 352 | 8x |
script = script, |
| 353 | 8x |
artifact = artifact, |
| 354 | 8x |
verification = verification |
| 355 |
) |
|
| 356 | 8x |
if (!is.null(verification)) {
|
| 357 | 6x |
.record_revision_context( # nolint: object_usage_linter. |
| 358 | 6x |
state, |
| 359 | 6x |
request = request, |
| 360 | 6x |
runner_result = runner_result |
| 361 |
) |
|
| 362 |
} |
|
| 363 | 8x |
state |
| 364 |
} |
|
| 365 | ||
| 366 |
.materialize_correction <- function( |
|
| 367 |
state, |
|
| 368 |
spec, |
|
| 369 |
profile, |
|
| 370 |
rationale, |
|
| 371 |
provenance |
|
| 372 |
) {
|
|
| 373 | 1x |
script <- compile_boxplot_script(spec, profile$low_n_policy) |
| 374 | 1x |
request <- new_execution_request( |
| 375 | 1x |
spec = spec, |
| 376 | 1x |
script_hash = .execution_hash_text(script), |
| 377 | 1x |
snapshot_id = profile$snapshot_id, |
| 378 | 1x |
snapshot_hash = canonical_hash(profile$analysis_data), |
| 379 | 1x |
harness_version = "execution-harness-v1" |
| 380 |
) |
|
| 381 | 1x |
artifact_store <- state$repository$artifact_store |
| 382 | 1x |
if (is.null(artifact_store)) {
|
| 383 | ! |
cli::cli_abort("Correction revisions require an artifact store")
|
| 384 |
} |
|
| 385 | 1x |
parent <- state$repository$get_revision(state$revision$revision_id) |
| 386 | 1x |
revisions <- state$repository$list_revisions(parent$plot_id) |
| 387 | 1x |
revision_number <- max(revisions$revision_number) + 1L |
| 388 | 1x |
revision_id <- paste0("rev-", substr(canonical_hash(list(
|
| 389 | 1x |
parent$revision_id, |
| 390 | 1x |
spec$hash, |
| 391 | 1x |
revision_number, |
| 392 | 1x |
Sys.time() |
| 393 | 1x |
)), 1L, 12L)) |
| 394 | 1x |
code_hash <- artifact_store$put(.execution_text_bytes(script)) |
| 395 | 1x |
lifecycle <- new_lifecycle_service(state$repository) |
| 396 | 1x |
lifecycle$create_correction( |
| 397 | 1x |
parent_revision_id = parent$revision_id, |
| 398 | 1x |
revision_id = revision_id, |
| 399 | 1x |
creator_id = "creator", |
| 400 | 1x |
spec_hash = spec$hash, |
| 401 | 1x |
code_hash = code_hash, |
| 402 | 1x |
image_hash = NULL, |
| 403 | 1x |
analytical_hash = NULL, |
| 404 | 1x |
rationale = rationale, |
| 405 | 1x |
provenance = provenance, |
| 406 | 1x |
idempotency_key = paste0("correct:", revision_id),
|
| 407 | 1x |
initial_status = .initial_revision_status(spec$fields$scale_mode) |
| 408 |
) |
|
| 409 | ||
| 410 | 1x |
verification <- NULL |
| 411 | 1x |
if (identical(spec$fields$scale_mode, "fixed")) {
|
| 412 | 1x |
execution <- new_execution_service( |
| 413 | 1x |
state$repository, |
| 414 | 1x |
artifact_store = artifact_store |
| 415 | 1x |
)$submit( |
| 416 | 1x |
revision_id = revision_id, |
| 417 | 1x |
request = request, |
| 418 | 1x |
script = script, |
| 419 | 1x |
analysis_data = profile$analysis_data, |
| 420 | 1x |
idempotency_key = paste0("execute:", revision_id)
|
| 421 |
) |
|
| 422 | 1x |
verification <- execution$verification |
| 423 | 1x |
runner_result <- execution$runner_result |
| 424 |
} |
|
| 425 | ||
| 426 | 1x |
analysis <- calculate_boxplot_statistics( |
| 427 | 1x |
profile$display_data, |
| 428 | 1x |
treatment_variable = spec$fields$treatment_variable, |
| 429 | 1x |
y_variable = spec$fields$y_variable, |
| 430 | 1x |
facet_levels = profile$facet_levels, |
| 431 | 1x |
visit_levels = profile$visit_levels, |
| 432 | 1x |
low_n_policy = profile$low_n_policy |
| 433 |
) |
|
| 434 | 1x |
artifact <- assemble_boxplot( |
| 435 | 1x |
analysis, |
| 436 | 1x |
scale_mode = spec$fields$scale_mode, |
| 437 | 1x |
unit = spec$fields$unit |
| 438 |
) |
|
| 439 | ||
| 440 | 1x |
state$pending <- NULL |
| 441 | 1x |
state$revision <- state$repository$get_revision(revision_id) |
| 442 | 1x |
state$spec <- spec |
| 443 | 1x |
state$profile <- profile |
| 444 | 1x |
state$script <- script |
| 445 | 1x |
state$artifact <- artifact |
| 446 | 1x |
state$verification <- verification |
| 447 | 1x |
if (!is.null(verification)) {
|
| 448 | 1x |
.record_revision_context( # nolint: object_usage_linter. |
| 449 | 1x |
state, |
| 450 | 1x |
request = request, |
| 451 | 1x |
runner_result = runner_result |
| 452 |
) |
|
| 453 |
} |
|
| 454 | 1x |
state |
| 455 |
} |
| 1 |
.latest_revision_context <- function(repository, revision_id) {
|
|
| 2 | 1x |
evidence <- repository$list_evidence(revision_id) |
| 3 | 1x |
evidence <- evidence[ |
| 4 | 1x |
evidence$evidence_type == "revision_context", , |
| 5 | 1x |
drop = FALSE |
| 6 |
] |
|
| 7 | 1x |
if (!nrow(evidence)) {
|
| 8 | ! |
cli::cli_abort( |
| 9 | ! |
"Revision {.val {revision_id}} has no revision context evidence"
|
| 10 |
) |
|
| 11 |
} |
|
| 12 | 1x |
jsonlite::fromJSON( |
| 13 | 1x |
evidence$details_json[[nrow(evidence)]], |
| 14 | 1x |
simplifyVector = FALSE |
| 15 |
) |
|
| 16 |
} |
|
| 17 | ||
| 18 |
.evidence_selected_data <- function(details) {
|
|
| 19 | 1x |
columns <- lapply(details$input_data$selected_data, function(column) {
|
| 20 | ! |
if (is.list(column)) unlist(column, use.names = FALSE) else column |
| 21 |
}) |
|
| 22 | 1x |
column_order <- unlist( |
| 23 | 1x |
details$input_data$selected_data_columns %||% names(columns), |
| 24 | 1x |
use.names = FALSE |
| 25 |
) |
|
| 26 | 1x |
columns <- columns[column_order] |
| 27 | 1x |
columns <- lapply(columns, function(column) {
|
| 28 | 5x |
column[column == "NA"] <- NA |
| 29 | 5x |
column |
| 30 |
}) |
|
| 31 | 1x |
classes <- unlist( |
| 32 | 1x |
details$input_data$selected_data_classes %||% list(), |
| 33 | 1x |
use.names = TRUE |
| 34 |
) |
|
| 35 | 1x |
if (!is.null(names(classes))) {
|
| 36 | 1x |
names(classes) <- sub("^.*[.]", "", names(classes))
|
| 37 |
} |
|
| 38 | 1x |
if (!length(names(classes)) && identical(length(classes), length(columns))) {
|
| 39 | ! |
names(classes) <- column_order |
| 40 |
} |
|
| 41 | 1x |
for (name in intersect(names(columns), names(classes))) {
|
| 42 | 5x |
if (classes[[name]] %in% c("numeric", "integer")) {
|
| 43 | 2x |
columns[[name]] <- as.numeric(columns[[name]]) |
| 44 |
} |
|
| 45 | 5x |
if (identical(classes[[name]], "integer")) {
|
| 46 | ! |
columns[[name]] <- as.integer(columns[[name]]) |
| 47 |
} |
|
| 48 |
} |
|
| 49 | 1x |
data <- as.data.frame(columns, stringsAsFactors = FALSE) |
| 50 | 1x |
data |
| 51 |
} |
|
| 52 | ||
| 53 |
.evidence_execution_request <- function(details) {
|
|
| 54 | 1x |
structure(details$request, class = "execution_request") |
| 55 |
} |
|
| 56 | ||
| 57 |
new_reproducibility_service <- function( |
|
| 58 |
repository, |
|
| 59 |
artifact_store, |
|
| 60 |
runner = new_execution_runner(), |
|
| 61 |
verification_service = new_verification_service() |
|
| 62 |
) {
|
|
| 63 | 1x |
.validate_evidence_repository( |
| 64 | 1x |
repository, |
| 65 | 1x |
c("get_revision", "list_evidence", "record_evidence")
|
| 66 |
) |
|
| 67 | 1x |
if (!is.list(artifact_store) || |
| 68 | 1x |
!is.function(artifact_store$get) || |
| 69 | 1x |
!is.function(artifact_store$verify)) {
|
| 70 | ! |
cli::cli_abort("Reproducibility service requires a readable artifact store")
|
| 71 |
} |
|
| 72 | 1x |
if (!is.function(runner$run)) {
|
| 73 | ! |
cli::cli_abort("Reproducibility runner must provide {.fn run}")
|
| 74 |
} |
|
| 75 | 1x |
if (!is.function(verification_service$verify)) {
|
| 76 | ! |
cli::cli_abort("Verification service must provide {.fn verify}")
|
| 77 |
} |
|
| 78 | ||
| 79 | 1x |
reproduce_revision <- function(revision_id, idempotency_key = NULL) {
|
| 80 | 1x |
.validate_identifier(revision_id, "revision_id") |
| 81 | 1x |
if (!is.null(idempotency_key)) {
|
| 82 | 1x |
.validate_identifier(idempotency_key, "idempotency_key") |
| 83 |
} |
|
| 84 | 1x |
revision <- repository$get_revision(revision_id) |
| 85 | 1x |
details <- .latest_revision_context(repository, revision_id) |
| 86 | 1x |
request <- .evidence_execution_request(details) |
| 87 | 1x |
script <- rawToChar(artifact_store$get(revision$code_hash)) |
| 88 | 1x |
analysis_data <- .evidence_selected_data(details) |
| 89 | 1x |
runner_result <- runner$run( |
| 90 | 1x |
request = request, |
| 91 | 1x |
script = script, |
| 92 | 1x |
analysis_data = analysis_data, |
| 93 | 1x |
data_classification = details$input_data$classification %||% "synthetic" |
| 94 |
) |
|
| 95 | 1x |
verification <- verification_service$verify( |
| 96 | 1x |
request = request, |
| 97 | 1x |
script = script, |
| 98 | 1x |
analysis_data = analysis_data, |
| 99 | 1x |
runner_result = runner_result |
| 100 |
) |
|
| 101 | 1x |
environment_match <- identical( |
| 102 | 1x |
runner_result$result$environment_fingerprint, |
| 103 | 1x |
details$environment$fingerprint |
| 104 |
) |
|
| 105 | 1x |
checks <- list( |
| 106 | 1x |
specification = identical(request$spec_hash, revision$spec_hash), |
| 107 | 1x |
generated_r_code = identical( |
| 108 | 1x |
.execution_hash_text(script), revision$code_hash |
| 109 |
), |
|
| 110 | 1x |
analytical_result = identical( |
| 111 | 1x |
runner_result$result$analytical_hash, |
| 112 | 1x |
revision$analytical_hash |
| 113 |
), |
|
| 114 | 1x |
environment = environment_match, |
| 115 | 1x |
image_bytes = if (environment_match) {
|
| 116 | 1x |
identical(runner_result$result$image_hash, revision$image_hash) |
| 117 |
} else {
|
|
| 118 | 1x |
NA |
| 119 |
} |
|
| 120 |
) |
|
| 121 | 1x |
failed <- names(checks)[vapply(checks, identical, logical(1), FALSE)] |
| 122 | 1x |
status <- if (length(failed)) "failed" else "passed" |
| 123 | 1x |
result <- structure( |
| 124 | 1x |
list( |
| 125 | 1x |
reproducibility_version = "revision-reproducibility-v1", |
| 126 | 1x |
status = status, |
| 127 | 1x |
checks = checks, |
| 128 | 1x |
failed_checks = failed, |
| 129 | 1x |
verification = unclass(verification) |
| 130 |
), |
|
| 131 | 1x |
class = "revision_reproducibility_result" |
| 132 |
) |
|
| 133 | 1x |
if (!is.null(idempotency_key)) {
|
| 134 | 1x |
repository$record_evidence( |
| 135 | 1x |
evidence_id = .revision_evidence_id(revision_id, idempotency_key), |
| 136 | 1x |
revision_id = revision_id, |
| 137 | 1x |
evidence_type = "reproducibility_rerun", |
| 138 | 1x |
outcome = status, |
| 139 | 1x |
idempotency_key = idempotency_key, |
| 140 | 1x |
details = unclass(result) |
| 141 |
) |
|
| 142 |
} |
|
| 143 | 1x |
result |
| 144 |
} |
|
| 145 | ||
| 146 | 1x |
structure( |
| 147 | 1x |
list(reproduce_revision = reproduce_revision), |
| 148 | 1x |
class = "reproducibility_service" |
| 149 |
) |
|
| 150 |
} |
| 1 |
.validate_boxplot_design <- function(facet_levels, visit_levels, low_n_policy) {
|
|
| 2 | 54x |
if (!checkmate::test_character( |
| 3 | 54x |
facet_levels, |
| 4 | 54x |
min.len = 1L, |
| 5 | 54x |
any.missing = FALSE, |
| 6 | 54x |
unique = TRUE |
| 7 | 54x |
) || !checkmate::test_character( |
| 8 | 54x |
visit_levels, |
| 9 | 54x |
min.len = 1L, |
| 10 | 54x |
any.missing = FALSE, |
| 11 | 54x |
unique = TRUE |
| 12 |
)) {
|
|
| 13 | ! |
cli::cli_abort(paste0( |
| 14 | ! |
"Facet and visit levels must be ordered, ", |
| 15 | ! |
"unique character vectors" |
| 16 |
)) |
|
| 17 |
} |
|
| 18 | 54x |
required_policy <- c("value", "rationale", "authority", "version")
|
| 19 | 54x |
if (!is.list(low_n_policy) || |
| 20 | 54x |
!setequal(names(low_n_policy), required_policy) || |
| 21 | 54x |
!checkmate::test_number(low_n_policy$value, lower = 1, finite = TRUE) || |
| 22 | 54x |
!all(vapply( |
| 23 | 54x |
low_n_policy[setdiff(required_policy, "value")], |
| 24 | 54x |
checkmate::test_string, |
| 25 | 54x |
logical(1), |
| 26 | 54x |
min.chars = 1L |
| 27 |
))) {
|
|
| 28 | ! |
cli::cli_abort(paste0( |
| 29 | ! |
"{.arg low_n_policy} must retain value, rationale, ",
|
| 30 | ! |
"authority, and version" |
| 31 |
)) |
|
| 32 |
} |
|
| 33 | 54x |
invisible(TRUE) |
| 34 |
} |
|
| 35 | ||
| 36 |
.validate_boxplot_inputs <- function( |
|
| 37 |
data, |
|
| 38 |
treatment_variable, |
|
| 39 |
y_variable, |
|
| 40 |
facet_levels, |
|
| 41 |
visit_levels, |
|
| 42 |
low_n_policy |
|
| 43 |
) {
|
|
| 44 | 21x |
required_columns <- c( |
| 45 | 21x |
"USUBJID", "AVISIT", "AVISITN", treatment_variable, y_variable |
| 46 |
) |
|
| 47 | 21x |
missing_columns <- setdiff(required_columns, names(data)) |
| 48 | 21x |
if (!is.data.frame(data) || length(missing_columns)) {
|
| 49 | ! |
cli::cli_abort( |
| 50 | ! |
paste0( |
| 51 | ! |
"{.arg data} must contain the governed analytical columns: ",
|
| 52 | ! |
"{.field {required_columns}}"
|
| 53 |
) |
|
| 54 |
) |
|
| 55 |
} |
|
| 56 | 21x |
if (!is.numeric(data[[y_variable]])) {
|
| 57 | ! |
cli::cli_abort("The selected Y variable must be numeric")
|
| 58 |
} |
|
| 59 | 21x |
.validate_boxplot_design(facet_levels, visit_levels, low_n_policy) |
| 60 |
} |
|
| 61 | ||
| 62 |
.type7_quantile_from_sorted <- function(values, probability) {
|
|
| 63 | 342x |
count <- length(values) |
| 64 | 342x |
position <- (count - 1) * probability + 1 |
| 65 | 342x |
lower_index <- floor(position) |
| 66 | 342x |
fraction <- position - lower_index |
| 67 | 342x |
if (fraction == 0 || lower_index == count) {
|
| 68 | 158x |
return(values[[lower_index]]) |
| 69 |
} |
|
| 70 | 184x |
values[[lower_index]] + fraction * ( |
| 71 | 184x |
values[[lower_index + 1L]] - values[[lower_index]] |
| 72 |
) |
|
| 73 |
} |
|
| 74 | ||
| 75 |
.summarize_box <- function( |
|
| 76 |
rows, |
|
| 77 |
treatment_variable, |
|
| 78 |
y_variable, |
|
| 79 |
low_n_threshold |
|
| 80 |
) {
|
|
| 81 | 114x |
values <- rows[[y_variable]] |
| 82 | 114x |
sorted_values <- sort(values, method = "radix") |
| 83 | 114x |
lower <- .type7_quantile_from_sorted(sorted_values, 0.25) |
| 84 | 114x |
middle <- .type7_quantile_from_sorted(sorted_values, 0.5) |
| 85 | 114x |
upper <- .type7_quantile_from_sorted(sorted_values, 0.75) |
| 86 | 114x |
interquartile_range <- upper - lower |
| 87 | 114x |
lower_fence <- lower - 1.5 * interquartile_range |
| 88 | 114x |
upper_fence <- upper + 1.5 * interquartile_range |
| 89 | 114x |
inside <- values >= lower_fence & values <= upper_fence |
| 90 | 114x |
distinct_n <- length(unique(as.character(rows$USUBJID))) |
| 91 | ||
| 92 | 114x |
list( |
| 93 | 114x |
box = data.frame( |
| 94 | 114x |
treatment = as.character(rows[[treatment_variable]][[1]]), |
| 95 | 114x |
visit = as.character(rows$AVISIT[[1]]), |
| 96 | 114x |
visit_n = rows$AVISITN[[1]], |
| 97 | 114x |
n = as.integer(distinct_n), |
| 98 | 114x |
ymin = min(values[inside]), |
| 99 | 114x |
lower = lower, |
| 100 | 114x |
middle = middle, |
| 101 | 114x |
upper = upper, |
| 102 | 114x |
ymax = max(values[inside]), |
| 103 | 114x |
low_n = distinct_n < low_n_threshold, |
| 104 | 114x |
stringsAsFactors = FALSE |
| 105 |
), |
|
| 106 | 114x |
outliers = data.frame( |
| 107 | 114x |
treatment = as.character(rows[[treatment_variable]][!inside]), |
| 108 | 114x |
visit = as.character(rows$AVISIT[!inside]), |
| 109 | 114x |
visit_n = rows$AVISITN[!inside], |
| 110 | 114x |
USUBJID = as.character(rows$USUBJID[!inside]), |
| 111 | 114x |
value = values[!inside], |
| 112 | 114x |
stringsAsFactors = FALSE |
| 113 |
) |
|
| 114 |
) |
|
| 115 |
} |
|
| 116 | ||
| 117 |
.summarize_box_indices <- function( |
|
| 118 |
index, |
|
| 119 |
data, |
|
| 120 |
treatment_variable, |
|
| 121 |
y_variable, |
|
| 122 |
low_n_threshold |
|
| 123 |
) {
|
|
| 124 | 114x |
.summarize_box( |
| 125 | 114x |
data[index, , drop = FALSE], |
| 126 | 114x |
treatment_variable, |
| 127 | 114x |
y_variable, |
| 128 | 114x |
low_n_threshold |
| 129 |
) |
|
| 130 |
} |
|
| 131 | ||
| 132 |
.empty_outlier_data <- function() {
|
|
| 133 | 19x |
data.frame( |
| 134 | 19x |
treatment = character(), |
| 135 | 19x |
visit = character(), |
| 136 | 19x |
visit_n = numeric(), |
| 137 | 19x |
USUBJID = character(), |
| 138 | 19x |
value = numeric(), |
| 139 | 19x |
stringsAsFactors = FALSE |
| 140 |
) |
|
| 141 |
} |
|
| 142 | ||
| 143 |
#' Calculate governed longitudinal boxplot statistics |
|
| 144 |
#' |
|
| 145 |
#' Calculates type-7 hinges, 1.5-IQR whiskers, visible outlier membership, |
|
| 146 |
#' distinct-subject counts, low-N flags, and median-line rows from the selected |
|
| 147 |
#' analytical data. Missing selected Y values do not contribute. |
|
| 148 |
#' |
|
| 149 |
#' @param data Selected records from a validated visit-based BDS profile. |
|
| 150 |
#' @param treatment_variable Name of the selected treatment variable. |
|
| 151 |
#' @param y_variable Name of the selected numeric Y variable. |
|
| 152 |
#' @param facet_levels Ordered included treatment values. |
|
| 153 |
#' @param visit_levels Ordered included visit labels. |
|
| 154 |
#' @param low_n_policy Retained policy with `value`, `rationale`, `authority`, |
|
| 155 |
#' and `version`. |
|
| 156 |
#' |
|
| 157 |
#' @return A deterministic `boxplot_analysis` object. |
|
| 158 |
#' @export |
|
| 159 |
calculate_boxplot_statistics <- function( |
|
| 160 |
data, |
|
| 161 |
treatment_variable, |
|
| 162 |
y_variable, |
|
| 163 |
facet_levels, |
|
| 164 |
visit_levels, |
|
| 165 |
low_n_policy |
|
| 166 |
) {
|
|
| 167 | 21x |
.validate_boxplot_inputs( |
| 168 | 21x |
data, |
| 169 | 21x |
treatment_variable, |
| 170 | 21x |
y_variable, |
| 171 | 21x |
facet_levels, |
| 172 | 21x |
visit_levels, |
| 173 | 21x |
low_n_policy |
| 174 |
) |
|
| 175 | 21x |
low_n_policy$value <- as.numeric(low_n_policy$value) |
| 176 | 21x |
display <- data[ |
| 177 | 21x |
!is.na(data[[y_variable]]) & |
| 178 | 21x |
!is.na(data[[treatment_variable]]) & |
| 179 | 21x |
data[[treatment_variable]] %in% facet_levels & |
| 180 | 21x |
!is.na(data$AVISIT) & |
| 181 | 21x |
data$AVISIT %in% visit_levels, , |
| 182 | 21x |
drop = FALSE |
| 183 |
] |
|
| 184 | 21x |
if (any(!is.finite(display[[y_variable]]))) {
|
| 185 | 1x |
cli::cli_abort("The selected Y variable must contain only finite values")
|
| 186 |
} |
|
| 187 | 20x |
if (nrow(display)) {
|
| 188 | 20x |
treatment_order <- match( |
| 189 | 20x |
as.character(display[[treatment_variable]]), |
| 190 | 20x |
facet_levels |
| 191 |
) |
|
| 192 | 20x |
visit_order <- match(as.character(display$AVISIT), visit_levels) |
| 193 | 20x |
ordering <- order( |
| 194 | 20x |
treatment_order, |
| 195 | 20x |
visit_order, |
| 196 | 20x |
as.character(display$USUBJID), |
| 197 | 20x |
method = "radix" |
| 198 |
) |
|
| 199 | 20x |
display <- display[ordering, , drop = FALSE] |
| 200 | 20x |
row.names(display) <- NULL |
| 201 | 20x |
group_start <- c( |
| 202 | 20x |
TRUE, |
| 203 | 20x |
as.character(display[[treatment_variable]][-1L]) != |
| 204 | 20x |
as.character(display[[treatment_variable]][-nrow(display)]) | |
| 205 | 20x |
as.character(display$AVISIT[-1L]) != |
| 206 | 20x |
as.character(display$AVISIT[-nrow(display)]) |
| 207 |
) |
|
| 208 | 20x |
group_id <- cumsum(group_start) |
| 209 | 20x |
summaries <- lapply( |
| 210 | 20x |
split(seq_len(nrow(display)), group_id), |
| 211 | 20x |
.summarize_box_indices, |
| 212 | 20x |
data = display, |
| 213 | 20x |
treatment_variable = treatment_variable, |
| 214 | 20x |
y_variable = y_variable, |
| 215 | 20x |
low_n_threshold = low_n_policy$value |
| 216 |
) |
|
| 217 | 20x |
boxes <- do.call(rbind, lapply(summaries, `[[`, "box")) |
| 218 | 20x |
outlier_rows <- lapply(summaries, `[[`, "outliers") |
| 219 | 20x |
outliers <- if (sum(vapply(outlier_rows, nrow, integer(1))) > 0L) {
|
| 220 | 1x |
do.call(rbind, outlier_rows) |
| 221 |
} else {
|
|
| 222 | 19x |
.empty_outlier_data() |
| 223 |
} |
|
| 224 | 20x |
box_order <- order( |
| 225 | 20x |
match(boxes$treatment, facet_levels), |
| 226 | 20x |
match(boxes$visit, visit_levels), |
| 227 | 20x |
method = "radix" |
| 228 |
) |
|
| 229 | 20x |
boxes <- boxes[box_order, , drop = FALSE] |
| 230 | 20x |
row.names(boxes) <- NULL |
| 231 | 20x |
if (nrow(outliers)) {
|
| 232 | 1x |
outlier_order <- order( |
| 233 | 1x |
match(outliers$treatment, facet_levels), |
| 234 | 1x |
match(outliers$visit, visit_levels), |
| 235 | 1x |
outliers$value, |
| 236 | 1x |
outliers$USUBJID, |
| 237 | 1x |
method = "radix" |
| 238 |
) |
|
| 239 | 1x |
outliers <- outliers[outlier_order, , drop = FALSE] |
| 240 | 1x |
row.names(outliers) <- NULL |
| 241 |
} |
|
| 242 |
} else {
|
|
| 243 | ! |
boxes <- data.frame( |
| 244 | ! |
treatment = character(), visit = character(), visit_n = numeric(), |
| 245 | ! |
n = integer(), ymin = numeric(), lower = numeric(), middle = numeric(), |
| 246 | ! |
upper = numeric(), ymax = numeric(), low_n = logical(), |
| 247 | ! |
stringsAsFactors = FALSE |
| 248 |
) |
|
| 249 | ! |
outliers <- .empty_outlier_data() |
| 250 |
} |
|
| 251 | 20x |
medians <- boxes[c("treatment", "visit", "visit_n", "middle")]
|
| 252 | 20x |
names(medians)[[4]] <- "median" |
| 253 | 20x |
n_strip <- boxes[c("treatment", "visit", "visit_n", "n", "low_n")]
|
| 254 | 20x |
n_strip$label <- ifelse( |
| 255 | 20x |
n_strip$low_n, |
| 256 | 20x |
paste0("N = ", n_strip$n, " - Low N"),
|
| 257 | 20x |
paste0("N = ", n_strip$n)
|
| 258 |
) |
|
| 259 | ||
| 260 | 20x |
structure( |
| 261 | 20x |
list( |
| 262 | 20x |
analysis_version = "boxplot-analysis-v1", |
| 263 | 20x |
treatment_variable = treatment_variable, |
| 264 | 20x |
y_variable = y_variable, |
| 265 | 20x |
facet_levels = facet_levels, |
| 266 | 20x |
visit_levels = visit_levels, |
| 267 | 20x |
low_n_policy = low_n_policy, |
| 268 | 20x |
boxes = boxes, |
| 269 | 20x |
outliers = outliers, |
| 270 | 20x |
medians = medians, |
| 271 | 20x |
n_strip = n_strip |
| 272 |
), |
|
| 273 | 20x |
class = "boxplot_analysis" |
| 274 |
) |
|
| 275 |
} |
| 1 |
reconcile_execution_attempts <- function(repository, revision_ids) {
|
|
| 2 | 1x |
.validate_evidence_repository( |
| 3 | 1x |
repository, |
| 4 | 1x |
c("list_attempts", "complete_attempt")
|
| 5 |
) |
|
| 6 | 1x |
if (!checkmate::test_character( |
| 7 | 1x |
revision_ids, |
| 8 | 1x |
any.missing = FALSE, |
| 9 | 1x |
unique = TRUE |
| 10 |
)) {
|
|
| 11 | ! |
cli::cli_abort( |
| 12 | ! |
"{.arg revision_ids} must contain unique revision identifiers"
|
| 13 |
) |
|
| 14 |
} |
|
| 15 | 1x |
reconciled <- list() |
| 16 | 1x |
for (revision_id in revision_ids) {
|
| 17 | 1x |
attempts <- repository$list_attempts(revision_id) |
| 18 | 1x |
active <- attempts[is.na(attempts$outcome), , drop = FALSE] |
| 19 | ! |
if (!nrow(active)) next |
| 20 | 1x |
for (index in seq_len(nrow(active))) {
|
| 21 | 1x |
attempt_id <- active$attempt_id[[index]] |
| 22 | 1x |
repository$complete_attempt( |
| 23 | 1x |
attempt_id, |
| 24 | 1x |
"failed", |
| 25 | 1x |
paste0("reconcile:", attempt_id)
|
| 26 |
) |
|
| 27 | 1x |
reconciled[[attempt_id]] <- "failed" |
| 28 |
} |
|
| 29 |
} |
|
| 30 | 1x |
structure( |
| 31 | 1x |
list( |
| 32 | 1x |
reconciliation_version = "attempt-reconciliation-v1", |
| 33 | 1x |
reconciled = reconciled |
| 34 |
), |
|
| 35 | 1x |
class = "attempt_reconciliation" |
| 36 |
) |
|
| 37 |
} |
| 1 |
.validate_artifact_hash <- function(hash) {
|
|
| 2 | 327x |
if (!checkmate::test_string(hash, pattern = "^[0-9a-f]{64}$")) {
|
| 3 | ! |
cli::cli_abort("Artifact hash must be a lowercase SHA-256 value")
|
| 4 |
} |
|
| 5 | 327x |
invisible(hash) |
| 6 |
} |
| 1 |
mod_export_ui <- function(id) {
|
|
| 2 | 6x |
ns <- shiny::NS(id) |
| 3 | 6x |
bslib::card( |
| 4 | 6x |
class = "assurance-panel", |
| 5 | 6x |
bslib::card_header("Controlled export"),
|
| 6 | 6x |
bslib::card_body( |
| 7 | 6x |
shiny::uiOutput(ns("controls")),
|
| 8 | 6x |
shiny::verbatimTextOutput(ns("message")),
|
| 9 | 6x |
shiny::tableOutput(ns("receipts"))
|
| 10 |
) |
|
| 11 |
) |
|
| 12 |
} |
|
| 13 | ||
| 14 |
mod_export_server <- function(id, current_revision, workspace_provider) {
|
|
| 15 | 15x |
shiny::moduleServer(id, function(input, output, session) {
|
| 16 | 15x |
message <- shiny::reactiveVal("No export attempted.")
|
| 17 | 15x |
receipt_refresh <- shiny::reactiveVal(0L) |
| 18 | ||
| 19 | 15x |
export_ready <- shiny::reactive({
|
| 20 | 25x |
identical(current_revision()$revision$status, "Reviewed") |
| 21 |
}) |
|
| 22 | ||
| 23 | 15x |
output$controls <- shiny::renderUI({
|
| 24 | 25x |
shiny::tags$fieldset( |
| 25 | 25x |
disabled = if (!export_ready()) NA else NULL, |
| 26 | 25x |
shiny::selectInput( |
| 27 | 25x |
session$ns("actor_id"),
|
| 28 | 25x |
"Actor", |
| 29 | 25x |
choices = c( |
| 30 | 25x |
"Creator" = "creator", |
| 31 | 25x |
"Statistical programmer" = "stat-programmer", |
| 32 | 25x |
"Biostatistician" = "biostatistician" |
| 33 |
) |
|
| 34 |
), |
|
| 35 | 25x |
shiny::textInput( |
| 36 | 25x |
session$ns("destination_id"), "Destination",
|
| 37 | 25x |
value = "local" |
| 38 |
), |
|
| 39 | 25x |
shiny::downloadButton( |
| 40 | 25x |
session$ns("download_zip"), "Export and download ZIP",
|
| 41 | 25x |
enabled = export_ready() |
| 42 |
) |
|
| 43 |
) |
|
| 44 |
}) |
|
| 45 | ||
| 46 | 15x |
output$message <- shiny::renderText(message()) |
| 47 | 15x |
output$download_zip <- shiny::downloadHandler( |
| 48 | 15x |
filename = function() {
|
| 49 | 13x |
state <- current_revision() |
| 50 | 13x |
if (is.null(state$revision)) {
|
| 51 | ! |
return("image-code.zip")
|
| 52 |
} |
|
| 53 | 13x |
paste0(state$revision$revision_id, "-image-code.zip") |
| 54 |
}, |
|
| 55 | 15x |
content = function(file) {
|
| 56 | 13x |
shiny::req(export_ready()) |
| 57 | 8x |
state <- current_revision() |
| 58 | 8x |
succeeded <- tryCatch( |
| 59 |
{
|
|
| 60 | 8x |
if (is.null(state$revision) || is.null(state$repository)) {
|
| 61 | ! |
cli::cli_abort("Create a revision before export")
|
| 62 |
} |
|
| 63 | 8x |
if (is.null(state$identity_provider)) {
|
| 64 | ! |
cli::cli_abort("Export requires an identity provider")
|
| 65 |
} |
|
| 66 | 8x |
service <- new_export_service( |
| 67 | 8x |
state$repository, |
| 68 | 8x |
state$repository$artifact_store, |
| 69 | 8x |
workspace_provider, |
| 70 | 8x |
state$identity_provider |
| 71 |
) |
|
| 72 | 8x |
receipt <- service$export_revision( |
| 73 | 8x |
revision_id = state$revision$revision_id, |
| 74 | 8x |
actor_id = input$actor_id, |
| 75 | 8x |
destination_id = input$destination_id, |
| 76 | 8x |
expected_version = as.integer(state$revision$version), |
| 77 | 8x |
idempotency_key = paste( |
| 78 | 8x |
"app-export", |
| 79 | 8x |
state$revision$revision_id, |
| 80 | 8x |
input$actor_id, |
| 81 | 8x |
input$destination_id, |
| 82 | 8x |
sep = ":" |
| 83 |
) |
|
| 84 |
) |
|
| 85 | 7x |
receipt_refresh(receipt_refresh() + 1L) |
| 86 | 7x |
.write_export_zip(receipt, file, state$revision$revision_id) |
| 87 | 6x |
message(paste("Exported", receipt$receipt_id, receipt$revision_id))
|
| 88 | 6x |
TRUE |
| 89 |
}, |
|
| 90 | 8x |
error = function(error) {
|
| 91 | 2x |
message(conditionMessage(error)) |
| 92 | 2x |
FALSE |
| 93 |
} |
|
| 94 |
) |
|
| 95 | 8x |
shiny::req(succeeded) |
| 96 |
}, |
|
| 97 | 15x |
contentType = "application/zip" |
| 98 |
) |
|
| 99 | 15x |
output$receipts <- shiny::renderTable( |
| 100 |
{
|
|
| 101 | 31x |
receipt_refresh() |
| 102 | 31x |
state <- current_revision() |
| 103 | 31x |
if (is.null(state$revision) || is.null(state$repository)) {
|
| 104 | 7x |
return(data.frame()) |
| 105 |
} |
|
| 106 | 24x |
state$repository$list_export_receipts(state$revision$revision_id) |
| 107 |
}, |
|
| 108 | 15x |
striped = TRUE, |
| 109 | 15x |
spacing = "s" |
| 110 |
) |
|
| 111 |
}) |
|
| 112 |
} |
|
| 113 | ||
| 114 |
.write_export_zip <- function(export, file, revision_id) {
|
|
| 115 | 8x |
if (is.null(export)) {
|
| 116 | ! |
cli::cli_abort("Export the revision before download")
|
| 117 |
} |
|
| 118 | 8x |
if (!identical(export$revision_id, revision_id)) {
|
| 119 | 1x |
cli::cli_abort("Export the current revision before download")
|
| 120 |
} |
|
| 121 | ||
| 122 | 7x |
root <- fs::dir_create(fs::file_temp("revision-download-"))
|
| 123 | 7x |
fs::file_copy(export$code_path, fs::path(root, "script.R")) |
| 124 | 7x |
fs::file_copy(export$image_path, fs::path(root, "plot.png")) |
| 125 | ||
| 126 | 7x |
file <- fs::path_abs(file) |
| 127 | 7x |
old <- getwd() |
| 128 | 7x |
on.exit( |
| 129 |
{
|
|
| 130 | 7x |
setwd(old) |
| 131 | 7x |
fs::dir_delete(root) |
| 132 |
}, |
|
| 133 | 7x |
add = TRUE |
| 134 |
) |
|
| 135 | 7x |
setwd(root) |
| 136 | 7x |
status <- utils::zip(file, c("script.R", "plot.png"), flags = "-q")
|
| 137 | 7x |
if (!identical(status, 0L) || !fs::file_exists(file)) {
|
| 138 | ! |
cli::cli_abort("Could not create ZIP download")
|
| 139 |
} |
|
| 140 | 7x |
invisible(file) |
| 141 |
} |
| 1 |
.execution_request_fingerprint <- function(request, script, analysis_data) {
|
|
| 2 | 10x |
canonical_hash(list( |
| 3 | 10x |
request = unclass(request), |
| 4 | 10x |
script_hash = .execution_hash_text(script), |
| 5 | 10x |
snapshot_hash = canonical_hash(analysis_data) |
| 6 |
)) |
|
| 7 |
} |
|
| 8 | ||
| 9 |
.execution_attempt_id <- function(idempotency_key) {
|
|
| 10 | 10x |
paste0("attempt-", substr(canonical_hash(idempotency_key), 1L, 24L))
|
| 11 |
} |
|
| 12 | ||
| 13 |
.execution_id <- function(prefix, attempt_id, suffix) {
|
|
| 14 | 18x |
paste( |
| 15 | 18x |
prefix, |
| 16 | 18x |
substr(canonical_hash(list(attempt_id, suffix)), 1L, 24L), |
| 17 | 18x |
sep = "-" |
| 18 |
) |
|
| 19 |
} |
|
| 20 | ||
| 21 |
new_execution_service <- function( |
|
| 22 |
repository, |
|
| 23 |
runner = new_callr_execution_runner(), |
|
| 24 |
verification_service = new_verification_service(), |
|
| 25 |
artifact_store = NULL |
|
| 26 |
) {
|
|
| 27 | 11x |
.validate_evidence_repository( |
| 28 | 11x |
repository, |
| 29 | 11x |
c( |
| 30 | 11x |
"record_attempt", "list_attempts", "complete_attempt", |
| 31 | 11x |
"record_evidence", "accept_artifact_bundle", "mark_verified", |
| 32 | 11x |
"get_revision" |
| 33 |
) |
|
| 34 |
) |
|
| 35 | 11x |
if (!is.function(runner$run)) {
|
| 36 | ! |
cli::cli_abort("Execution runner must provide {.fn run}")
|
| 37 |
} |
|
| 38 | 11x |
if (!is.function(verification_service$verify)) {
|
| 39 | ! |
cli::cli_abort("Verification service must provide {.fn verify}")
|
| 40 |
} |
|
| 41 | ||
| 42 | 11x |
start <- function( |
| 43 | 11x |
revision_id, |
| 44 | 11x |
request, |
| 45 | 11x |
script, |
| 46 | 11x |
analysis_data, |
| 47 | 11x |
idempotency_key |
| 48 |
) {
|
|
| 49 | 11x |
revision <- repository$get_revision(revision_id) |
| 50 | 11x |
if (!identical(revision$status, "Draft")) {
|
| 51 | 1x |
cli::cli_abort("Only Draft revisions can be submitted for verification")
|
| 52 |
} |
|
| 53 | 10x |
attempts <- repository$list_attempts(revision_id) |
| 54 | 10x |
active <- attempts[is.na(attempts$outcome), , drop = FALSE] |
| 55 | 10x |
attempt_id <- .execution_attempt_id(idempotency_key) |
| 56 | 10x |
fingerprint <- .execution_request_fingerprint( |
| 57 | 10x |
request, |
| 58 | 10x |
script, |
| 59 | 10x |
analysis_data |
| 60 |
) |
|
| 61 | 10x |
if (nrow(active) && !attempt_id %in% active$attempt_id) {
|
| 62 | ! |
cli::cli_abort("A revision can have only one active execution attempt")
|
| 63 |
} |
|
| 64 | 10x |
repository$record_attempt(attempt_id, revision_id, fingerprint) |
| 65 | 10x |
invisible(list( |
| 66 | 10x |
revision_id = revision_id, |
| 67 | 10x |
revision_version = as.integer(revision$version), |
| 68 | 10x |
attempt_id = attempt_id, |
| 69 | 10x |
idempotency_key = idempotency_key, |
| 70 | 10x |
request = request, |
| 71 | 10x |
script = script, |
| 72 | 10x |
analysis_data = analysis_data, |
| 73 | 10x |
work_dir = fs::file_temp(pattern = paste0(attempt_id, "-")) |
| 74 |
)) |
|
| 75 |
} |
|
| 76 | ||
| 77 | 11x |
complete <- function(attempt, runner_result) {
|
| 78 | 10x |
verification <- verification_service$verify( |
| 79 | 10x |
request = attempt$request, |
| 80 | 10x |
script = attempt$script, |
| 81 | 10x |
analysis_data = attempt$analysis_data, |
| 82 | 10x |
runner_result = runner_result |
| 83 |
) |
|
| 84 | 10x |
evidence_id <- .execution_id("evidence", attempt$attempt_id, "verification")
|
| 85 | 10x |
repository$record_evidence( |
| 86 | 10x |
evidence_id = evidence_id, |
| 87 | 10x |
revision_id = attempt$revision_id, |
| 88 | 10x |
evidence_type = "execution_verification", |
| 89 | 10x |
outcome = verification$status, |
| 90 | 10x |
idempotency_key = paste0(attempt$idempotency_key, ":evidence"), |
| 91 | 10x |
details = list( |
| 92 | 10x |
attempt_id = attempt$attempt_id, |
| 93 | 10x |
verification = unclass(verification), |
| 94 | 10x |
execution_result = unclass(runner_result$result %||% list()), |
| 95 | 10x |
environment = runner_result$environment %||% list( |
| 96 | 10x |
fingerprint = runner_result$result$environment_fingerprint %||% |
| 97 | 10x |
NA_character_ |
| 98 |
), |
|
| 99 | 10x |
diagnostics = runner_result$diagnostics |
| 100 |
) |
|
| 101 |
) |
|
| 102 | 10x |
if (!identical(verification$status, "passed")) {
|
| 103 | 2x |
repository$complete_attempt( |
| 104 | 2x |
attempt$attempt_id, |
| 105 | 2x |
"failed", |
| 106 | 2x |
paste0(attempt$idempotency_key, ":terminal") |
| 107 |
) |
|
| 108 | 2x |
return(invisible(list( |
| 109 | 2x |
attempt_id = attempt$attempt_id, |
| 110 | 2x |
status = "failed", |
| 111 | 2x |
verification = verification, |
| 112 | 2x |
runner_result = runner_result |
| 113 |
))) |
|
| 114 |
} |
|
| 115 | 8x |
if (!is.null(artifact_store)) {
|
| 116 | 7x |
artifact_store$put(.execution_text_bytes(attempt$script)) |
| 117 | 7x |
artifact_store$put(readBin( |
| 118 | 7x |
runner_result$image_path, |
| 119 | 7x |
what = "raw", |
| 120 | 7x |
n = fs::file_size(runner_result$image_path) |
| 121 |
)) |
|
| 122 |
} |
|
| 123 | 8x |
repository$accept_artifact_bundle( |
| 124 | 8x |
bundle_id = .execution_id("bundle", attempt$attempt_id, "accepted"),
|
| 125 | 8x |
revision_id = attempt$revision_id, |
| 126 | 8x |
code_hash = attempt$request$script_hash, |
| 127 | 8x |
image_hash = runner_result$result$image_hash, |
| 128 | 8x |
analytical_hash = runner_result$result$analytical_hash, |
| 129 | 8x |
idempotency_key = paste0(attempt$idempotency_key, ":bundle") |
| 130 |
) |
|
| 131 | 8x |
repository$complete_attempt( |
| 132 | 8x |
attempt$attempt_id, |
| 133 | 8x |
"succeeded", |
| 134 | 8x |
paste0(attempt$idempotency_key, ":terminal") |
| 135 |
) |
|
| 136 | 8x |
repository$mark_verified( |
| 137 | 8x |
attempt$revision_id, |
| 138 | 8x |
attempt$revision_version, |
| 139 | 8x |
paste0(attempt$idempotency_key, ":verified") |
| 140 |
) |
|
| 141 | 8x |
invisible(list( |
| 142 | 8x |
attempt_id = attempt$attempt_id, |
| 143 | 8x |
status = "verified", |
| 144 | 8x |
verification = verification, |
| 145 | 8x |
result = runner_result$result, |
| 146 | 8x |
runner_result = runner_result |
| 147 |
)) |
|
| 148 |
} |
|
| 149 | ||
| 150 | 11x |
submit <- function( |
| 151 | 11x |
revision_id, |
| 152 | 11x |
request, |
| 153 | 11x |
script, |
| 154 | 11x |
analysis_data, |
| 155 | 11x |
idempotency_key, |
| 156 | 11x |
data_classification = "synthetic" |
| 157 |
) {
|
|
| 158 | 11x |
attempt <- start( |
| 159 | 11x |
revision_id, |
| 160 | 11x |
request, |
| 161 | 11x |
script, |
| 162 | 11x |
analysis_data, |
| 163 | 11x |
idempotency_key |
| 164 |
) |
|
| 165 | 10x |
runner_result <- tryCatch( |
| 166 | 10x |
runner$run( |
| 167 | 10x |
request = request, |
| 168 | 10x |
script = script, |
| 169 | 10x |
analysis_data = analysis_data, |
| 170 | 10x |
data_classification = data_classification, |
| 171 | 10x |
work_dir = attempt$work_dir |
| 172 |
), |
|
| 173 | 10x |
error = \(error) {
|
| 174 | 1x |
structure( |
| 175 | 1x |
list( |
| 176 | 1x |
status = "failed", |
| 177 | 1x |
manifest_version = "execution-runner-result-v1", |
| 178 | 1x |
diagnostics = list(stdout = "", stderr = conditionMessage(error)) |
| 179 |
), |
|
| 180 | 1x |
class = "execution_runner_result" |
| 181 |
) |
|
| 182 |
} |
|
| 183 |
) |
|
| 184 | 10x |
complete(attempt, runner_result) |
| 185 |
} |
|
| 186 | ||
| 187 | 11x |
structure( |
| 188 | 11x |
list(submit = submit, start = start, complete = complete), |
| 189 | 11x |
class = "execution_service" |
| 190 |
) |
|
| 191 |
} |
| 1 |
mod_specification_ui <- function(id) {
|
|
| 2 | 6x |
ns <- shiny::NS(id) |
| 3 | 6x |
bslib::card( |
| 4 | 6x |
class = "assurance-panel", |
| 5 | 6x |
bslib::card_header("Confirm plot choices"),
|
| 6 | 6x |
bslib::card_body( |
| 7 | 6x |
tags$details( |
| 8 | 6x |
class = "f001-panel", |
| 9 | 6x |
open = NA, |
| 10 | 6x |
tags$summary("Plot type"),
|
| 11 | 6x |
tags$div( |
| 12 | 6x |
class = "f001-panel-body", |
| 13 | 6x |
tags$p("Longitudinal numeric BDS boxplot"),
|
| 14 | 6x |
tags$p( |
| 15 | 6x |
class = "text-body-secondary", |
| 16 | 6x |
"This is the plot pattern supported by the current POC." |
| 17 |
) |
|
| 18 |
) |
|
| 19 |
), |
|
| 20 | 6x |
tags$details( |
| 21 | 6x |
class = "f001-panel", |
| 22 | 6x |
open = NA, |
| 23 | 6x |
tags$summary("Variables and settings"),
|
| 24 | 6x |
tags$div( |
| 25 | 6x |
class = "f001-panel-body", |
| 26 | 6x |
shiny::uiOutput(ns("summary")),
|
| 27 | 6x |
shiny::tableOutput(ns("fields")),
|
| 28 | 6x |
shiny::uiOutput(ns("controls")),
|
| 29 | 6x |
shiny::uiOutput(ns("message"))
|
| 30 |
) |
|
| 31 |
) |
|
| 32 |
) |
|
| 33 |
) |
|
| 34 |
} |
|
| 35 | ||
| 36 |
mod_specification_server <- function( |
|
| 37 |
id, |
|
| 38 |
current_revision, |
|
| 39 |
execute_revision = NULL, |
|
| 40 |
create_correction = NULL |
|
| 41 |
) {
|
|
| 42 | 16x |
shiny::moduleServer(id, function(input, output, session) {
|
| 43 | 16x |
message <- shiny::reactiveVal(NULL) |
| 44 | ||
| 45 | 16x |
output$summary <- shiny::renderUI({
|
| 46 | 20x |
state <- current_revision() |
| 47 | 20x |
if (is.null(state$spec)) {
|
| 48 | 3x |
return(div( |
| 49 | 3x |
class = "text-body-secondary", "No confirmed specification yet." |
| 50 |
)) |
|
| 51 |
} |
|
| 52 | 17x |
if (!is.null(state$pending)) {
|
| 53 | 10x |
return(div( |
| 54 | 10x |
class = "assurance-summary", |
| 55 | 10x |
tags$strong("Specification pending confirmation")
|
| 56 |
)) |
|
| 57 |
} |
|
| 58 | 7x |
div( |
| 59 | 7x |
class = "assurance-summary", |
| 60 | 7x |
tags$strong("Specification hash"),
|
| 61 | 7x |
tags$code(state$spec$hash) |
| 62 |
) |
|
| 63 |
}) |
|
| 64 | ||
| 65 | 16x |
output$fields <- shiny::renderTable( |
| 66 |
{
|
|
| 67 | 20x |
state <- current_revision() |
| 68 | 20x |
if (is.null(state$spec)) {
|
| 69 | 3x |
return(data.frame()) |
| 70 |
} |
|
| 71 | 17x |
fields <- state$spec$fields |
| 72 | 17x |
data.frame( |
| 73 | 17x |
field = names(fields), |
| 74 | 17x |
value = vapply(fields, .display_value, character(1)), |
| 75 | 17x |
provenance = vapply(names(fields), function(name) {
|
| 76 | 136x |
state$spec$provenance[[name]] %||% "" |
| 77 | 17x |
}, character(1)), |
| 78 | 17x |
stringsAsFactors = FALSE |
| 79 |
) |
|
| 80 |
}, |
|
| 81 | 16x |
striped = TRUE, |
| 82 | 16x |
bordered = FALSE, |
| 83 | 16x |
spacing = "s" |
| 84 |
) |
|
| 85 | ||
| 86 | 16x |
output$controls <- shiny::renderUI({
|
| 87 | 26x |
state <- current_revision() |
| 88 | 26x |
read_only <- is.null(state$pending) && !.can_create_correction(state) |
| 89 | 26x |
choices <- .specification_choices(state) |
| 90 | 22x |
fields <- state$spec$fields |
| 91 | 22x |
treatment_variable <- input$treatment_variable %||% |
| 92 | 22x |
fields$treatment_variable %||% choices$treatment_variables[[1]] |
| 93 | 22x |
paramcd <- input$paramcd %||% fields$paramcd %||% choices$parameters[[1]] |
| 94 | 22x |
units <- choices$units[[paramcd]] %||% character() |
| 95 | 22x |
current_unit <- input$unit %||% fields$unit |
| 96 | 22x |
selected_unit <- if ( |
| 97 | 22x |
length(current_unit) == 1L && current_unit %in% units |
| 98 |
) {
|
|
| 99 | 21x |
current_unit |
| 100 | 22x |
} else if (length(units) == 1L) {
|
| 101 | ! |
units[[1]] |
| 102 |
} else {
|
|
| 103 | 1x |
character() |
| 104 |
} |
|
| 105 | 22x |
treatment_levels <- choices$treatment_levels[[treatment_variable]] |
| 106 | 22x |
current_treatment_levels <- input$treatment_levels %||% |
| 107 | 22x |
fields$treatment_levels |
| 108 | 22x |
selected_treatment_levels <- if ( |
| 109 | 22x |
length(current_treatment_levels) && |
| 110 | 22x |
all(current_treatment_levels %in% treatment_levels) |
| 111 |
) {
|
|
| 112 | 22x |
current_treatment_levels |
| 113 |
} else {
|
|
| 114 | ! |
treatment_levels |
| 115 |
} |
|
| 116 | 22x |
current_visits <- input$visits %||% fields$visits |
| 117 | 22x |
selected_visits <- if ( |
| 118 | 22x |
length(current_visits) && all(current_visits %in% choices$visits) |
| 119 |
) {
|
|
| 120 | 22x |
current_visits |
| 121 |
} else {
|
|
| 122 | ! |
choices$visits |
| 123 |
} |
|
| 124 | ||
| 125 | 22x |
settings <- tagList( |
| 126 | 22x |
shiny::selectInput( |
| 127 | 22x |
session$ns("paramcd"), "Parameter", choices$parameters,
|
| 128 | 22x |
selected = paramcd |
| 129 |
), |
|
| 130 | 22x |
shiny::selectInput( |
| 131 | 22x |
session$ns("y_variable"),
|
| 132 | 22x |
"Y variable", |
| 133 | 22x |
choices$y_variables, |
| 134 | 22x |
selected = fields$y_variable %||% choices$y_variables[[1]] |
| 135 |
), |
|
| 136 | 22x |
shiny::selectInput( |
| 137 | 22x |
session$ns("unit"), "Unit", units,
|
| 138 | 22x |
selected = selected_unit |
| 139 |
), |
|
| 140 | 22x |
shiny::selectInput( |
| 141 | 22x |
session$ns("treatment_variable"),
|
| 142 | 22x |
"Treatment facet", |
| 143 | 22x |
choices$treatment_variables, |
| 144 | 22x |
selected = treatment_variable |
| 145 |
), |
|
| 146 | 22x |
shiny::checkboxGroupInput( |
| 147 | 22x |
session$ns("treatment_levels"),
|
| 148 | 22x |
"Included treatment levels", |
| 149 | 22x |
treatment_levels, |
| 150 | 22x |
selected = selected_treatment_levels |
| 151 |
), |
|
| 152 | 22x |
shiny::checkboxGroupInput( |
| 153 | 22x |
session$ns("visits"),
|
| 154 | 22x |
"Included visits", |
| 155 | 22x |
choices$visits, |
| 156 | 22x |
selected = selected_visits |
| 157 |
), |
|
| 158 | 22x |
shiny::radioButtons( |
| 159 | 22x |
session$ns("scale_mode"),
|
| 160 | 22x |
"Y scales", |
| 161 | 22x |
choices = c("Fixed" = "fixed", "Free" = "free"),
|
| 162 | 22x |
selected = fields$scale_mode %||% "fixed", |
| 163 | 22x |
inline = TRUE |
| 164 |
), |
|
| 165 | 22x |
shiny::checkboxInput( |
| 166 | 22x |
session$ns("confirm_free_scale"),
|
| 167 | 22x |
"I understand free Y scales create an Experimental/Draft revision", |
| 168 | 22x |
value = FALSE |
| 169 |
) |
|
| 170 |
) |
|
| 171 | ||
| 172 | 22x |
if (read_only) {
|
| 173 | 5x |
return(tags$fieldset( |
| 174 | 5x |
disabled = TRUE, |
| 175 | 5x |
class = "specification-controls", |
| 176 | 5x |
tags$legend(class = "visually-hidden", "Confirmed plot settings"), |
| 177 | 5x |
settings |
| 178 |
)) |
|
| 179 |
} |
|
| 180 | ||
| 181 | 17x |
tags$div( |
| 182 | 17x |
class = "specification-controls", |
| 183 | 17x |
settings, |
| 184 | 17x |
if (.can_create_correction(state)) {
|
| 185 | 2x |
tagList( |
| 186 | 2x |
shiny::textAreaInput( |
| 187 | 2x |
session$ns("correction_rationale"),
|
| 188 | 2x |
"Correction rationale", |
| 189 | 2x |
rows = 2 |
| 190 |
), |
|
| 191 | 2x |
shiny::textInput( |
| 192 | 2x |
session$ns("correction_provenance"),
|
| 193 | 2x |
"Correction provenance" |
| 194 |
), |
|
| 195 | 2x |
.assurance_task_button( |
| 196 | 2x |
session$ns("correct"),
|
| 197 | 2x |
"Create correction revision", |
| 198 | 2x |
class = "btn-primary" |
| 199 |
) |
|
| 200 |
) |
|
| 201 |
} else {
|
|
| 202 | 15x |
.assurance_task_button( |
| 203 | 15x |
session$ns("execute"),
|
| 204 | 15x |
"Confirm and generate", |
| 205 | 15x |
class = "btn-primary" |
| 206 |
) |
|
| 207 |
} |
|
| 208 |
) |
|
| 209 |
}) |
|
| 210 | ||
| 211 | 16x |
output$message <- shiny::renderUI({
|
| 212 | 14x |
text <- message() |
| 213 | 14x |
if (is.null(text)) {
|
| 214 | 13x |
return(NULL) |
| 215 |
} |
|
| 216 | 1x |
div(class = "assurance-message", role = "status", text) |
| 217 |
}) |
|
| 218 | ||
| 219 | 16x |
shiny::observeEvent(input$execute, |
| 220 |
{
|
|
| 221 | 7x |
message(NULL) |
| 222 | 7x |
if (!is.function(execute_revision)) {
|
| 223 | ! |
message("Execution is unavailable in this context.")
|
| 224 | ! |
return() |
| 225 |
} |
|
| 226 | 16x |
if ( |
| 227 | 7x |
identical(input$scale_mode, "free") && |
| 228 | 7x |
!isTRUE(input$confirm_free_scale) |
| 229 |
) {
|
|
| 230 | 1x |
message("Free Y scales require confirmation before execution.")
|
| 231 | 1x |
return() |
| 232 |
} |
|
| 233 | 6x |
tryCatch( |
| 234 | 6x |
execute_revision( |
| 235 | 6x |
.current_spec_input_fields(input, current_revision()) |
| 236 |
), |
|
| 237 | 6x |
error = function(error) {
|
| 238 | ! |
message(conditionMessage(error)) |
| 239 |
} |
|
| 240 |
) |
|
| 241 |
}, |
|
| 242 | 16x |
ignoreInit = TRUE |
| 243 |
) |
|
| 244 | ||
| 245 | 16x |
shiny::observeEvent(input$correct, |
| 246 |
{
|
|
| 247 | 1x |
message(NULL) |
| 248 | 1x |
if (!is.function(create_correction)) {
|
| 249 | ! |
message("Correction is unavailable in this context.")
|
| 250 | ! |
return() |
| 251 |
} |
|
| 252 | 16x |
if ( |
| 253 | 1x |
!checkmate::test_string(input$correction_rationale, min.chars = 1L) || |
| 254 | 1x |
!checkmate::test_string( |
| 255 | 1x |
input$correction_provenance, |
| 256 | 1x |
min.chars = 1L |
| 257 |
) |
|
| 258 |
) {
|
|
| 259 | ! |
message( |
| 260 | ! |
"Enter the correction rationale and provenance before continuing." |
| 261 |
) |
|
| 262 | ! |
return() |
| 263 |
} |
|
| 264 | 16x |
if ( |
| 265 | 1x |
identical(input$scale_mode, "free") && |
| 266 | 1x |
!isTRUE(input$confirm_free_scale) |
| 267 |
) {
|
|
| 268 | ! |
message("Free Y scales require confirmation before correction.")
|
| 269 | ! |
return() |
| 270 |
} |
|
| 271 | 1x |
tryCatch( |
| 272 | 1x |
create_correction( |
| 273 | 1x |
.current_spec_input_fields(input, current_revision()), |
| 274 | 1x |
input$correction_rationale, |
| 275 | 1x |
input$correction_provenance |
| 276 |
), |
|
| 277 | 1x |
error = function(error) {
|
| 278 | ! |
message(conditionMessage(error)) |
| 279 |
} |
|
| 280 |
) |
|
| 281 |
}, |
|
| 282 | 16x |
ignoreInit = TRUE |
| 283 |
) |
|
| 284 |
}) |
|
| 285 |
} |
|
| 286 | ||
| 287 |
.current_spec_input_fields <- function(input, state) {
|
|
| 288 | 7x |
choices <- .specification_choices(state) |
| 289 | 7x |
fields <- state$spec$fields |
| 290 | 7x |
treatment_variable <- input$treatment_variable %||% |
| 291 | 7x |
fields$treatment_variable %||% choices$treatment_variables[[1]] |
| 292 | 7x |
paramcd <- input$paramcd %||% fields$paramcd %||% choices$parameters[[1]] |
| 293 | 7x |
units <- choices$units[[paramcd]] %||% character() |
| 294 | 7x |
unit <- input$unit %||% fields$unit %||% if (length(units) == 1L) {
|
| 295 | ! |
units[[1]] |
| 296 |
} else {
|
|
| 297 | ! |
NULL |
| 298 |
} |
|
| 299 | 7x |
treatment_levels <- input$treatment_levels %||% |
| 300 | 7x |
fields$treatment_levels %||% choices$treatment_levels[[treatment_variable]] |
| 301 | 7x |
visits <- input$visits %||% fields$visits %||% choices$visits |
| 302 | ||
| 303 | 7x |
list( |
| 304 | 7x |
paramcd = paramcd, |
| 305 | 7x |
y_variable = input$y_variable %||% fields$y_variable %||% |
| 306 | 7x |
choices$y_variables[[1]], |
| 307 | 7x |
unit = unit, |
| 308 | 7x |
treatment_variable = treatment_variable, |
| 309 | 7x |
treatment_levels = treatment_levels, |
| 310 | 7x |
visits = visits, |
| 311 | 7x |
scale_mode = input$scale_mode %||% fields$scale_mode %||% "fixed" |
| 312 |
) |
|
| 313 |
} |
|
| 314 | ||
| 315 |
.specification_choices <- function(state) {
|
|
| 316 | 33x |
choices <- state$pending$choices %||% state$choices |
| 317 | 33x |
if (is.null(choices)) {
|
| 318 | 4x |
cli::cli_abort("Specification choices are unavailable")
|
| 319 |
} |
|
| 320 | 29x |
choices |
| 321 |
} |
|
| 322 | ||
| 323 |
.can_create_correction <- function(state) {
|
|
| 324 | 28x |
!is.null(state$revision) && |
| 325 | 28x |
state$revision$status %in% c("Rejected", "Reviewed") &&
|
| 326 | 28x |
!is.null(state$choices) |
| 327 |
} |
|
| 328 | ||
| 329 |
.display_value <- function(value) {
|
|
| 330 | 136x |
if (is.null(value)) {
|
| 331 | 1x |
return("")
|
| 332 |
} |
|
| 333 | 135x |
paste(as.character(value), collapse = ", ") |
| 334 |
} |
| 1 |
#' Calculate a stable content hash for study data |
|
| 2 |
#' |
|
| 3 |
#' The hash is independent of row order while preserving column names, types, |
|
| 4 |
#' missing values, and stored values. |
|
| 5 |
#' |
|
| 6 |
#' @param data A data frame containing a study-data snapshot. |
|
| 7 |
#' |
|
| 8 |
#' @return A SHA-256 content hash. |
|
| 9 |
#' @export |
|
| 10 |
study_data_content_hash <- function(data) {
|
|
| 11 | 54x |
if (!checkmate::test_data_frame(data, min.rows = 1L, min.cols = 1L)) {
|
| 12 | ! |
cli::cli_abort("{.arg data} must be a non-empty data frame")
|
| 13 |
} |
|
| 14 | ||
| 15 | 54x |
normalized <- .normalize_study_data(data) |
| 16 | 54x |
payload <- lapply(normalized, function(column) {
|
| 17 | 651x |
list(type = typeof(column), class = class(column), values = unname(column)) |
| 18 |
}) |
|
| 19 | 54x |
canonical_hash(list(columns = names(normalized), data = payload)) |
| 20 |
} |
|
| 21 | ||
| 22 |
.normalize_study_data <- function(data) {
|
|
| 23 | 139x |
data <- data[, sort(names(data), method = "radix"), drop = FALSE] |
| 24 | 139x |
row_keys <- do.call( |
| 25 | 139x |
paste, |
| 26 | 139x |
c(lapply(data, .stable_column_text), sep = "\u001f") |
| 27 |
) |
|
| 28 | 139x |
result <- data[ |
| 29 | 139x |
order(row_keys, method = "radix", na.last = TRUE), , |
| 30 | 139x |
drop = FALSE |
| 31 |
] |
|
| 32 | 139x |
row.names(result) <- NULL |
| 33 | 139x |
result |
| 34 |
} |
|
| 35 | ||
| 36 |
.stable_column_text <- function(x) {
|
|
| 37 | 1582x |
if (inherits(x, "POSIXt")) {
|
| 38 | ! |
value <- format(x, "%Y-%m-%dT%H:%M:%OS6Z", tz = "UTC") |
| 39 | 1582x |
} else if (inherits(x, "Date")) {
|
| 40 | ! |
value <- format(x, "%Y-%m-%d") |
| 41 | 1582x |
} else if (is.numeric(x)) {
|
| 42 | 525x |
value <- ifelse(is.na(x), NA_character_, sprintf("%.17g", x))
|
| 43 |
} else {
|
|
| 44 | 1057x |
value <- enc2utf8(as.character(x)) |
| 45 |
} |
|
| 46 | 1582x |
paste0(typeof(x), ":", ifelse(is.na(value), "<NA>", value)) |
| 47 |
} |
|
| 48 | ||
| 49 |
.validate_study_data_provider <- function(provider) {
|
|
| 50 | 31x |
if (!inherits(provider, "study_data_provider")) {
|
| 51 | ! |
cli::cli_abort("{.arg provider} must be a {.cls study_data_provider}")
|
| 52 |
} |
|
| 53 | 31x |
invisible(provider) |
| 54 |
} |
|
| 55 | ||
| 56 |
#' List datasets authorized by a study-data provider |
|
| 57 |
#' |
|
| 58 |
#' @param provider A study-data provider. |
|
| 59 |
#' |
|
| 60 |
#' @return A deterministic data frame of authorized, non-patient metadata. |
|
| 61 |
#' @export |
|
| 62 |
list_study_catalog <- function(provider) {
|
|
| 63 | 4x |
.validate_study_data_provider(provider) |
| 64 | 4x |
scenarios <- provider$manifest$scenarios |
| 65 | 4x |
catalog <- data.frame( |
| 66 | 4x |
dataset_id = vapply(scenarios, `[[`, "", "dataset_id"), |
| 67 | 4x |
classification = vapply( |
| 68 | 4x |
scenarios, |
| 69 | 4x |
function(scenario) scenario$classification %||% NA_character_, |
| 70 |
"" |
|
| 71 |
), |
|
| 72 | 4x |
adam_designation = vapply(scenarios, `[[`, "", "adam_designation"), |
| 73 | 4x |
metadata_version = vapply(scenarios, `[[`, "", "metadata_version"), |
| 74 | 4x |
content_hash = vapply(scenarios, `[[`, "", "content_hash"), |
| 75 | 4x |
snapshot_id = vapply(scenarios, `[[`, "", "snapshot_id"), |
| 76 | 4x |
scenario_id = vapply(scenarios, `[[`, "", "scenario_id"), |
| 77 | 4x |
purpose = vapply(scenarios, `[[`, "", "purpose"), |
| 78 | 4x |
expected_behavior = vapply(scenarios, `[[`, "", "expected_behavior"), |
| 79 | 4x |
stringsAsFactors = FALSE |
| 80 |
) |
|
| 81 | 4x |
catalog$declared_keys <- I(lapply(scenarios, `[[`, "declared_keys")) |
| 82 | 4x |
catalog$permitted_treatment_variables <- I(lapply( |
| 83 | 4x |
scenarios, |
| 84 |
`[[`, |
|
| 85 | 4x |
"permitted_treatment_variables" |
| 86 |
)) |
|
| 87 | 4x |
catalog <- catalog[, c( |
| 88 | 4x |
"dataset_id", "classification", "adam_designation", "declared_keys", |
| 89 | 4x |
"metadata_version", "content_hash", "permitted_treatment_variables", |
| 90 | 4x |
"snapshot_id", "scenario_id", "purpose", "expected_behavior" |
| 91 |
)] |
|
| 92 | 4x |
catalog <- catalog[ |
| 93 | 4x |
order(catalog$dataset_id, method = "radix"), , |
| 94 | 4x |
drop = FALSE |
| 95 |
] |
|
| 96 | 4x |
row.names(catalog) <- NULL |
| 97 | 4x |
catalog |
| 98 |
} |
|
| 99 | ||
| 100 |
#' Pin an authorized immutable study-data snapshot |
|
| 101 |
#' |
|
| 102 |
#' @param provider A study-data provider. |
|
| 103 |
#' @param dataset_id One opaque dataset identifier from the authorized catalog. |
|
| 104 |
#' |
|
| 105 |
#' @return A hash-verified `study_data_snapshot`. |
|
| 106 |
#' @export |
|
| 107 |
pin_study_snapshot <- function(provider, dataset_id) {
|
|
| 108 | 27x |
.validate_study_data_provider(provider) |
| 109 | 27x |
if (!checkmate::test_string(dataset_id, min.chars = 1L)) {
|
| 110 | ! |
cli::cli_abort("{.arg dataset_id} must be one non-empty identifier")
|
| 111 |
} |
|
| 112 | ||
| 113 | 27x |
matches <- which(vapply( |
| 114 | 27x |
provider$manifest$scenarios, |
| 115 | 27x |
function(x) identical(x$dataset_id, dataset_id), |
| 116 | 27x |
logical(1) |
| 117 |
)) |
|
| 118 | 27x |
if (length(matches) != 1L) {
|
| 119 | 1x |
cli::cli_abort( |
| 120 | 1x |
"{.val {dataset_id}} is not present in the authorized catalog"
|
| 121 |
) |
|
| 122 |
} |
|
| 123 | 26x |
metadata <- provider$manifest$scenarios[[matches]] |
| 124 | 26x |
data <- provider$read_data(metadata) |
| 125 | 26x |
observed_hash <- study_data_content_hash(data) |
| 126 | 26x |
if (!identical(observed_hash, metadata$content_hash)) {
|
| 127 | 1x |
cli::cli_abort( |
| 128 | 1x |
"The authorized snapshot content hash does not match its catalog entry" |
| 129 |
) |
|
| 130 |
} |
|
| 131 | ||
| 132 | 25x |
snapshot <- list( |
| 133 | 25x |
snapshot_contract_version = "study-snapshot-v1", |
| 134 | 25x |
snapshot_id = metadata$snapshot_id, |
| 135 | 25x |
dataset_id = metadata$dataset_id, |
| 136 | 25x |
adam_designation = metadata$adam_designation, |
| 137 | 25x |
declared_keys = unlist(metadata$declared_keys, use.names = FALSE), |
| 138 | 25x |
metadata_version = metadata$metadata_version, |
| 139 | 25x |
content_hash = metadata$content_hash, |
| 140 | 25x |
permitted_treatment_variables = unlist( |
| 141 | 25x |
metadata$permitted_treatment_variables, |
| 142 | 25x |
use.names = FALSE |
| 143 |
), |
|
| 144 | 25x |
data = .normalize_study_data(data) |
| 145 |
) |
|
| 146 | 25x |
if (!is.null(metadata$classification)) {
|
| 147 | 12x |
snapshot$classification <- metadata$classification |
| 148 |
} |
|
| 149 | 25x |
structure(snapshot, class = "study_data_snapshot") |
| 150 |
} |
| 1 |
.base_synthetic_bds <- function() {
|
|
| 2 | ! |
data.frame( |
| 3 | ! |
STUDYID = "SYNTH001", |
| 4 | ! |
USUBJID = rep(sprintf("SYNTH001-%03d", 1:6), each = 3L),
|
| 5 | ! |
PARAMCD = "ALT", |
| 6 | ! |
PARAM = "Alanine aminotransferase", |
| 7 | ! |
AVISIT = rep(c("Baseline", "Week 4", "Week 8"), times = 6L),
|
| 8 | ! |
AVISITN = rep(c(0, 4, 8), times = 6L), |
| 9 | ! |
AVAL = c( |
| 10 | ! |
20, 24, 26, 22, 25, 28, 24, 27, 30, |
| 11 | ! |
26, 29, 32, 28, NA, 34, 30, NA, 36 |
| 12 |
), |
|
| 13 | ! |
CHG = c( |
| 14 | ! |
0, 4, 6, 0, 3, 6, 0, 3, 6, |
| 15 | ! |
0, 3, 6, 0, NA, 6, 0, NA, 6 |
| 16 |
), |
|
| 17 | ! |
PCHG = c( |
| 18 | ! |
0, 20, 30, 0, 13.6363636364, 27.2727272727, |
| 19 | ! |
0, 12.5, 25, 0, 11.5384615385, 23.0769230769, |
| 20 | ! |
0, NA, 21.4285714286, 0, NA, 20 |
| 21 |
), |
|
| 22 | ! |
AVALU = "U/L", |
| 23 | ! |
TRT01A = rep(c("Placebo", "Active"), each = 9L),
|
| 24 | ! |
ANL01FL = rep(c("Y", "N"), 9L),
|
| 25 | ! |
stringsAsFactors = FALSE |
| 26 |
) |
|
| 27 |
} |
|
| 28 | ||
| 29 |
.synthetic_scenario_data <- function() {
|
|
| 30 | ! |
standard <- .base_synthetic_bds() |
| 31 | ||
| 32 | ! |
multiple_units <- standard |
| 33 | ! |
multiple_units$AVALU[multiple_units$USUBJID == "SYNTH001-006"] <- "ukat/L" |
| 34 | ||
| 35 | ! |
duplicate_keys <- standard |
| 36 | ! |
duplicate_keys <- rbind( |
| 37 | ! |
duplicate_keys, |
| 38 | ! |
transform( |
| 39 | ! |
duplicate_keys[ |
| 40 | ! |
duplicate_keys$USUBJID == "SYNTH001-001" & |
| 41 | ! |
duplicate_keys$AVISIT == "Week 4", |
| 42 |
], |
|
| 43 | ! |
AVAL = 999 |
| 44 |
) |
|
| 45 |
) |
|
| 46 | ||
| 47 | ! |
sparse_missing <- standard |
| 48 | ! |
sparse_missing$AVAL[ |
| 49 | ! |
sparse_missing$AVISIT == "Week 4" & |
| 50 | ! |
sparse_missing$USUBJID %in% c("SYNTH001-005", "SYNTH001-006")
|
| 51 | ! |
] <- NA_real_ |
| 52 | ||
| 53 | ! |
empty_combination <- standard |
| 54 | ! |
empty_combination$AVAL[ |
| 55 | ! |
empty_combination$TRT01A == "Active" & empty_combination$AVISIT == "Week 4" |
| 56 | ! |
] <- NA_real_ |
| 57 | ||
| 58 | ! |
bad_visit_mapping <- standard |
| 59 | ! |
bad_visit_mapping$AVISITN[ |
| 60 | ! |
bad_visit_mapping$USUBJID == "SYNTH001-001" & |
| 61 | ! |
bad_visit_mapping$AVISIT == "Week 4" |
| 62 | ! |
] <- 5 |
| 63 | ||
| 64 | ! |
empty_treatment <- standard |
| 65 | ! |
empty_treatment$AVAL[empty_treatment$TRT01A == "Placebo"] <- NA_real_ |
| 66 | ||
| 67 | ! |
extra_timepoint_keys <- standard |
| 68 | ! |
extra_timepoint_keys$ATPTN <- 1L |
| 69 | ! |
repeated <- extra_timepoint_keys[ |
| 70 | ! |
extra_timepoint_keys$USUBJID == "SYNTH001-001" & |
| 71 | ! |
extra_timepoint_keys$AVISIT == "Week 4", |
| 72 |
] |
|
| 73 | ! |
repeated$ATPTN <- 2L |
| 74 | ! |
extra_timepoint_keys <- rbind(extra_timepoint_keys, repeated) |
| 75 | ||
| 76 | ! |
identifier_canary <- standard |
| 77 | ! |
canary <- identifier_canary[ |
| 78 | ! |
identifier_canary$USUBJID == "SYNTH001-001" & |
| 79 | ! |
identifier_canary$AVISIT == "Week 4", |
| 80 |
] |
|
| 81 | ! |
canary$USUBJID <- "SYNTH-CANARY-DO-NOT-RETAIN" |
| 82 | ! |
identifier_canary <- rbind( |
| 83 | ! |
identifier_canary, canary, transform(canary, AVAL = 999) |
| 84 |
) |
|
| 85 | ||
| 86 | ! |
exclusions <- standard |
| 87 | ! |
exclusions <- rbind( |
| 88 | ! |
exclusions, |
| 89 | ! |
exclusions[ |
| 90 | ! |
exclusions$USUBJID == "SYNTH001-001" & exclusions$AVISIT == "Week 8", |
| 91 |
] |
|
| 92 |
) |
|
| 93 | ||
| 94 | ! |
list( |
| 95 | ! |
standard = standard, |
| 96 | ! |
`multiple-units` = multiple_units, |
| 97 | ! |
`duplicate-keys` = duplicate_keys, |
| 98 | ! |
`sparse-missing` = sparse_missing, |
| 99 | ! |
`empty-combination` = empty_combination, |
| 100 | ! |
`bad-visit-mapping` = bad_visit_mapping, |
| 101 | ! |
`empty-treatment` = empty_treatment, |
| 102 | ! |
`extra-timepoint-keys` = extra_timepoint_keys, |
| 103 | ! |
`identifier-canary` = identifier_canary, |
| 104 | ! |
exclusions = exclusions |
| 105 |
) |
|
| 106 |
} |
|
| 107 | ||
| 108 |
.synthetic_scenario_purpose <- c( |
|
| 109 |
standard = "Passing visit-based numeric BDS profile", |
|
| 110 |
`multiple-units` = "Unresolved multiple-unit selection", |
|
| 111 |
`duplicate-keys` = "Unsupported duplicate subject-parameter-visit key", |
|
| 112 |
`sparse-missing` = "Six source subjects and four non-missing selected values", |
|
| 113 |
`empty-combination` = "No display data for one treatment-visit combination", |
|
| 114 |
`bad-visit-mapping` = "Conflicting AVISIT to AVISITN mapping", |
|
| 115 |
`empty-treatment` = paste( |
|
| 116 |
"Included treatment level with all selected", "Y values missing" |
|
| 117 |
), |
|
| 118 |
`extra-timepoint-keys` = paste( |
|
| 119 |
"Legitimate additional timepoint key outside", "the cell profile" |
|
| 120 |
), |
|
| 121 |
`identifier-canary` = "Authorization-scoped duplicate identifier canary", |
|
| 122 |
exclusions = "Explicit selections remove an otherwise blocking duplicate" |
|
| 123 |
) |
|
| 124 | ||
| 125 |
.synthetic_expected_behavior <- c( |
|
| 126 |
standard = "passes", |
|
| 127 |
`multiple-units` = "blocks:unit_selection_required", |
|
| 128 |
`duplicate-keys` = "blocks:unsupported_duplicate_visit_key", |
|
| 129 |
`sparse-missing` = "warns:low_sample_size", |
|
| 130 |
`empty-combination` = "warns:empty_treatment_visit", |
|
| 131 |
`bad-visit-mapping` = "blocks:invalid_visit_mapping", |
|
| 132 |
`empty-treatment` = "warns:empty_treatment_level", |
|
| 133 |
`extra-timepoint-keys` = "blocks:unsupported_additional_timepoint_keys", |
|
| 134 |
`identifier-canary` = "blocks:unsupported_duplicate_visit_key", |
|
| 135 |
exclusions = "passes:explicit_exclusions_precede_key_check" |
|
| 136 |
) |
|
| 137 | ||
| 138 |
.synthetic_declared_keys <- function(scenario_id) {
|
|
| 139 | ! |
keys <- c("STUDYID", "USUBJID", "PARAMCD", "AVISITN")
|
| 140 | ! |
if (identical(scenario_id, "extra-timepoint-keys")) c(keys, "ATPTN") else keys |
| 141 |
} |
|
| 142 | ||
| 143 |
.build_synthetic_manifest <- function(scenarios) {
|
|
| 144 | ! |
entries <- lapply(names(scenarios), function(scenario_id) {
|
| 145 | ! |
list( |
| 146 | ! |
scenario_id = scenario_id, |
| 147 | ! |
dataset_id = paste0("adlb-", scenario_id),
|
| 148 | ! |
snapshot_id = paste0("synthetic-adlb-", scenario_id, "-v1"),
|
| 149 | ! |
file = paste0("adlb-", scenario_id, ".rds"),
|
| 150 | ! |
purpose = unname(.synthetic_scenario_purpose[[scenario_id]]), |
| 151 | ! |
expected_behavior = unname(.synthetic_expected_behavior[[scenario_id]]), |
| 152 | ! |
generator_version = "synthetic-bds-generator-v1", |
| 153 | ! |
seed = 20260906, |
| 154 | ! |
classification = "synthetic", |
| 155 | ! |
adam_designation = "BDS", |
| 156 | ! |
declared_keys = .synthetic_declared_keys(scenario_id), |
| 157 | ! |
metadata_version = "synthetic-adlb-metadata-v1", |
| 158 | ! |
permitted_treatment_variables = "TRT01A", |
| 159 | ! |
content_hash = study_data_content_hash(scenarios[[scenario_id]]) |
| 160 |
) |
|
| 161 |
}) |
|
| 162 | ! |
list( |
| 163 | ! |
manifest_version = "synthetic-bds-manifest-v1", |
| 164 | ! |
generator_version = "synthetic-bds-generator-v1", |
| 165 | ! |
classification = "synthetic", |
| 166 | ! |
clinical_evidence_use = FALSE, |
| 167 | ! |
scenarios = entries |
| 168 |
) |
|
| 169 |
} |
|
| 170 | ||
| 171 |
.validate_synthetic_manifest <- function(manifest) {
|
|
| 172 | 22x |
required_top <- c( |
| 173 | 22x |
"manifest_version", "generator_version", "classification", |
| 174 | 22x |
"clinical_evidence_use", "scenarios" |
| 175 |
) |
|
| 176 | 22x |
required_scenario <- c( |
| 177 | 22x |
"scenario_id", "dataset_id", "snapshot_id", "file", "purpose", |
| 178 | 22x |
"expected_behavior", "generator_version", "seed", "classification", |
| 179 | 22x |
"adam_designation", "declared_keys", "metadata_version", |
| 180 | 22x |
"permitted_treatment_variables", "content_hash" |
| 181 |
) |
|
| 182 | 22x |
valid <- is.list(manifest) && all(required_top %in% names(manifest)) && |
| 183 | 22x |
identical(manifest$manifest_version, "synthetic-bds-manifest-v1") && |
| 184 | 22x |
identical(manifest$classification, "synthetic") && |
| 185 | 22x |
identical(manifest$clinical_evidence_use, FALSE) && |
| 186 | 22x |
is.list(manifest$scenarios) && length(manifest$scenarios) > 0L && |
| 187 | 22x |
all(vapply(manifest$scenarios, function(x) {
|
| 188 | 220x |
all(required_scenario %in% names(x)) && |
| 189 | 220x |
identical(x$classification, "synthetic") && |
| 190 | 220x |
identical(x$adam_designation, "BDS") && |
| 191 | 220x |
identical(x$generator_version, manifest$generator_version) && |
| 192 | 220x |
all(vapply( |
| 193 | 220x |
x[c( |
| 194 | 220x |
"scenario_id", "dataset_id", "snapshot_id", "file", "purpose", |
| 195 | 220x |
"expected_behavior", "metadata_version", "content_hash" |
| 196 |
)], |
|
| 197 | 220x |
checkmate::test_string, |
| 198 | 220x |
logical(1), |
| 199 | 220x |
min.chars = 1L |
| 200 |
)) && |
|
| 201 | 220x |
checkmate::test_string(x$file, pattern = "^adlb-[a-z0-9-]+\\.rds$") && |
| 202 | 220x |
checkmate::test_string(x$content_hash, pattern = "^[a-f0-9]{64}$") &&
|
| 203 | 220x |
checkmate::test_number(x$seed, finite = TRUE) && |
| 204 | 220x |
checkmate::test_character( |
| 205 | 220x |
unlist(x$declared_keys, use.names = FALSE), |
| 206 | 220x |
min.len = 3L, |
| 207 | 220x |
any.missing = FALSE, |
| 208 | 220x |
unique = TRUE |
| 209 |
) && |
|
| 210 | 220x |
all(c("USUBJID", "PARAMCD", "AVISITN") %in%
|
| 211 | 220x |
unlist(x$declared_keys, use.names = FALSE)) && |
| 212 | 220x |
checkmate::test_character( |
| 213 | 220x |
unlist(x$permitted_treatment_variables, use.names = FALSE), |
| 214 | 220x |
min.len = 1L, |
| 215 | 220x |
any.missing = FALSE, |
| 216 | 220x |
unique = TRUE |
| 217 |
) |
|
| 218 | 22x |
}, logical(1))) |
| 219 | 22x |
if (valid) {
|
| 220 | 20x |
identifiers <- lapply( |
| 221 | 20x |
c("scenario_id", "dataset_id", "snapshot_id", "file"),
|
| 222 | 20x |
function(field) vapply(manifest$scenarios, `[[`, "", field) |
| 223 |
) |
|
| 224 | 20x |
valid <- all(vapply(identifiers, function(x) !anyDuplicated(x), logical(1))) |
| 225 |
} |
|
| 226 | 22x |
if (!valid) {
|
| 227 | 3x |
cli::cli_abort("The governed synthetic scenario manifest is invalid")
|
| 228 |
} |
|
| 229 | 19x |
invisible(manifest) |
| 230 |
} |
|
| 231 | ||
| 232 |
#' Read the governed synthetic scenario manifest |
|
| 233 |
#' |
|
| 234 |
#' @param root Governed synthetic-data directory. |
|
| 235 |
#' |
|
| 236 |
#' @return The validated versioned manifest. |
|
| 237 |
#' @export |
|
| 238 |
synthetic_scenario_manifest <- function(root = .synthetic_data_root()) {
|
|
| 239 | 14x |
manifest <- jsonlite::read_json( |
| 240 | 14x |
file.path(root, "scenario-manifest.json"), |
| 241 | 14x |
simplifyVector = FALSE |
| 242 |
) |
|
| 243 | 14x |
.validate_synthetic_manifest(manifest) |
| 244 | 14x |
manifest |
| 245 |
} |
|
| 246 | ||
| 247 |
#' Load a governed synthetic BDS scenario |
|
| 248 |
#' |
|
| 249 |
#' @param scenario_id Scenario identifier from [synthetic_scenario_manifest()]. |
|
| 250 |
#' @param root Governed synthetic-data directory. |
|
| 251 |
#' |
|
| 252 |
#' @return A hash-verified synthetic BDS data frame. |
|
| 253 |
#' @export |
|
| 254 |
load_synthetic_scenario <- function( |
|
| 255 |
scenario_id, root = .synthetic_data_root() |
|
| 256 |
) {
|
|
| 257 | 10x |
if (!checkmate::test_string(scenario_id, min.chars = 1L)) {
|
| 258 | ! |
cli::cli_abort("{.arg scenario_id} must be one non-empty identifier")
|
| 259 |
} |
|
| 260 | 10x |
manifest <- synthetic_scenario_manifest(root) |
| 261 | 10x |
matches <- which(vapply( |
| 262 | 10x |
manifest$scenarios, |
| 263 | 10x |
function(x) identical(x$scenario_id, scenario_id), |
| 264 | 10x |
logical(1) |
| 265 |
)) |
|
| 266 | 10x |
if (length(matches) != 1L) {
|
| 267 | ! |
cli::cli_abort("{.val {scenario_id}} is not a governed synthetic scenario")
|
| 268 |
} |
|
| 269 | 10x |
metadata <- manifest$scenarios[[matches]] |
| 270 | 10x |
data <- readRDS(file.path(root, metadata$file)) |
| 271 | 10x |
if (!identical(study_data_content_hash(data), metadata$content_hash)) {
|
| 272 | ! |
cli::cli_abort( |
| 273 | ! |
"The synthetic scenario content hash does not match its manifest" |
| 274 |
) |
|
| 275 |
} |
|
| 276 | 10x |
data |
| 277 |
} |
|
| 278 | ||
| 279 |
.write_synthetic_scenarios <- function(root = .synthetic_data_root()) {
|
|
| 280 | ! |
dir.create(root, recursive = TRUE, showWarnings = FALSE) |
| 281 | ! |
scenarios <- .synthetic_scenario_data() |
| 282 | ! |
manifest <- .build_synthetic_manifest(scenarios) |
| 283 | ! |
for (scenario in manifest$scenarios) {
|
| 284 | ! |
saveRDS( |
| 285 | ! |
scenarios[[scenario$scenario_id]], |
| 286 | ! |
file.path(root, scenario$file), |
| 287 | ! |
version = 3, |
| 288 | ! |
compress = "xz" |
| 289 |
) |
|
| 290 |
} |
|
| 291 | ! |
jsonlite::write_json( |
| 292 | ! |
manifest, |
| 293 | ! |
file.path(root, "scenario-manifest.json"), |
| 294 | ! |
auto_unbox = TRUE, |
| 295 | ! |
pretty = TRUE, |
| 296 | ! |
null = "null", |
| 297 | ! |
digits = NA |
| 298 |
) |
|
| 299 | ! |
invisible(manifest) |
| 300 |
} |
| 1 |
.prompt_context_blocked_terms <- c( |
|
| 2 |
"USUBJID", "patient", "secret", "credential", "workspace", "path", "url", |
|
| 3 |
"file", "directory", "comment", "diagnostic" |
|
| 4 |
) |
|
| 5 | ||
| 6 |
new_prompt_context <- function( |
|
| 7 |
dataset_id, labels = list(), aggregate_profile = list() |
|
| 8 |
) {
|
|
| 9 | 33x |
if (!checkmate::test_string(dataset_id, min.chars = 1) || |
| 10 | 33x |
!checkmate::test_list(labels) || |
| 11 | 33x |
!checkmate::test_list(aggregate_profile)) {
|
| 12 | ! |
cli::cli_abort( |
| 13 | ! |
"Prompt context requires an opaque dataset identifier and list inputs" |
| 14 |
) |
|
| 15 |
} |
|
| 16 | 33x |
if (!.assert_prompt_context_content(list( |
| 17 | 33x |
labels = labels, aggregate_profile = aggregate_profile |
| 18 |
))) {
|
|
| 19 | 3x |
cli::cli_abort("Prompt context contains prohibited content")
|
| 20 |
} |
|
| 21 | 30x |
payload <- list( |
| 22 | 30x |
version = "prompt-context-v1", |
| 23 | 30x |
dataset_id = dataset_id, |
| 24 | 30x |
labels = labels, |
| 25 | 30x |
aggregate_profile = aggregate_profile |
| 26 |
) |
|
| 27 | 30x |
payload$hash <- canonical_hash(payload) |
| 28 | 30x |
structure(payload, class = "prompt_context") |
| 29 |
} |
|
| 30 | ||
| 31 |
.assert_prompt_context_content <- function(value) {
|
|
| 32 | 33x |
content <- paste(unlist(value, use.names = TRUE), collapse = " ") |
| 33 | 33x |
pattern <- paste( |
| 34 | 33x |
c(.prompt_context_blocked_terms, "https?://"), |
| 35 | 33x |
collapse = "|" |
| 36 |
) |
|
| 37 | 33x |
if (grepl(pattern, content, ignore.case = TRUE)) {
|
| 38 | 3x |
return(FALSE) |
| 39 |
} |
|
| 40 | 30x |
TRUE |
| 41 |
} |
| 1 |
.validate_prompt_context <- function(context) {
|
|
| 2 | 23x |
if (!inherits(context, "prompt_context")) {
|
| 3 | ! |
cli::cli_abort("{.arg context} must be a {.cls prompt_context}")
|
| 4 |
} |
|
| 5 | 23x |
invisible(context) |
| 6 |
} |
|
| 7 | ||
| 8 |
.validate_prompt_interpreter <- function(interpreter) {
|
|
| 9 | 23x |
if (!inherits(interpreter, "prompt_interpreter")) {
|
| 10 | ! |
cli::cli_abort("{.arg interpreter} must be a {.cls prompt_interpreter}")
|
| 11 |
} |
|
| 12 | 23x |
invisible(interpreter) |
| 13 |
} |
|
| 14 | ||
| 15 |
#' Interpret a prompt through a provider-neutral adapter |
|
| 16 |
#' |
|
| 17 |
#' @param prompt One natural-language request. |
|
| 18 |
#' @param context A `prompt_context`. |
|
| 19 |
#' @param interpreter A prompt interpreter, such as `mock_prompt_interpreter()`. |
|
| 20 |
#' @param snapshot Optional pinned snapshot used for U3 semantic validation. |
|
| 21 |
#' |
|
| 22 |
#' @return A `prompt_interpretation` with status, candidate, choices, and |
|
| 23 |
#' provider metadata. It never includes executable code. |
|
| 24 |
#' @export |
|
| 25 |
interpret_prompt <- function(prompt, context, interpreter, snapshot = NULL) {
|
|
| 26 | 23x |
if (!checkmate::test_string(prompt, min.chars = 1L)) {
|
| 27 | ! |
cli::cli_abort("{.arg prompt} must be one non-empty string")
|
| 28 |
} |
|
| 29 | 23x |
.validate_prompt_context(context) |
| 30 | 23x |
.validate_prompt_interpreter(interpreter) |
| 31 | ||
| 32 | 23x |
blocked <- .detect_blocked_prompt(prompt) |
| 33 | 23x |
if (!is.null(blocked)) {
|
| 34 | 6x |
return(.prompt_interpretation( |
| 35 | 6x |
status = "manual_selection", |
| 36 | 6x |
candidate = NULL, |
| 37 | 6x |
clarifications = list(reason = blocked), |
| 38 | 6x |
metadata = list(provider = interpreter$provider, provider_called = FALSE) |
| 39 |
)) |
|
| 40 |
} |
|
| 41 | ||
| 42 | 17x |
raw <- interpreter$interpret(prompt = prompt, context = context) |
| 43 | 17x |
result <- .normalize_provider_result(raw, context, interpreter) |
| 44 | 17x |
if (identical(result$status, "candidate") && !is.null(snapshot)) {
|
| 45 | 3x |
.validate_prompt_candidate(result$candidate, snapshot) |
| 46 |
} |
|
| 47 | 17x |
result |
| 48 |
} |
|
| 49 | ||
| 50 |
.detect_blocked_prompt <- function(prompt) {
|
|
| 51 | 23x |
checks <- c( |
| 52 | 23x |
arbitrary_r_deferred = paste( |
| 53 | 23x |
"\\b(run|write|execute|compile)\\b.*\\b(r|code|script)\\b", |
| 54 | 23x |
paste0( |
| 55 | 23x |
"\\b(log[- ]?transform|transform|derive|calculate|compute|mutate|", |
| 56 | 23x |
"normaliz\\w*|standardiz\\w*)\\b" |
| 57 |
), |
|
| 58 | 23x |
"\\b(AVAL|CHG|PCHG)\\b\\s*[-+*/^]", |
| 59 | 23x |
"[-+*/^]\\s*\\b(AVAL|CHG|PCHG)\\b", |
| 60 | 23x |
"library\\s*\\(|ggplot\\s*\\(|system\\s*\\(",
|
| 61 | 23x |
sep = "|" |
| 62 |
), |
|
| 63 | 23x |
prompt_injection = paste( |
| 64 | 23x |
"ignore (all )?(previous|system)|developer message|tool instruction" |
| 65 |
), |
|
| 66 | 23x |
path_or_url = "https?://|[A-Za-z]:[\\\\/]|\\\\\\\\|\\.rds\\b|\\.csv\\b", |
| 67 | 23x |
sensitive_content = "USUBJID|patient|secret|credential|workspace" |
| 68 |
) |
|
| 69 | 23x |
matched <- names(checks)[vapply( |
| 70 | 23x |
checks, grepl, logical(1), |
| 71 | 23x |
x = prompt, ignore.case = TRUE |
| 72 |
)] |
|
| 73 | 6x |
if (length(matched)) matched[[1]] else NULL |
| 74 |
} |
|
| 75 | ||
| 76 |
.normalize_provider_result <- function(raw, context, interpreter) {
|
|
| 77 | 17x |
allowed <- c( |
| 78 | 17x |
"status", "fields", "clarifications", "required_confirmations", "metadata" |
| 79 |
) |
|
| 80 | 17x |
extras <- setdiff(names(raw), allowed) |
| 81 | 17x |
if (length(extras)) {
|
| 82 | 1x |
return(.prompt_interpretation( |
| 83 | 1x |
status = "manual_selection", |
| 84 | 1x |
candidate = NULL, |
| 85 | 1x |
clarifications = list(reason = "extra_provider_fields"), |
| 86 | 1x |
metadata = list(provider = interpreter$provider, provider_called = TRUE) |
| 87 |
)) |
|
| 88 |
} |
|
| 89 | 16x |
if (!checkmate::test_choice( |
| 90 | 16x |
raw$status, c("candidate", "clarification", "manual_selection")
|
| 91 |
)) {
|
|
| 92 | ! |
return(.prompt_interpretation( |
| 93 | ! |
status = "manual_selection", |
| 94 | ! |
candidate = NULL, |
| 95 | ! |
clarifications = list(reason = "malformed_provider_response"), |
| 96 | ! |
metadata = list(provider = interpreter$provider, provider_called = TRUE) |
| 97 |
)) |
|
| 98 |
} |
|
| 99 | 16x |
if (!identical(raw$status, "candidate")) {
|
| 100 | 2x |
return(.prompt_interpretation( |
| 101 | 2x |
status = raw$status, |
| 102 | 2x |
candidate = NULL, |
| 103 | 2x |
clarifications = raw$clarifications %||% list(reason = raw$status), |
| 104 | 2x |
metadata = c( |
| 105 | 2x |
list(provider = interpreter$provider, provider_called = TRUE), |
| 106 | 2x |
raw$metadata %||% list() |
| 107 |
) |
|
| 108 |
)) |
|
| 109 |
} |
|
| 110 | ||
| 111 | 14x |
fields <- raw$fields |
| 112 | 14x |
field_extras <- setdiff(names(fields), .plot_spec_fields) |
| 113 | 14x |
if (length(field_extras)) {
|
| 114 | 1x |
return(.prompt_interpretation( |
| 115 | 1x |
status = "manual_selection", |
| 116 | 1x |
candidate = NULL, |
| 117 | 1x |
clarifications = list(reason = "extra_candidate_fields"), |
| 118 | 1x |
metadata = list(provider = interpreter$provider, provider_called = TRUE) |
| 119 |
)) |
|
| 120 |
} |
|
| 121 | 13x |
candidate <- new_plot_spec( |
| 122 | 13x |
dataset_id = context$dataset_id, |
| 123 | 13x |
paramcd = fields$paramcd %||% NULL, |
| 124 | 13x |
y_variable = fields$y_variable %||% NULL, |
| 125 | 13x |
unit = fields$unit %||% NULL, |
| 126 | 13x |
treatment_variable = fields$treatment_variable %||% NULL, |
| 127 | 13x |
treatment_levels = fields$treatment_levels %||% NULL, |
| 128 | 13x |
visits = fields$visits %||% NULL, |
| 129 | 13x |
scale_mode = fields$scale_mode %||% "fixed", |
| 130 | 13x |
provenance = .candidate_provenance(fields), |
| 131 | 13x |
state = "candidate" |
| 132 |
) |
|
| 133 | 13x |
clarifications <- plot_spec_clarifications(candidate) |
| 134 | 13x |
status <- if ( |
| 135 | 13x |
length(clarifications) || length(raw$required_confirmations %||% list()) |
| 136 |
) {
|
|
| 137 | 2x |
"clarification" |
| 138 |
} else {
|
|
| 139 | 11x |
"candidate" |
| 140 |
} |
|
| 141 | 13x |
.prompt_interpretation( |
| 142 | 13x |
status = status, |
| 143 | 13x |
candidate = candidate, |
| 144 | 13x |
clarifications = list( |
| 145 | 13x |
missing_fields = clarifications, |
| 146 | 13x |
required_confirmations = raw$required_confirmations %||% list() |
| 147 |
), |
|
| 148 | 13x |
metadata = c( |
| 149 | 13x |
list( |
| 150 | 13x |
provider = interpreter$provider, |
| 151 | 13x |
model = interpreter$model, |
| 152 | 13x |
provider_called = TRUE, |
| 153 | 13x |
interpreter_version = "prompt-interpreter-v1", |
| 154 | 13x |
schema_version = "plot-spec-v1", |
| 155 | 13x |
response_retention = "none" |
| 156 |
), |
|
| 157 | 13x |
raw$metadata %||% list() |
| 158 |
) |
|
| 159 |
) |
|
| 160 |
} |
|
| 161 | ||
| 162 |
.candidate_provenance <- function(fields) {
|
|
| 163 | 13x |
provenance <- rep("prompt", length(fields))
|
| 164 | 13x |
names(provenance) <- names(fields) |
| 165 | 13x |
provenance <- as.list(provenance) |
| 166 | 13x |
provenance$dataset_id <- "metadata" |
| 167 | ! |
if (is.null(fields$scale_mode)) provenance$scale_mode <- "default" |
| 168 | 13x |
provenance |
| 169 |
} |
|
| 170 | ||
| 171 |
.validate_prompt_candidate <- function(candidate, snapshot) {
|
|
| 172 | 3x |
selections <- candidate$fields[ |
| 173 | 3x |
setdiff(.plot_spec_fields, c("dataset_id", "scale_mode"))
|
| 174 |
] |
|
| 175 | 3x |
result <- validate_bds_profile(snapshot, selections) |
| 176 | 3x |
if (result$blocked) {
|
| 177 | ! |
cli::cli_abort( |
| 178 | ! |
"The interpreted candidate does not satisfy the supported BDS profile" |
| 179 |
) |
|
| 180 |
} |
|
| 181 | 3x |
invisible(candidate) |
| 182 |
} |
|
| 183 | ||
| 184 |
.prompt_interpretation <- function( |
|
| 185 |
status, candidate, clarifications, metadata |
|
| 186 |
) {
|
|
| 187 | 23x |
structure( |
| 188 | 23x |
list( |
| 189 | 23x |
version = "prompt-interpretation-v1", |
| 190 | 23x |
status = status, |
| 191 | 23x |
candidate = candidate, |
| 192 | 23x |
clarifications = clarifications, |
| 193 | 23x |
metadata = metadata, |
| 194 | 23x |
executable_code = NULL |
| 195 |
), |
|
| 196 | 23x |
class = "prompt_interpretation" |
| 197 |
) |
|
| 198 |
} |
|
| 199 | ||
| 200 |
`%||%` <- function(x, y) {
|
|
| 201 | 257x |
if (is.null(x)) y else x |
| 202 |
} |
| 1 |
#' Create a deterministic prompt interpreter for tests and local development |
|
| 2 |
#' |
|
| 3 |
#' @return A provider-neutral `prompt_interpreter`. |
|
| 4 |
#' @export |
|
| 5 |
mock_prompt_interpreter <- function() {
|
|
| 6 | 18x |
structure( |
| 7 | 18x |
list( |
| 8 | 18x |
port = new_provider_port( |
| 9 | 18x |
"prompt_interpreter", c("structured_candidate", "clarification")
|
| 10 |
), |
|
| 11 | 18x |
provider = "deterministic-mock", |
| 12 | 18x |
model = "mock-rule-set-v1", |
| 13 | 18x |
interpret = .mock_interpret_prompt |
| 14 |
), |
|
| 15 | 18x |
class = "prompt_interpreter" |
| 16 |
) |
|
| 17 |
} |
|
| 18 | ||
| 19 |
.mock_interpret_prompt <- function(prompt, context) {
|
|
| 20 | 15x |
profile <- context$aggregate_profile |
| 21 | 15x |
parameters <- profile$parameters |
| 22 | 15x |
paramcd <- .mock_match_parameter(prompt, parameters) |
| 23 | 15x |
if (is.null(paramcd)) {
|
| 24 | 2x |
return(list( |
| 25 | 2x |
status = "clarification", |
| 26 | 2x |
clarifications = list(field = "paramcd", choices = names(parameters)) |
| 27 |
)) |
|
| 28 |
} |
|
| 29 | 13x |
units <- parameters[[paramcd]]$units |
| 30 | 13x |
unit <- .mock_match_value(prompt, units) |
| 31 | 12x |
if (is.null(unit) && length(units) == 1L) unit <- units[[1]] |
| 32 | ||
| 33 | 13x |
treatment_variable <- profile$treatment_variables[[1]] |
| 34 | 13x |
treatment_levels <- profile$treatment_levels[[treatment_variable]] |
| 35 | 13x |
included_treatment_levels <- .mock_exclude_values(prompt, treatment_levels) |
| 36 | 13x |
visits <- vapply(profile$visits, `[[`, "", "label") |
| 37 | 13x |
included_visits <- .mock_exclude_values(prompt, visits) |
| 38 | 13x |
scale_mode <- if (grepl( |
| 39 | 13x |
"free[- ]?scale|free y|separate scale", prompt, |
| 40 | 13x |
ignore.case = TRUE |
| 41 |
)) {
|
|
| 42 | 1x |
"free" |
| 43 |
} else {
|
|
| 44 | 12x |
"fixed" |
| 45 |
} |
|
| 46 | 13x |
required_confirmations <- if (identical(scale_mode, "free")) {
|
| 47 | 1x |
list(scale_mode = "Free scales create an Experimental/Draft candidate") |
| 48 |
} else {
|
|
| 49 | 12x |
list() |
| 50 |
} |
|
| 51 | 13x |
clarifications <- if (length(units) > 1L && is.null(unit)) {
|
| 52 | 1x |
list(field = "unit", choices = units) |
| 53 |
} else {
|
|
| 54 | 12x |
list() |
| 55 |
} |
|
| 56 | ||
| 57 | 13x |
list( |
| 58 | 13x |
status = "candidate", |
| 59 | 13x |
fields = list( |
| 60 | 13x |
paramcd = paramcd, |
| 61 | 13x |
y_variable = .mock_y_variable(prompt, profile$y_variables), |
| 62 | 13x |
unit = unit, |
| 63 | 13x |
treatment_variable = treatment_variable, |
| 64 | 13x |
treatment_levels = included_treatment_levels, |
| 65 | 13x |
visits = included_visits, |
| 66 | 13x |
scale_mode = scale_mode |
| 67 |
), |
|
| 68 | 13x |
clarifications = clarifications, |
| 69 | 13x |
required_confirmations = required_confirmations, |
| 70 | 13x |
metadata = list(prompt_template = "mock-prompt-template-v1") |
| 71 |
) |
|
| 72 |
} |
|
| 73 | ||
| 74 |
.mock_match_parameter <- function(prompt, parameters) {
|
|
| 75 | 15x |
matches <- names(parameters)[vapply(names(parameters), function(paramcd) {
|
| 76 | 15x |
grepl(paste0("\\b", paramcd, "\\b"), prompt, ignore.case = TRUE) ||
|
| 77 | 15x |
grepl(tolower(parameters[[paramcd]]$label), tolower(prompt), fixed = TRUE) |
| 78 | 15x |
}, logical(1))] |
| 79 | 2x |
if (length(matches) == 1L) matches[[1]] else NULL |
| 80 |
} |
|
| 81 | ||
| 82 |
.mock_match_value <- function(prompt, values) {
|
|
| 83 | 13x |
matches <- values[vapply( |
| 84 | 13x |
tolower(values), grepl, logical(1), |
| 85 | 13x |
x = tolower(prompt), fixed = TRUE |
| 86 |
)] |
|
| 87 | ! |
if (length(matches) == 1L) matches[[1]] else NULL |
| 88 |
} |
|
| 89 | ||
| 90 |
.mock_y_variable <- function(prompt, choices) {
|
|
| 91 | 13x |
if ("PCHG" %in% choices && grepl(
|
| 92 | 13x |
"percent|percentage|pct|PCHG", prompt, |
| 93 | 13x |
ignore.case = TRUE |
| 94 |
)) {
|
|
| 95 | 1x |
return("PCHG")
|
| 96 |
} |
|
| 97 | 12x |
if ("CHG" %in% choices && grepl("change|CHG", prompt, ignore.case = TRUE)) {
|
| 98 | 1x |
return("CHG")
|
| 99 |
} |
|
| 100 | 11x |
"AVAL" |
| 101 |
} |
|
| 102 | ||
| 103 |
.mock_exclude_values <- function(prompt, values) {
|
|
| 104 | 26x |
kept <- values |
| 105 | 26x |
for (value in values) {
|
| 106 | 65x |
pattern <- paste0( |
| 107 | 65x |
"\\b(exclude|without|omit)\\b[^.]*\\b", |
| 108 | 65x |
gsub(" ", "\\\\s+", value), "\\b"
|
| 109 |
) |
|
| 110 | 65x |
if (grepl(pattern, prompt, ignore.case = TRUE)) {
|
| 111 | 2x |
kept <- setdiff(kept, value) |
| 112 |
} |
|
| 113 |
} |
|
| 114 | 26x |
kept |
| 115 |
} |
| 1 |
mod_plot_generation_ui <- function(id) {
|
|
| 2 | 6x |
ns <- shiny::NS(id) |
| 3 | 6x |
tags <- shiny::tags |
| 4 | ||
| 5 | 6x |
tags$div( |
| 6 | 6x |
tags$style(shiny::HTML(paste(c( |
| 7 | 6x |
".f001-screen { position: relative; min-width: 0;",
|
| 8 | 6x |
"min-height: 385px; border: 1px solid #aebbc5;", |
| 9 | 6x |
"border-radius: .5rem; background: white; overflow: hidden; }", |
| 10 | 6x |
".f001-appbar { height: 45px; padding: .5rem .75rem;",
|
| 11 | 6x |
"border-bottom: 1px solid #c7d1d9; display: flex;", |
| 12 | 6x |
"align-items: center; justify-content: space-between;", |
| 13 | 6x |
"background: white; }", |
| 14 | 6x |
".f001-brand { font-weight: 700; }",
|
| 15 | 6x |
".f001-stepmark { color: #344754; font-size: .78rem; }",
|
| 16 | 6x |
".f001-step-status { color: #344754; font-size: .78rem; }",
|
| 17 | 6x |
".f001-workbench { position: relative; padding: 12px 14px 13px 40px; }",
|
| 18 | 6x |
".f001-steps { display: grid; grid-template-columns: repeat(5, 1fr);",
|
| 19 | 6x |
"gap: 5px; margin-bottom: 11px; }", |
| 20 | 6x |
".f001-stepmark { padding: 6px 5px; border: 1px solid #c7d1d9;",
|
| 21 | 6x |
"border-radius: 4px; background: #f7f9fa; text-align: center;", |
| 22 | 6x |
"color: #52616d; font-size: .75rem; }", |
| 23 | 6x |
".f001-stepmark.current { border-color: #7492a6;",
|
| 24 | 6x |
"background: #edf4f8; color: #173f59; font-weight: 700; }", |
| 25 | 6x |
".f001-stepmark.done { color: #245b46; }",
|
| 26 | 6x |
".f001-data-drawer { position: static; }",
|
| 27 | 6x |
".f001-data-drawer > summary { position: absolute; z-index: 5;",
|
| 28 | 6x |
"top: 57px; left: 0; display: flex; flex-direction: column;", |
| 29 | 6x |
"width: 28px; height: 148px; padding: 9px 5px;", |
| 30 | 6x |
"align-items: center; justify-content: space-between;", |
| 31 | 6x |
"border: 1px solid #8fa2b0; border-left: 0;", |
| 32 | 6x |
"border-radius: 0 6px 6px 0; background: white; color: #1c4259;", |
| 33 | 6x |
"cursor: pointer; list-style: none; box-shadow: 1px 2px 5px #18293612; }", |
| 34 | 6x |
".f001-data-drawer > summary::-webkit-details-marker,", |
| 35 | 6x |
".f001-panel > summary::-webkit-details-marker { display: none; }",
|
| 36 | 6x |
".f001-trigger-label { writing-mode: vertical-rl;",
|
| 37 | 6x |
"transform: rotate(180deg); font-size: .75rem; font-weight: 650;", |
| 38 | 6x |
"white-space: nowrap; }", |
| 39 | 6x |
".f001-drawer-grip { color: #688091; font-size: 1rem; line-height: 1;",
|
| 40 | 6x |
"letter-spacing: -2px; }", |
| 41 | 6x |
".f001-data-drawer[open] > summary { left: 250px;",
|
| 42 | 6x |
"border-left: 1px solid #8fa2b0; border-radius: 6px 0 0 6px;", |
| 43 | 6x |
"background: #edf3f6; box-shadow: none; }", |
| 44 | 6x |
".f001-drawer-body { position: absolute; z-index: 4; top: 45px;",
|
| 45 | 6x |
"bottom: 0; left: 0; width: 250px; padding: 15px 14px;", |
| 46 | 6x |
"border-right: 1px solid #8fa2b0; background: white;", |
| 47 | 6x |
"box-shadow: 4px 0 14px #18293616; }", |
| 48 | 6x |
".f001-drawer-body h3 { margin: 0 0 3px; font-size: 1rem; }",
|
| 49 | 6x |
".f001-drawer-body p { margin: 0 0 12px; color: #52616d;",
|
| 50 | 6x |
"font-size: .82rem; }", |
| 51 | 6x |
".f001-drawer-section { padding: 10px 0;",
|
| 52 | 6x |
"border-top: 1px solid #e0e6ea; }", |
| 53 | 6x |
".f001-drawer-section b { display: block; }",
|
| 54 | 6x |
".f001-drawer-tag { display: inline-block; padding: 2px 7px;",
|
| 55 | 6x |
"border: 1px solid #a6b6c0; border-radius: 12px;", |
| 56 | 6x |
"background: #f4f7f8; color: #334b5a; font-size: .72rem; }", |
| 57 | 6x |
".f001-screen h2 { margin: 0; font-size: 1.05rem; }",
|
| 58 | 6x |
".f001-panel { margin-bottom: 8px; border: 1px solid #c7d1d9;",
|
| 59 | 6x |
"border-radius: 5px; background: white; }", |
| 60 | 6x |
".f001-panel > summary { display: flex; align-items: center;",
|
| 61 | 6x |
"justify-content: space-between; padding: .5rem .65rem;", |
| 62 | 6x |
"cursor: pointer; font-weight: 650; list-style: none; }", |
| 63 | 6x |
".f001-panel > summary::after { content: '+'; color: #31556b;",
|
| 64 | 6x |
"font-weight: 500; }", |
| 65 | 6x |
".f001-panel[open] > summary::after { content: '\\2212'; }",
|
| 66 | 6x |
".f001-panel-body { padding: 0 .65rem .65rem; }",
|
| 67 | 6x |
".f001-caption { margin-top: .75rem; color: #52616d;",
|
| 68 | 6x |
"font-size: .9rem; }", |
| 69 | 6x |
".f001-page-title { display: flex; align-items: center;",
|
| 70 | 6x |
"justify-content: space-between; gap: .75rem; margin: 0 0 8px; }", |
| 71 | 6x |
".f001-page-title > span { color: #52616d; font-size: .78rem; }",
|
| 72 | 6x |
".f001-required { display: inline-block; margin-left: 5px;",
|
| 73 | 6x |
"padding: 1px 5px; border: 1px solid #e1c38a; border-radius: 3px;", |
| 74 | 6x |
"background: #fff4df; color: #6d4400; font-size: .7rem;", |
| 75 | 6x |
"font-weight: 700; }", |
| 76 | 6x |
".f001-drop { margin-top: 8px; padding: 13px 10px;",
|
| 77 | 6x |
"border: 1px dashed #91a1ac; border-radius: 4px;", |
| 78 | 6x |
"background: #fbfcfd; }", |
| 79 | 6x |
".f001-drop .shiny-input-container { width: 100%; margin: 0; }",
|
| 80 | 6x |
".f001-drop .form-group { margin: 0; }",
|
| 81 | 6x |
".f001-drop input[type=file] { width: 100%; }",
|
| 82 | 6x |
".f001-drop-caption { color: #52616d; font-size: .8rem; }",
|
| 83 | 6x |
".f001-upload-status { margin-top: .5rem; font-size: .82rem; }",
|
| 84 | 6x |
".f001-footer-action { display: flex; align-items: center;",
|
| 85 | 6x |
"justify-content: space-between; gap: .75rem; margin-top: 9px; }", |
| 86 | 6x |
".f001-footer-action > span { font-size: .8rem; }",
|
| 87 | 6x |
".f001-footer-action .btn { margin: 0; }",
|
| 88 | 6x |
".f001-review-info { width: 1.5rem; height: 1.5rem;",
|
| 89 | 6x |
"margin-left: .35rem; border: 1px solid #96a8b3; border-radius: 50%;", |
| 90 | 6x |
"background: white; color: #23485f; }", |
| 91 | 6x |
".f001-result-content { position: relative; }",
|
| 92 | 6x |
".f001-screen-caption { padding: 7px 12px 8px 40px;",
|
| 93 | 6x |
"border-top: 1px solid #e3e8eb; background: #fafbfc;", |
| 94 | 6x |
"color: #50606a; font-size: .78rem; }", |
| 95 | 6x |
"@media (max-width: 575.98px) {",
|
| 96 | 6x |
".f001-workbench { padding-right: 10px; }",
|
| 97 | 6x |
".f001-stepmark { padding: .35rem .1rem; font-size: .75rem; }",
|
| 98 |
"}" |
|
| 99 | 6x |
), collapse = "\n"))), |
| 100 | 6x |
tags$div( |
| 101 | 6x |
class = "f001-screen", |
| 102 | 6x |
tags$div( |
| 103 | 6x |
class = "f001-appbar", |
| 104 | 6x |
tags$span(class = "f001-brand", "Create plot"), |
| 105 | 6x |
shiny::uiOutput(ns("step_mark"))
|
| 106 |
), |
|
| 107 | 6x |
tags$div( |
| 108 | 6x |
style = "display: none;", |
| 109 | 6x |
shiny::textInput(ns("step_signal"), NULL, value = "data")
|
| 110 |
), |
|
| 111 | 6x |
tags$details( |
| 112 | 6x |
id = ns("data_drawer"),
|
| 113 | 6x |
class = "f001-data-drawer", |
| 114 | 6x |
tags$summary( |
| 115 | 6x |
tags$span(class = "f001-trigger-label", "Data & profile"), |
| 116 | 6x |
tags$span(class = "f001-drawer-grip", "\u2022\u2022\u2022"), |
| 117 | 6x |
`aria-label` = "Open active data and profile drawer" |
| 118 |
), |
|
| 119 | 6x |
tags$div( |
| 120 | 6x |
class = "f001-drawer-body", shiny::uiOutput(ns("data_profile"))
|
| 121 |
) |
|
| 122 |
), |
|
| 123 | 6x |
tags$div( |
| 124 | 6x |
class = "f001-workbench", |
| 125 | 6x |
shiny::uiOutput(ns("stepper")),
|
| 126 | 6x |
shiny::conditionalPanel( |
| 127 | 6x |
sprintf("input['%s'] === 'data'", ns("step_signal")),
|
| 128 | 6x |
tags$section( |
| 129 | 6x |
`aria-labelledby` = ns("data-heading"),
|
| 130 | 6x |
tags$div( |
| 131 | 6x |
class = "f001-page-title", |
| 132 | 6x |
tags$h2(id = ns("data-heading"), "Start with your data"),
|
| 133 | 6x |
tags$span("Upload is required to continue")
|
| 134 |
), |
|
| 135 | 6x |
tags$details( |
| 136 | 6x |
class = "f001-panel", |
| 137 | 6x |
open = NA, |
| 138 | 6x |
tags$summary( |
| 139 | 6x |
"Upload a dataset ", |
| 140 | 6x |
tags$span(class = "f001-required", "Required") |
| 141 |
), |
|
| 142 | 6x |
tags$div( |
| 143 | 6x |
class = "f001-panel-body", |
| 144 | 6x |
tags$div( |
| 145 | 6x |
class = "f001-drop-caption", |
| 146 | 6x |
"Choose a supported CSV or Excel file." |
| 147 |
), |
|
| 148 | 6x |
tags$div( |
| 149 | 6x |
class = "f001-drop", |
| 150 | 6x |
shiny::fileInput( |
| 151 | 6x |
ns("data_file"),
|
| 152 | 6x |
NULL, |
| 153 | 6x |
accept = c(".csv", ".xls", ".xlsx"),
|
| 154 | 6x |
multiple = FALSE, |
| 155 | 6x |
buttonLabel = "Choose file", |
| 156 | 6x |
placeholder = "or drag and drop it here" |
| 157 |
), |
|
| 158 | 6x |
tags$div(class = "f001-drop-caption", "CSV or Excel") |
| 159 |
), |
|
| 160 | 6x |
tags$div( |
| 161 | 6x |
class = "f001-upload-status", |
| 162 | 6x |
shiny::uiOutput(ns("upload_status"))
|
| 163 |
) |
|
| 164 |
) |
|
| 165 |
), |
|
| 166 | 6x |
tags$div( |
| 167 | 6x |
class = "f001-footer-action", |
| 168 | 6x |
tags$span( |
| 169 | 6x |
class = "text-body-secondary", |
| 170 | 6x |
"Next: describe the visualization question" |
| 171 |
), |
|
| 172 | 6x |
shiny::uiOutput(ns("continue_data"))
|
| 173 |
) |
|
| 174 |
) |
|
| 175 |
), |
|
| 176 | 6x |
shiny::conditionalPanel( |
| 177 | 6x |
sprintf("input['%s'] === 'ask'", ns("step_signal")),
|
| 178 | 6x |
tags$section( |
| 179 | 6x |
`aria-labelledby` = ns("ask-heading"),
|
| 180 | 6x |
tags$h2(id = ns("ask-heading"), "What would you like to see?"),
|
| 181 | 6x |
tags$p("Describe one visualization question in your words."),
|
| 182 | 6x |
tags$details( |
| 183 | 6x |
class = "f001-panel", |
| 184 | 6x |
open = NA, |
| 185 | 6x |
tags$summary("Your visualization question \u00b7 Required"),
|
| 186 | 6x |
tags$div( |
| 187 | 6x |
class = "f001-panel-body", |
| 188 | 6x |
shiny::textAreaInput( |
| 189 | 6x |
ns("question"),
|
| 190 | 6x |
"Question", |
| 191 | 6x |
value = paste( |
| 192 | 6x |
"Create a boxplot of ALT AVAL by visit,", |
| 193 | 6x |
"split by treatment.", |
| 194 | 6x |
"Include all visits and treatment groups, show the number", |
| 195 | 6x |
"of subjects below each box, and connect the medians." |
| 196 |
), |
|
| 197 | 6x |
width = "100%", |
| 198 | 6x |
rows = 3 |
| 199 |
), |
|
| 200 | 6x |
shiny::uiOutput(ns("question_validation"))
|
| 201 |
) |
|
| 202 |
), |
|
| 203 | 6x |
tags$div( |
| 204 | 6x |
class = "d-flex justify-content-between", |
| 205 | 6x |
shiny::actionButton( |
| 206 | 6x |
ns("back_to_data"), "Back to data",
|
| 207 | 6x |
class = "btn-outline-secondary" |
| 208 |
), |
|
| 209 | 6x |
shiny::actionButton( |
| 210 | 6x |
ns("to_confirm"), "Continue to review choices",
|
| 211 | 6x |
class = "btn-primary" |
| 212 |
) |
|
| 213 |
) |
|
| 214 |
) |
|
| 215 |
), |
|
| 216 | 6x |
shiny::conditionalPanel( |
| 217 | 6x |
sprintf("input['%s'] === 'confirm'", ns("step_signal")),
|
| 218 | 6x |
tags$section( |
| 219 | 6x |
`aria-labelledby` = ns("confirm-heading"),
|
| 220 | 6x |
tags$h2(id = ns("confirm-heading"), "Review the proposed plot"),
|
| 221 | 6x |
shiny::uiOutput(ns("confirm_guidance")),
|
| 222 | 6x |
mod_specification_ui(ns("specification")),
|
| 223 | 6x |
tags$div( |
| 224 | 6x |
class = "d-flex justify-content-between", |
| 225 | 6x |
tags$div( |
| 226 | 6x |
class = "d-flex gap-2", |
| 227 | 6x |
shiny::uiOutput(ns("back_to_ask_ui")),
|
| 228 | 6x |
shiny::uiOutput(ns("back_to_result_from_confirm_ui"))
|
| 229 |
), |
|
| 230 | 6x |
shiny::uiOutput(ns("confirm_message"))
|
| 231 |
), |
|
| 232 | 6x |
tags$p( |
| 233 | 6x |
class = "f001-caption", |
| 234 | 6x |
paste( |
| 235 | 6x |
"The selected BDS records are validated before", |
| 236 | 6x |
"the deterministic", |
| 237 | 6x |
"R script is generated or executed." |
| 238 |
) |
|
| 239 |
) |
|
| 240 |
) |
|
| 241 |
), |
|
| 242 | 6x |
shiny::conditionalPanel( |
| 243 | 6x |
sprintf("input['%s'] === 'result'", ns("step_signal")),
|
| 244 | 6x |
tags$section( |
| 245 | 6x |
`aria-labelledby` = ns("result-heading"),
|
| 246 | 6x |
tags$div( |
| 247 | 6x |
class = "f001-page-title", |
| 248 | 6x |
tags$h2(id = ns("result-heading"), "Plot result"),
|
| 249 | 6x |
tags$div(shiny::uiOutput(ns("review_status")))
|
| 250 |
), |
|
| 251 | 6x |
tags$div( |
| 252 | 6x |
id = ns("result-body"),
|
| 253 | 6x |
class = "f001-result-content", |
| 254 | 6x |
mod_run_status_ui(ns("run_status")),
|
| 255 | 6x |
mod_plot_preview_ui(ns("plot_preview")),
|
| 256 | 6x |
tags$details( |
| 257 | 6x |
class = "f001-panel", |
| 258 | 6x |
tags$summary("Optional: view automated checks"),
|
| 259 | 6x |
tags$div( |
| 260 | 6x |
class = "f001-panel-body", mod_evidence_ui(ns("evidence"))
|
| 261 |
) |
|
| 262 |
) |
|
| 263 |
), |
|
| 264 | 6x |
tags$div( |
| 265 | 6x |
class = "d-flex justify-content-between", |
| 266 | 6x |
shiny::actionButton( |
| 267 | 6x |
ns("back_to_confirm"), "Back to confirm",
|
| 268 | 6x |
class = "btn-outline-secondary" |
| 269 |
), |
|
| 270 | 6x |
shiny::actionButton( |
| 271 | 6x |
ns("to_export"), "Continue to export",
|
| 272 | 6x |
class = "btn-primary" |
| 273 |
) |
|
| 274 |
) |
|
| 275 |
) |
|
| 276 |
), |
|
| 277 | 6x |
shiny::conditionalPanel( |
| 278 | 6x |
sprintf("input['%s'] === 'export'", ns("step_signal")),
|
| 279 | 6x |
tags$section( |
| 280 | 6x |
`aria-labelledby` = ns("export-heading"),
|
| 281 | 6x |
tags$h2(id = ns("export-heading"), "Export"),
|
| 282 | 6x |
tags$details( |
| 283 | 6x |
class = "f001-panel", |
| 284 | 6x |
open = NA, |
| 285 | 6x |
tags$summary("Traceability, review, and export"),
|
| 286 | 6x |
tags$div( |
| 287 | 6x |
class = "f001-panel-body", |
| 288 | 6x |
mod_revision_history_ui(ns("revision_history")),
|
| 289 | 6x |
mod_review_ui(ns("review")),
|
| 290 | 6x |
mod_export_ui(ns("export"))
|
| 291 |
) |
|
| 292 |
), |
|
| 293 | 6x |
tags$div( |
| 294 | 6x |
class = "d-flex justify-content-start", |
| 295 | 6x |
shiny::actionButton( |
| 296 | 6x |
ns("back_to_result"), "Back to result",
|
| 297 | 6x |
class = "btn-outline-secondary" |
| 298 |
) |
|
| 299 |
), |
|
| 300 | 6x |
tags$p( |
| 301 | 6x |
class = "f001-caption", |
| 302 | 6x |
paste( |
| 303 | 6x |
"Review decisions are pending until both independent reviewers", |
| 304 | 6x |
"approve. The exact R script and check evidence remain linked", |
| 305 | 6x |
"to this revision." |
| 306 |
) |
|
| 307 |
) |
|
| 308 |
) |
|
| 309 |
), |
|
| 310 | 6x |
shiny::conditionalPanel( |
| 311 | 6x |
sprintf("input['%s'] === 'data'", ns("step_signal")),
|
| 312 | 6x |
tags$div( |
| 313 | 6x |
class = "f001-screen-caption", |
| 314 | 6x |
paste( |
| 315 | 6x |
"Data comes first. Continue becomes available when a permitted", |
| 316 | 6x |
"CSV or Excel file is ready." |
| 317 |
) |
|
| 318 |
) |
|
| 319 |
) |
|
| 320 |
), |
|
| 321 | 6x |
tags$script(shiny::HTML(sprintf("\
|
| 322 |
(() => {
|
|
| 323 | 6x |
const drawer = document.getElementById('%s');
|
| 324 | 6x |
const handle = drawer && drawer.querySelector('summary');
|
| 325 | 6x |
if (!handle) return; |
| 326 | 6x |
let startX = 0; |
| 327 | 6x |
let dragged = false; |
| 328 | 6x |
handle.addEventListener('pointerdown', (event) => {
|
| 329 | 6x |
startX = event.clientX; |
| 330 | 6x |
dragged = false; |
| 331 | 6x |
handle.setPointerCapture(event.pointerId); |
| 332 |
}); |
|
| 333 | 6x |
handle.addEventListener('pointermove', (event) => {
|
| 334 | 6x |
if (event.buttons && event.clientX - startX > 48) {
|
| 335 | 6x |
drawer.open = true; |
| 336 | 6x |
dragged = true; |
| 337 |
} |
|
| 338 |
}); |
|
| 339 | 6x |
handle.addEventListener('click', (event) => {
|
| 340 | 6x |
if (dragged) {
|
| 341 | 6x |
event.preventDefault(); |
| 342 | 6x |
dragged = false; |
| 343 |
} |
|
| 344 |
}); |
|
| 345 | 6x |
})();\n", ns("data_drawer"))))
|
| 346 |
) |
|
| 347 |
) |
|
| 348 |
} |
|
| 349 | ||
| 350 |
mod_plot_generation_server <- function( |
|
| 351 |
id, |
|
| 352 |
current_revision, |
|
| 353 |
execute_revision, |
|
| 354 |
create_correction, |
|
| 355 |
workspace_provider |
|
| 356 |
) {
|
|
| 357 | 11x |
tags <- shiny::tags |
| 358 | 11x |
shiny::moduleServer(id, function(input, output, session) {
|
| 359 | 11x |
step_ids <- c("data", "ask", "confirm", "result", "export")
|
| 360 | 11x |
step_labels <- c("Data", "Ask", "Confirm", "Result", "Export")
|
| 361 | 11x |
active_step <- shiny::reactiveVal("data")
|
| 362 | 11x |
loaded_file <- shiny::reactiveVal(NULL) |
| 363 | 11x |
upload_error <- shiny::reactiveVal(NULL) |
| 364 | 11x |
question_attempted <- shiny::reactiveVal(FALSE) |
| 365 | 11x |
confirm_message <- shiny::reactiveVal(NULL) |
| 366 | ||
| 367 | 11x |
question_is_valid <- function() {
|
| 368 | 16x |
is.character(input$question) && length(input$question) == 1L && |
| 369 | 16x |
!is.na(input$question) && nzchar(trimws(input$question)) |
| 370 |
} |
|
| 371 | ||
| 372 | 11x |
upload_inputs_valid <- function() {
|
| 373 | 5x |
file <- input$data_file |
| 374 | 5x |
is.data.frame(file) && nrow(file) == 1L && |
| 375 | 5x |
all(c("name", "datapath") %in% names(file)) &&
|
| 376 | 5x |
checkmate::test_string(file$name[[1]], min.chars = 1L) && |
| 377 | 5x |
tolower(tools::file_ext(file$name[[1]])) %in% c("csv", "xls", "xlsx") &&
|
| 378 | 5x |
checkmate::test_file_exists(file$datapath[[1]], access = "r") |
| 379 |
} |
|
| 380 | ||
| 381 | 11x |
is_current_upload <- shiny::reactive({
|
| 382 | 13x |
loaded <- loaded_file() |
| 383 | 13x |
file <- input$data_file |
| 384 | 13x |
!is.null(loaded) && is.data.frame(file) && nrow(file) == 1L && |
| 385 | 13x |
identical(loaded$path, file$datapath[[1]]) |
| 386 |
}) |
|
| 387 | ||
| 388 | 11x |
output$stepper <- shiny::renderUI({
|
| 389 | 33x |
current <- active_step() |
| 390 | 33x |
current_index <- match(current, step_ids) |
| 391 | 33x |
tags$div( |
| 392 | 33x |
class = "f001-steps", |
| 393 | 33x |
lapply(seq_along(step_ids), function(index) {
|
| 394 | 165x |
class <- if (index == current_index) {
|
| 395 | 33x |
"f001-stepmark current" |
| 396 | 165x |
} else if (index < current_index) {
|
| 397 | 58x |
"f001-stepmark done" |
| 398 |
} else {
|
|
| 399 | 74x |
"f001-stepmark" |
| 400 |
} |
|
| 401 | 165x |
label <- if (index < current_index) {
|
| 402 | 58x |
paste("\u2713", "\u00b7", step_labels[[index]])
|
| 403 |
} else {
|
|
| 404 | 107x |
paste(index, "\u00b7", step_labels[[index]]) |
| 405 |
} |
|
| 406 | 165x |
tags$div(class = class, label) |
| 407 |
}) |
|
| 408 |
) |
|
| 409 |
}) |
|
| 410 | 11x |
output$step_mark <- shiny::renderUI({
|
| 411 | 33x |
tags$span( |
| 412 | 33x |
class = "f001-step-status", |
| 413 | 33x |
paste("Step", match(active_step(), step_ids), "of", length(step_ids))
|
| 414 |
) |
|
| 415 |
}) |
|
| 416 | ||
| 417 | 11x |
output$data_profile <- shiny::renderUI({
|
| 418 | 12x |
loaded <- loaded_file() |
| 419 | 12x |
tags$div( |
| 420 | 12x |
tags$h3("Active data"),
|
| 421 | 12x |
tags$p( |
| 422 | 12x |
if (is.null(loaded)) "No dataset uploaded" else loaded$file_name, |
| 423 | 12x |
if (!is.null(loaded)) tags$span(class = "f001-drawer-tag", "Ready") |
| 424 |
), |
|
| 425 | 12x |
tags$div( |
| 426 | 12x |
class = "f001-drawer-section", |
| 427 | 12x |
tags$b("Data profile"),
|
| 428 | 12x |
tags$span( |
| 429 | 12x |
class = "text-body-secondary", |
| 430 | 12x |
if (is.null(loaded)) {
|
| 431 | 7x |
"Available after upload" |
| 432 |
} else {
|
|
| 433 | 5x |
paste(loaded$rows, "rows \u00d7", loaded$columns, "columns") |
| 434 |
} |
|
| 435 |
) |
|
| 436 |
), |
|
| 437 | 12x |
tags$div( |
| 438 | 12x |
class = "f001-drawer-section", |
| 439 | 12x |
tags$b("File check"),
|
| 440 | 12x |
tags$span( |
| 441 | 12x |
class = "f001-drawer-tag", |
| 442 | 12x |
if (!is.null(loaded)) {
|
| 443 | 5x |
"Passed" |
| 444 | 12x |
} else if (!is.null(upload_error())) {
|
| 445 | ! |
"Needs attention" |
| 446 |
} else {
|
|
| 447 | 7x |
"Waiting for file" |
| 448 |
} |
|
| 449 |
) |
|
| 450 |
), |
|
| 451 | 12x |
if (is.null(loaded)) {
|
| 452 | 7x |
tags$p( |
| 453 | 7x |
class = "text-body-secondary", |
| 454 | 7x |
"Drawer starts closed. Click the tab or drag it to open." |
| 455 |
) |
|
| 456 |
} |
|
| 457 |
) |
|
| 458 |
}) |
|
| 459 | ||
| 460 | 11x |
output$upload_status <- shiny::renderUI({
|
| 461 | 13x |
if (!is.null(upload_error())) {
|
| 462 | ! |
return(tags$div(class = "text-danger", role = "alert", upload_error())) |
| 463 |
} |
|
| 464 | 13x |
if (is_current_upload()) {
|
| 465 | 5x |
return(tags$div(class = "text-success", role = "status", "File ready")) |
| 466 |
} |
|
| 467 | 8x |
NULL |
| 468 |
}) |
|
| 469 | ||
| 470 | 11x |
output$continue_data <- shiny::renderUI({
|
| 471 | 13x |
shiny::actionButton( |
| 472 | 13x |
session$ns("to_ask"),
|
| 473 | 13x |
"Continue", |
| 474 | 13x |
class = "btn-primary", |
| 475 | 13x |
disabled = !is_current_upload() |
| 476 |
) |
|
| 477 |
}) |
|
| 478 | ||
| 479 | 11x |
output$question_validation <- shiny::renderUI({
|
| 480 | 15x |
if (!question_attempted()) {
|
| 481 | 7x |
return(NULL) |
| 482 |
} |
|
| 483 | 8x |
shiny::validate( |
| 484 | 8x |
shiny::need( |
| 485 | 8x |
question_is_valid(), |
| 486 | 8x |
"Enter a visualization question before continuing." |
| 487 |
) |
|
| 488 |
) |
|
| 489 | 8x |
NULL |
| 490 |
}) |
|
| 491 | ||
| 492 | 11x |
output$confirm_guidance <- shiny::renderUI({
|
| 493 | 15x |
state <- current_revision() |
| 494 | 15x |
if (!is.null(state$revision) && is.null(state$pending)) {
|
| 495 | 5x |
return(tags$p( |
| 496 | 5x |
class = "text-body-secondary", |
| 497 | 5x |
paste( |
| 498 | 5x |
"This generated revision is immutable. Its specification is", |
| 499 | 5x |
"shown for review." |
| 500 |
) |
|
| 501 |
)) |
|
| 502 |
} |
|
| 503 | 10x |
NULL |
| 504 |
}) |
|
| 505 | ||
| 506 | 11x |
output$back_to_ask_ui <- shiny::renderUI({
|
| 507 | 15x |
state <- current_revision() |
| 508 | 15x |
if (!is.null(state$revision) && is.null(state$pending)) {
|
| 509 | 5x |
return(NULL) |
| 510 |
} |
|
| 511 | 10x |
shiny::actionButton( |
| 512 | 10x |
session$ns("back_to_ask"),
|
| 513 | 10x |
"Back to question", |
| 514 | 10x |
class = "btn-outline-secondary" |
| 515 |
) |
|
| 516 |
}) |
|
| 517 | ||
| 518 | 11x |
output$back_to_result_from_confirm_ui <- shiny::renderUI({
|
| 519 | 15x |
state <- current_revision() |
| 520 | 15x |
if (is.null(state$revision) || !is.null(state$pending)) {
|
| 521 | 10x |
return(NULL) |
| 522 |
} |
|
| 523 | 5x |
shiny::actionButton( |
| 524 | 5x |
session$ns("back_to_result_from_confirm"),
|
| 525 | 5x |
"Back to result", |
| 526 | 5x |
class = "btn-outline-secondary" |
| 527 |
) |
|
| 528 |
}) |
|
| 529 | ||
| 530 | 11x |
output$confirm_message <- shiny::renderUI({
|
| 531 | 7x |
message <- confirm_message() |
| 532 | 7x |
if (is.null(message)) {
|
| 533 | 7x |
return(NULL) |
| 534 |
} |
|
| 535 | ! |
tags$div(class = "alert alert-danger mb-0", role = "alert", message) |
| 536 |
}) |
|
| 537 | ||
| 538 | 11x |
output$review_status <- shiny::renderUI({
|
| 539 | 11x |
state <- current_revision() |
| 540 | 11x |
if (is.null(state$revision)) {
|
| 541 | 6x |
return(tags$span(class = "status-badge status-draft", "No result yet")) |
| 542 |
} |
|
| 543 | 5x |
revision <- state$revision |
| 544 | 5x |
decisions <- state$repository$list_review_decisions(revision$revision_id) |
| 545 | 4x |
status <- revision$status |
| 546 | 4x |
label <- if (status %in% c("Draft", "Verified") && !nrow(decisions)) {
|
| 547 | 4x |
paste(status, "\u00b7 not reviewed") |
| 548 | 4x |
} else if (status %in% c("Draft", "Verified")) {
|
| 549 | ! |
paste(status, "\u00b7 review pending") |
| 550 |
} else {
|
|
| 551 | ! |
status |
| 552 |
} |
|
| 553 | 4x |
tags$span( |
| 554 | 4x |
tags$span(class = paste("status-badge", .status_class(status)), label),
|
| 555 | 4x |
tags$button( |
| 556 | 4x |
type = "button", |
| 557 | 4x |
class = "f001-review-info", |
| 558 | 4x |
title = paste( |
| 559 | 4x |
"Both independent approvals are required for", |
| 560 | 4x |
"post-approval export eligibility." |
| 561 |
), |
|
| 562 | 4x |
`aria-label` = paste( |
| 563 | 4x |
"Review status: both independent approvals are required for", |
| 564 | 4x |
"post-approval export eligibility." |
| 565 |
), |
|
| 566 | 4x |
"i" |
| 567 |
) |
|
| 568 |
) |
|
| 569 |
}) |
|
| 570 | ||
| 571 | 11x |
set_step <- function(next_step) {
|
| 572 | 28x |
if (identical(active_step(), next_step)) {
|
| 573 | 2x |
return(invisible(next_step)) |
| 574 |
} |
|
| 575 | 26x |
active_step(next_step) |
| 576 | 26x |
shiny::updateTextInput(session, "step_signal", value = next_step) |
| 577 | 26x |
invisible(next_step) |
| 578 |
} |
|
| 579 | ||
| 580 | 11x |
shiny::observeEvent(input$data_file, |
| 581 |
{
|
|
| 582 | 5x |
upload_error(NULL) |
| 583 | 5x |
loaded_file(NULL) |
| 584 | 5x |
if (!upload_inputs_valid()) {
|
| 585 | ! |
if (!is.null(input$data_file)) {
|
| 586 | ! |
upload_error("Choose one readable CSV or Excel file.")
|
| 587 |
} |
|
| 588 | ! |
return() |
| 589 |
} |
|
| 590 | 5x |
file <- input$data_file |
| 591 | 5x |
loaded <- tryCatch( |
| 592 |
{
|
|
| 593 | 5x |
provider <- .uploaded_study_data_provider(file) |
| 594 | 5x |
snapshot <- pin_study_snapshot( |
| 595 | 5x |
provider, provider$manifest$scenarios[[1]]$dataset_id |
| 596 |
) |
|
| 597 | 5x |
snapshot$source_metadata <- provider$source_metadata |
| 598 | 5x |
data <- snapshot$data |
| 599 | 5x |
list( |
| 600 | 5x |
path = file$datapath[[1]], |
| 601 | 5x |
file_name = provider$source_metadata$file_name, |
| 602 | 5x |
rows = nrow(data), |
| 603 | 5x |
columns = ncol(data), |
| 604 | 5x |
provider = provider, |
| 605 | 5x |
snapshot = snapshot |
| 606 |
) |
|
| 607 |
}, |
|
| 608 | 5x |
error = function(error) {
|
| 609 | ! |
upload_error(trimws(cli::ansi_strip(conditionMessage(error)))) |
| 610 | ! |
NULL |
| 611 |
} |
|
| 612 |
) |
|
| 613 | 5x |
if (is.null(loaded)) {
|
| 614 | ! |
return() |
| 615 |
} |
|
| 616 | 5x |
loaded_file(loaded) |
| 617 | 5x |
current_revision(.empty_assurance_state()) |
| 618 |
}, |
|
| 619 | 11x |
ignoreInit = TRUE |
| 620 |
) |
|
| 621 | ||
| 622 | 11x |
shiny::observeEvent(input$to_ask, |
| 623 |
{
|
|
| 624 | 4x |
if (!is_current_upload()) {
|
| 625 | ! |
return() |
| 626 |
} |
|
| 627 | 4x |
set_step("ask")
|
| 628 |
}, |
|
| 629 | 11x |
ignoreInit = TRUE |
| 630 |
) |
|
| 631 | ||
| 632 | 11x |
shiny::observeEvent(input$to_confirm, |
| 633 |
{
|
|
| 634 | 8x |
if (!is_current_upload()) {
|
| 635 | ! |
return() |
| 636 |
} |
|
| 637 | 8x |
question_attempted(TRUE) |
| 638 | 8x |
if (!question_is_valid()) {
|
| 639 | ! |
return() |
| 640 |
} |
|
| 641 | 8x |
state <- current_revision() |
| 642 | 11x |
if ( |
| 643 | 8x |
is.null(state$pending) || !identical(state$prompt, input$question) |
| 644 |
) {
|
|
| 645 | 8x |
loaded <- loaded_file() |
| 646 | 8x |
state <- .create_assurance_candidate( |
| 647 | 8x |
provider = loaded$provider, |
| 648 | 8x |
dataset_id = loaded$snapshot$dataset_id, |
| 649 | 8x |
prompt = input$question |
| 650 |
) |
|
| 651 | 8x |
current_revision(state) |
| 652 |
} |
|
| 653 | 8x |
set_step("confirm")
|
| 654 |
}, |
|
| 655 | 11x |
ignoreInit = TRUE |
| 656 |
) |
|
| 657 | ||
| 658 | 11x |
shiny::observeEvent(input$back_to_data, set_step("data"), ignoreInit = TRUE)
|
| 659 | 11x |
shiny::observeEvent(input$back_to_ask, set_step("ask"), ignoreInit = TRUE)
|
| 660 | 11x |
shiny::observeEvent( |
| 661 | 11x |
input$back_to_result_from_confirm, set_step("result"),
|
| 662 | 11x |
ignoreInit = TRUE |
| 663 |
) |
|
| 664 | 11x |
shiny::observeEvent( |
| 665 | 11x |
input$back_to_confirm, set_step("confirm"),
|
| 666 | 11x |
ignoreInit = TRUE |
| 667 |
) |
|
| 668 | 11x |
shiny::observeEvent(input$to_export, set_step("export"), ignoreInit = TRUE)
|
| 669 | 11x |
shiny::observeEvent( |
| 670 | 11x |
input$back_to_result, set_step("result"),
|
| 671 | 11x |
ignoreInit = TRUE |
| 672 |
) |
|
| 673 | ||
| 674 | 11x |
run_revision <- function(fields, correction = FALSE, rationale = NULL, provenance = NULL) {
|
| 675 | 4x |
state <- current_revision() |
| 676 | 4x |
snapshot <- if (isTRUE(correction)) {
|
| 677 | ! |
state$snapshot |
| 678 |
} else {
|
|
| 679 | 4x |
state$pending$snapshot |
| 680 |
} |
|
| 681 | 4x |
can_run <- if (isTRUE(correction)) {
|
| 682 | ! |
.can_create_correction(state) |
| 683 |
} else {
|
|
| 684 | 4x |
!is.null(state$pending) |
| 685 |
} |
|
| 686 | 4x |
if (!can_run || is.null(snapshot)) {
|
| 687 | ! |
confirm_message(paste( |
| 688 | ! |
"Create a new question and confirm its plot choices", |
| 689 | ! |
"before execution." |
| 690 |
)) |
|
| 691 | ! |
return(invisible(NULL)) |
| 692 |
} |
|
| 693 | 4x |
spec <- tryCatch( |
| 694 | 4x |
.confirmed_spec_from_fields(snapshot$dataset_id, fields), |
| 695 | 4x |
error = function(error) {
|
| 696 | ! |
confirm_message(conditionMessage(error)) |
| 697 | ! |
NULL |
| 698 |
} |
|
| 699 |
) |
|
| 700 | 4x |
if (is.null(spec)) {
|
| 701 | ! |
return(invisible(NULL)) |
| 702 |
} |
|
| 703 | 4x |
profile <- validate_bds_profile( |
| 704 | 4x |
snapshot, |
| 705 | 4x |
spec$fields[setdiff(.plot_spec_fields, c("dataset_id", "scale_mode"))]
|
| 706 |
) |
|
| 707 | 4x |
if (profile$blocked) {
|
| 708 | ! |
diagnostic <- profile$blocking_diagnostics[[1]] |
| 709 | ! |
confirm_message(paste(diagnostic$code, diagnostic$message, sep = ": ")) |
| 710 | ! |
return(invisible(NULL)) |
| 711 |
} |
|
| 712 | 4x |
confirm_message(NULL) |
| 713 | 4x |
set_step("result")
|
| 714 | 4x |
tryCatch( |
| 715 | 4x |
if (isTRUE(correction)) {
|
| 716 | ! |
create_correction(fields, rationale, provenance) |
| 717 |
} else {
|
|
| 718 | 4x |
execute_revision(fields) |
| 719 |
}, |
|
| 720 | 4x |
error = function(error) {
|
| 721 | ! |
set_step("confirm")
|
| 722 | ! |
confirm_message(conditionMessage(error)) |
| 723 |
} |
|
| 724 |
) |
|
| 725 |
} |
|
| 726 | ||
| 727 | 11x |
execute_current <- function(fields) run_revision(fields) |
| 728 | 11x |
correct_current <- function(fields, rationale, provenance) {
|
| 729 | ! |
run_revision( |
| 730 | ! |
fields, |
| 731 | ! |
correction = TRUE, |
| 732 | ! |
rationale = rationale, |
| 733 | ! |
provenance = provenance |
| 734 |
) |
|
| 735 |
} |
|
| 736 | ||
| 737 | 11x |
mod_specification_server( |
| 738 | 11x |
"specification", |
| 739 | 11x |
current_revision, |
| 740 | 11x |
execute_current, |
| 741 | 11x |
correct_current |
| 742 |
) |
|
| 743 | 11x |
mod_run_status_server("run_status", current_revision)
|
| 744 | 11x |
mod_plot_preview_server("plot_preview", current_revision)
|
| 745 | 11x |
mod_evidence_server("evidence", current_revision)
|
| 746 | 11x |
mod_revision_history_server("revision_history", current_revision)
|
| 747 | 11x |
mod_export_server("export", current_revision, workspace_provider)
|
| 748 | 11x |
mod_review_server("review", current_revision, current_revision)
|
| 749 |
}) |
|
| 750 |
} |
|
| 751 | ||
| 752 |
.uploaded_study_data_provider <- function(file) {
|
|
| 753 | 5x |
if (!checkmate::test_data_frame(file, min.rows = 1L) || nrow(file) != 1L || |
| 754 | 5x |
!checkmate::test_string(file$name[[1]], min.chars = 1L) || |
| 755 | 5x |
!checkmate::test_file_exists(file$datapath[[1]], access = "r")) {
|
| 756 | ! |
cli::cli_abort("Choose one readable CSV or Excel file")
|
| 757 |
} |
|
| 758 | ||
| 759 | 5x |
file_name <- basename(file$name[[1]]) |
| 760 | 5x |
file_type <- tolower(tools::file_ext(file_name)) |
| 761 | 5x |
sheet_name <- NULL |
| 762 | 5x |
data <- if (identical(file_type, "csv")) {
|
| 763 | 5x |
utils::read.csv( |
| 764 | 5x |
file$datapath[[1]], |
| 765 | 5x |
check.names = FALSE, stringsAsFactors = FALSE |
| 766 |
) |
|
| 767 | 5x |
} else if (file_type %in% c("xls", "xlsx")) {
|
| 768 | ! |
sheets <- readxl::excel_sheets(file$datapath[[1]]) |
| 769 | ! |
if (!length(sheets)) {
|
| 770 | ! |
cli::cli_abort("The Excel workbook contains no worksheet")
|
| 771 |
} |
|
| 772 | ! |
sheet_name <- sheets[[1]] |
| 773 | ! |
as.data.frame( |
| 774 | ! |
readxl::read_excel( |
| 775 | ! |
file$datapath[[1]], |
| 776 | ! |
sheet = sheet_name, .name_repair = "minimal" |
| 777 |
), |
|
| 778 | ! |
optional = TRUE |
| 779 |
) |
|
| 780 |
} else {
|
|
| 781 | ! |
cli::cli_abort("Choose a CSV or Excel file")
|
| 782 |
} |
|
| 783 | ||
| 784 |
if ( |
|
| 785 | 5x |
!checkmate::test_data_frame(data, min.rows = 1L, min.cols = 1L) || |
| 786 | 5x |
anyNA(names(data)) || any(!nzchar(trimws(names(data)))) || |
| 787 | 5x |
anyDuplicated(names(data)) |
| 788 |
) {
|
|
| 789 | ! |
cli::cli_abort( |
| 790 | ! |
paste( |
| 791 | ! |
"The selected file must contain records and", |
| 792 | ! |
"unique, non-empty column names" |
| 793 |
) |
|
| 794 |
) |
|
| 795 |
} |
|
| 796 | 5x |
required <- c( |
| 797 | 5x |
"USUBJID", "PARAMCD", "PARAM", "AVISIT", "AVISITN", "AVAL", "AVALU" |
| 798 |
) |
|
| 799 | 5x |
missing <- setdiff(required, names(data)) |
| 800 | 5x |
if (length(missing)) {
|
| 801 | ! |
cli::cli_abort(paste( |
| 802 | ! |
"This file does not match the supported upload format. Upload", |
| 803 | ! |
"long-format clinical data", |
| 804 | ! |
"with one row per subject and visit.", |
| 805 | ! |
"Missing columns:", paste0(paste(missing, collapse = ", "), "."), |
| 806 | ! |
"If visits are separate columns (for example, Baseline, Week 4, and", |
| 807 | ! |
"Week 8), reshape them into rows, with visit labels in AVISIT, numeric", |
| 808 | ! |
"visit order in AVISITN, and measurements in AVAL. Include PARAMCD", |
| 809 | ! |
"(parameter code), PARAM (name),", |
| 810 | ! |
"and AVALU (unit) as columns too." |
| 811 |
)) |
|
| 812 |
} |
|
| 813 | 5x |
treatment_variables <- grep( |
| 814 | 5x |
"^TRT(A|P|[0-9]+[AP])$", names(data), |
| 815 | 5x |
value = TRUE |
| 816 |
) |
|
| 817 | 5x |
if (!length(treatment_variables)) {
|
| 818 | ! |
cli::cli_abort( |
| 819 | ! |
paste( |
| 820 | ! |
"The selected worksheet needs a supported planned or actual", |
| 821 | ! |
"treatment variable" |
| 822 |
) |
|
| 823 |
) |
|
| 824 |
} |
|
| 825 | 5x |
if (!is.numeric(data$AVAL) || !is.numeric(data$AVISITN)) {
|
| 826 | ! |
cli::cli_abort("AVAL and AVISITN must be numeric in the selected worksheet")
|
| 827 |
} |
|
| 828 | 5x |
if (!any(!is.na(data$PARAMCD) & nzchar(as.character(data$PARAMCD))) || |
| 829 | 5x |
!any(!is.na(data$PARAM) & nzchar(as.character(data$PARAM))) || |
| 830 | 5x |
!any(!is.na(data$AVALU) & nzchar(as.character(data$AVALU))) || |
| 831 | 5x |
!any( |
| 832 | 5x |
!is.na(data$AVISIT) & nzchar(as.character(data$AVISIT)) & |
| 833 | 5x |
is.finite(data$AVISITN) |
| 834 |
) || |
|
| 835 | 5x |
!any(vapply(treatment_variables, function(variable) {
|
| 836 | 5x |
any(!is.na(data[[variable]]) & nzchar(as.character(data[[variable]]))) |
| 837 | 5x |
}, logical(1)))) {
|
| 838 | ! |
cli::cli_abort( |
| 839 | ! |
paste( |
| 840 | ! |
"The selected worksheet must contain selectable parameters, units,", |
| 841 | ! |
"visits, and treatment levels" |
| 842 |
) |
|
| 843 |
) |
|
| 844 |
} |
|
| 845 | ||
| 846 | 5x |
content_hash <- study_data_content_hash(data) |
| 847 | 5x |
source_hash <- digest::digest(file = file$datapath[[1]], algo = "sha256") |
| 848 | 5x |
snapshot_key <- canonical_hash(list( |
| 849 | 5x |
content_hash = content_hash, |
| 850 | 5x |
source_file_hash = source_hash |
| 851 |
)) |
|
| 852 | 5x |
source_metadata <- list( |
| 853 | 5x |
source = "direct_upload", |
| 854 | 5x |
file_name = file_name, |
| 855 | 5x |
file_type = file_type, |
| 856 | 5x |
sheet_name = sheet_name, |
| 857 | 5x |
source_file_hash = source_hash |
| 858 |
) |
|
| 859 | 5x |
dataset_id <- paste0("upload-", substr(snapshot_key, 1L, 16L))
|
| 860 | 5x |
metadata <- list( |
| 861 | 5x |
dataset_id = dataset_id, |
| 862 | 5x |
adam_designation = "BDS", |
| 863 | 5x |
declared_keys = as.list(c("USUBJID", "PARAMCD", "AVISIT")),
|
| 864 | 5x |
metadata_version = "uploaded-bds-v1", |
| 865 | 5x |
content_hash = content_hash, |
| 866 | 5x |
snapshot_id = paste0("snapshot-", substr(snapshot_key, 1L, 16L)),
|
| 867 | 5x |
permitted_treatment_variables = as.list(treatment_variables) |
| 868 |
) |
|
| 869 | 5x |
structure( |
| 870 | 5x |
list( |
| 871 | 5x |
manifest = list(scenarios = list(metadata)), |
| 872 | 5x |
read_data = \(metadata) data, |
| 873 | 5x |
source_metadata = source_metadata |
| 874 |
), |
|
| 875 | 5x |
class = "study_data_provider" |
| 876 |
) |
|
| 877 |
} |
| 1 |
.standalone_boxplot_stats <- function( |
|
| 2 |
data, |
|
| 3 |
treatment_variable, |
|
| 4 |
y_variable, |
|
| 5 |
facet_levels, |
|
| 6 |
visit_levels, |
|
| 7 |
low_n_policy |
|
| 8 |
) {
|
|
| 9 | 20x |
required_columns <- c( |
| 10 | 20x |
"USUBJID", "AVISIT", "AVISITN", treatment_variable, y_variable |
| 11 |
) |
|
| 12 | 20x |
if (!is.data.frame(data) || length(setdiff(required_columns, names(data)))) {
|
| 13 | ! |
cli::cli_abort(paste0( |
| 14 | ! |
"analysis_data must contain USUBJID, AVISIT, AVISITN, ", |
| 15 | ! |
"treatment, and Y columns" |
| 16 |
)) |
|
| 17 |
} |
|
| 18 | 20x |
if (!is.numeric(data[[y_variable]])) {
|
| 19 | ! |
cli::cli_abort("The selected Y variable must be numeric")
|
| 20 |
} |
|
| 21 | 20x |
if (!is.character(facet_levels) || !length(facet_levels) || |
| 22 | 20x |
anyDuplicated(facet_levels) || |
| 23 | 20x |
!is.character(visit_levels) || !length(visit_levels) || |
| 24 | 20x |
anyDuplicated(visit_levels)) {
|
| 25 | ! |
cli::cli_abort(paste0( |
| 26 | ! |
"Facet and visit levels must be ordered, ", |
| 27 | ! |
"unique character vectors" |
| 28 |
)) |
|
| 29 |
} |
|
| 30 | 20x |
if (!is.list(low_n_policy) || |
| 31 | 20x |
!identical( |
| 32 | 20x |
sort(names(low_n_policy)), |
| 33 | 20x |
sort(c("value", "rationale", "authority", "version"))
|
| 34 |
) || |
|
| 35 | 20x |
!is.numeric(low_n_policy$value) || length(low_n_policy$value) != 1L || |
| 36 | 20x |
!is.finite(low_n_policy$value) || low_n_policy$value < 1) {
|
| 37 | ! |
cli::cli_abort(paste0( |
| 38 | ! |
"low_n_policy must retain value, rationale, ", |
| 39 | ! |
"authority, and version" |
| 40 |
)) |
|
| 41 |
} |
|
| 42 | 20x |
if (!all(vapply( |
| 43 | 20x |
low_n_policy[c("rationale", "authority", "version")],
|
| 44 | 20x |
function(value) {
|
| 45 | 60x |
is.character(value) && length(value) == 1L && nzchar(value) |
| 46 |
}, |
|
| 47 | 20x |
logical(1) |
| 48 |
))) {
|
|
| 49 | ! |
cli::cli_abort("low_n_policy text fields must be non-empty strings")
|
| 50 |
} |
|
| 51 | 20x |
type7_quantile <- function(values, probability) {
|
| 52 | 354x |
position <- (length(values) - 1) * probability + 1 |
| 53 | 354x |
lower_index <- floor(position) |
| 54 | 354x |
fraction <- position - lower_index |
| 55 | 354x |
if (fraction == 0 || lower_index == length(values)) {
|
| 56 | 158x |
return(values[[lower_index]]) |
| 57 |
} |
|
| 58 | 196x |
values[[lower_index]] + fraction * ( |
| 59 | 196x |
values[[lower_index + 1L]] - values[[lower_index]] |
| 60 |
) |
|
| 61 |
} |
|
| 62 | 20x |
summarize_box <- function(rows) {
|
| 63 | 118x |
values <- rows[[y_variable]] |
| 64 | 118x |
sorted_values <- sort(values, method = "radix") |
| 65 | 118x |
lower <- type7_quantile(sorted_values, 0.25) |
| 66 | 118x |
middle <- type7_quantile(sorted_values, 0.5) |
| 67 | 118x |
upper <- type7_quantile(sorted_values, 0.75) |
| 68 | 118x |
interquartile_range <- upper - lower |
| 69 | 118x |
inside <- values >= lower - 1.5 * interquartile_range & |
| 70 | 118x |
values <= upper + 1.5 * interquartile_range |
| 71 | 118x |
distinct_n <- length(unique(as.character(rows$USUBJID))) |
| 72 | 118x |
list( |
| 73 | 118x |
box = data.frame( |
| 74 | 118x |
treatment = as.character(rows[[treatment_variable]][[1]]), |
| 75 | 118x |
visit = as.character(rows$AVISIT[[1]]), |
| 76 | 118x |
visit_n = rows$AVISITN[[1]], |
| 77 | 118x |
n = as.integer(distinct_n), |
| 78 | 118x |
ymin = min(values[inside]), |
| 79 | 118x |
lower = lower, |
| 80 | 118x |
middle = middle, |
| 81 | 118x |
upper = upper, |
| 82 | 118x |
ymax = max(values[inside]), |
| 83 | 118x |
low_n = distinct_n < low_n_policy$value, |
| 84 | 118x |
stringsAsFactors = FALSE |
| 85 |
), |
|
| 86 | 118x |
outliers = data.frame( |
| 87 | 118x |
treatment = as.character(rows[[treatment_variable]][!inside]), |
| 88 | 118x |
visit = as.character(rows$AVISIT[!inside]), |
| 89 | 118x |
visit_n = rows$AVISITN[!inside], |
| 90 | 118x |
USUBJID = as.character(rows$USUBJID[!inside]), |
| 91 | 118x |
value = values[!inside], |
| 92 | 118x |
stringsAsFactors = FALSE |
| 93 |
) |
|
| 94 |
) |
|
| 95 |
} |
|
| 96 | 20x |
empty_outliers <- function() {
|
| 97 | 20x |
data.frame( |
| 98 | 20x |
treatment = character(), visit = character(), visit_n = numeric(), |
| 99 | 20x |
USUBJID = character(), value = numeric(), stringsAsFactors = FALSE |
| 100 |
) |
|
| 101 |
} |
|
| 102 | 20x |
low_n_policy$value <- as.numeric(low_n_policy$value) |
| 103 | 20x |
display <- data[ |
| 104 | 20x |
!is.na(data[[y_variable]]) & |
| 105 | 20x |
!is.na(data[[treatment_variable]]) & |
| 106 | 20x |
data[[treatment_variable]] %in% facet_levels & |
| 107 | 20x |
!is.na(data$AVISIT) & data$AVISIT %in% visit_levels, , |
| 108 | 20x |
drop = FALSE |
| 109 |
] |
|
| 110 | 20x |
if (any(!is.finite(display[[y_variable]]))) {
|
| 111 | ! |
cli::cli_abort("The selected Y variable must contain only finite values")
|
| 112 |
} |
|
| 113 | 20x |
if (nrow(display)) {
|
| 114 | 20x |
ordering <- order( |
| 115 | 20x |
match(as.character(display[[treatment_variable]]), facet_levels), |
| 116 | 20x |
match(as.character(display$AVISIT), visit_levels), |
| 117 | 20x |
as.character(display$USUBJID), |
| 118 | 20x |
method = "radix" |
| 119 |
) |
|
| 120 | 20x |
display <- display[ordering, , drop = FALSE] |
| 121 | 20x |
row.names(display) <- NULL |
| 122 | 20x |
group_start <- c( |
| 123 | 20x |
TRUE, |
| 124 | 20x |
as.character(display[[treatment_variable]][-1L]) != |
| 125 | 20x |
as.character(display[[treatment_variable]][-nrow(display)]) | |
| 126 | 20x |
as.character(display$AVISIT[-1L]) != |
| 127 | 20x |
as.character(display$AVISIT[-nrow(display)]) |
| 128 |
) |
|
| 129 | 20x |
summaries <- lapply( |
| 130 | 20x |
split(seq_len(nrow(display)), cumsum(group_start)), |
| 131 | 20x |
function(index) {
|
| 132 | 118x |
summarize_box(display[index, , drop = FALSE]) |
| 133 |
} |
|
| 134 |
) |
|
| 135 | 20x |
boxes <- do.call(rbind, lapply(summaries, `[[`, "box")) |
| 136 | 20x |
outlier_rows <- lapply(summaries, `[[`, "outliers") |
| 137 | 20x |
outliers <- if (sum(vapply(outlier_rows, nrow, integer(1))) > 0L) {
|
| 138 | ! |
do.call(rbind, outlier_rows) |
| 139 |
} else {
|
|
| 140 | 20x |
empty_outliers() |
| 141 |
} |
|
| 142 | 20x |
box_order <- order( |
| 143 | 20x |
match(boxes$treatment, facet_levels), |
| 144 | 20x |
match(boxes$visit, visit_levels), |
| 145 | 20x |
method = "radix" |
| 146 |
) |
|
| 147 | 20x |
boxes <- boxes[box_order, , drop = FALSE] |
| 148 | 20x |
row.names(boxes) <- NULL |
| 149 | 20x |
if (nrow(outliers)) {
|
| 150 | ! |
outlier_order <- order( |
| 151 | ! |
match(outliers$treatment, facet_levels), |
| 152 | ! |
match(outliers$visit, visit_levels), |
| 153 | ! |
outliers$value, |
| 154 | ! |
outliers$USUBJID, |
| 155 | ! |
method = "radix" |
| 156 |
) |
|
| 157 | ! |
outliers <- outliers[outlier_order, , drop = FALSE] |
| 158 | ! |
row.names(outliers) <- NULL |
| 159 |
} |
|
| 160 |
} else {
|
|
| 161 | ! |
boxes <- data.frame( |
| 162 | ! |
treatment = character(), visit = character(), visit_n = numeric(), |
| 163 | ! |
n = integer(), ymin = numeric(), lower = numeric(), middle = numeric(), |
| 164 | ! |
upper = numeric(), ymax = numeric(), low_n = logical(), |
| 165 | ! |
stringsAsFactors = FALSE |
| 166 |
) |
|
| 167 | ! |
outliers <- empty_outliers() |
| 168 |
} |
|
| 169 | 20x |
medians <- boxes[c("treatment", "visit", "visit_n", "middle")]
|
| 170 | 20x |
names(medians)[[4]] <- "median" |
| 171 | 20x |
n_strip <- boxes[c("treatment", "visit", "visit_n", "n", "low_n")]
|
| 172 | 20x |
n_strip$label <- ifelse( |
| 173 | 20x |
n_strip$low_n, |
| 174 | 20x |
paste0("N = ", n_strip$n, " - Low N"),
|
| 175 | 20x |
paste0("N = ", n_strip$n)
|
| 176 |
) |
|
| 177 | 20x |
structure( |
| 178 | 20x |
list( |
| 179 | 20x |
analysis_version = "boxplot-analysis-v1", |
| 180 | 20x |
treatment_variable = treatment_variable, |
| 181 | 20x |
y_variable = y_variable, |
| 182 | 20x |
facet_levels = facet_levels, |
| 183 | 20x |
visit_levels = visit_levels, |
| 184 | 20x |
low_n_policy = low_n_policy, |
| 185 | 20x |
boxes = boxes, |
| 186 | 20x |
outliers = outliers, |
| 187 | 20x |
medians = medians, |
| 188 | 20x |
n_strip = n_strip |
| 189 |
), |
|
| 190 | 20x |
class = "boxplot_analysis" |
| 191 |
) |
|
| 192 |
} |
|
| 193 | ||
| 194 |
.standalone_assemble_boxplot <- function( |
|
| 195 |
analysis, |
|
| 196 |
scale_mode = c("fixed", "free"),
|
|
| 197 |
unit = NULL |
|
| 198 |
) {
|
|
| 199 | 20x |
if (!inherits(analysis, "boxplot_analysis")) {
|
| 200 | ! |
cli::cli_abort("analysis must be a boxplot_analysis object")
|
| 201 |
} |
|
| 202 | 20x |
scale_mode <- match.arg(scale_mode) |
| 203 | 20x |
plot_data <- analysis[c("boxes", "outliers", "medians", "n_strip")]
|
| 204 | 20x |
plot_data <- lapply(plot_data, function(data) {
|
| 205 | 80x |
data$treatment <- factor(data$treatment, levels = analysis$facet_levels) |
| 206 | 80x |
data$visit <- factor(data$visit, levels = analysis$visit_levels) |
| 207 | 80x |
data |
| 208 |
}) |
|
| 209 | 20x |
y_label <- if (is.null(unit) || !nzchar(unit)) {
|
| 210 | ! |
analysis$y_variable |
| 211 |
} else {
|
|
| 212 | 20x |
paste0(analysis$y_variable, " (", unit, ")")
|
| 213 |
} |
|
| 214 | 20x |
facet_scales <- if (identical(scale_mode, "free")) "free_y" else "fixed" |
| 215 | 20x |
plot_theme <- ggplot2::theme_bw(base_size = 12) + |
| 216 | 20x |
ggplot2::theme( |
| 217 | 20x |
panel.border = ggplot2::element_rect( |
| 218 | 20x |
color = "black", fill = NA, linewidth = 1 |
| 219 |
), |
|
| 220 | 20x |
panel.grid.major = ggplot2::element_line( |
| 221 | 20x |
color = "grey90", linewidth = 0.3 |
| 222 |
), |
|
| 223 | 20x |
panel.grid.minor = ggplot2::element_blank(), |
| 224 | 20x |
text = ggplot2::element_text(face = "bold"), |
| 225 | 20x |
axis.text = ggplot2::element_text(face = "bold", color = "black"), |
| 226 | 20x |
axis.text.x = ggplot2::element_blank(), |
| 227 | 20x |
axis.ticks.x = ggplot2::element_blank(), |
| 228 | 20x |
strip.background = ggplot2::element_rect( |
| 229 | 20x |
fill = "grey85", color = "black", linewidth = 1 |
| 230 |
), |
|
| 231 | 20x |
strip.text = ggplot2::element_text(face = "bold", color = "black") |
| 232 |
) |
|
| 233 | 20x |
plot <- ggplot2::ggplot( |
| 234 | 20x |
plot_data$boxes, |
| 235 | 20x |
ggplot2::aes( |
| 236 | 20x |
x = visit, ymin = ymin, lower = lower, middle = middle, |
| 237 | 20x |
upper = upper, ymax = ymax |
| 238 |
) |
|
| 239 |
) + |
|
| 240 | 20x |
ggplot2::geom_boxplot( |
| 241 | 20x |
stat = "identity", outlier.shape = NA, fill = "white", |
| 242 | 20x |
color = "black", linewidth = 0.6 |
| 243 |
) + |
|
| 244 | 20x |
ggplot2::geom_line( |
| 245 | 20x |
data = plot_data$medians, |
| 246 | 20x |
mapping = ggplot2::aes(x = visit, y = median, group = treatment), |
| 247 | 20x |
inherit.aes = FALSE, |
| 248 | 20x |
linewidth = 0.8, |
| 249 | 20x |
color = "black" |
| 250 |
) + |
|
| 251 | 20x |
ggplot2::geom_point( |
| 252 | 20x |
data = plot_data$medians, |
| 253 | 20x |
mapping = ggplot2::aes(x = visit, y = median), |
| 254 | 20x |
inherit.aes = FALSE, |
| 255 | 20x |
shape = 21, |
| 256 | 20x |
size = 3, |
| 257 | 20x |
fill = "white", |
| 258 | 20x |
color = "black", |
| 259 | 20x |
stroke = 1.2 |
| 260 |
) + |
|
| 261 | 20x |
ggplot2::geom_point( |
| 262 | 20x |
data = plot_data$outliers, |
| 263 | 20x |
mapping = ggplot2::aes(x = visit, y = value), |
| 264 | 20x |
inherit.aes = FALSE, |
| 265 | 20x |
shape = 1, |
| 266 | 20x |
size = 2.5, |
| 267 | 20x |
color = "black", |
| 268 | 20x |
stroke = 1 |
| 269 |
) + |
|
| 270 | 20x |
ggplot2::facet_wrap( |
| 271 | 20x |
ggplot2::vars(treatment), |
| 272 | 20x |
scales = facet_scales, drop = FALSE |
| 273 |
) + |
|
| 274 | 20x |
ggplot2::scale_x_discrete(drop = FALSE, limits = analysis$visit_levels) + |
| 275 | 20x |
ggplot2::labs(x = NULL, y = y_label) + |
| 276 | 20x |
plot_theme |
| 277 | 20x |
n_strip <- ggplot2::ggplot( |
| 278 | 20x |
plot_data$n_strip, |
| 279 | 20x |
ggplot2::aes(x = visit, y = 1, label = label) |
| 280 |
) + |
|
| 281 | 20x |
ggplot2::geom_text( |
| 282 | 20x |
na.rm = TRUE, size = 4, color = "black", fontface = "bold" |
| 283 |
) + |
|
| 284 | 20x |
ggplot2::facet_wrap(ggplot2::vars(treatment), drop = FALSE) + |
| 285 | 20x |
ggplot2::scale_x_discrete(drop = FALSE, limits = analysis$visit_levels) + |
| 286 | 20x |
ggplot2::scale_y_continuous(limits = c(0.5, 1.5)) + |
| 287 | 20x |
ggplot2::labs(x = "Analysis Visit", y = NULL) + |
| 288 | 20x |
ggplot2::theme_bw(base_size = 12) + |
| 289 | 20x |
ggplot2::theme( |
| 290 | 20x |
panel.border = ggplot2::element_rect( |
| 291 | 20x |
color = "black", fill = NA, linewidth = 1 |
| 292 |
), |
|
| 293 | 20x |
text = ggplot2::element_text(face = "bold"), |
| 294 | 20x |
axis.text = ggplot2::element_text(face = "bold", color = "black"), |
| 295 | 20x |
axis.text.y = ggplot2::element_blank(), |
| 296 | 20x |
axis.ticks.y = ggplot2::element_blank(), |
| 297 | 20x |
panel.grid = ggplot2::element_blank(), |
| 298 | 20x |
strip.text = ggplot2::element_blank(), |
| 299 | 20x |
strip.background = ggplot2::element_rect( |
| 300 | 20x |
fill = "grey85", color = "black", linewidth = 1 |
| 301 |
) |
|
| 302 |
) |
|
| 303 | 20x |
combined <- patchwork::wrap_plots( |
| 304 | 20x |
plot, |
| 305 | 20x |
n_strip, |
| 306 | 20x |
ncol = 1L, |
| 307 | 20x |
heights = c(4, 1) |
| 308 |
) |
|
| 309 | 20x |
structure( |
| 310 | 20x |
list( |
| 311 | 20x |
artifact_version = "boxplot-artifact-v1", |
| 312 | 20x |
status = if (identical(scale_mode, "free")) {
|
| 313 | ! |
"Experimental/Draft" |
| 314 |
} else {
|
|
| 315 | 20x |
"Draft" |
| 316 |
}, |
|
| 317 | 20x |
warning = if (identical(scale_mode, "free")) {
|
| 318 | ! |
"Free Y scales weaken cross-facet visual comparison." |
| 319 |
} else {
|
|
| 320 | 20x |
NULL |
| 321 |
}, |
|
| 322 | 20x |
scale_mode = scale_mode, |
| 323 | 20x |
analysis = analysis, |
| 324 | 20x |
plot_data = plot_data, |
| 325 | 20x |
plot = plot, |
| 326 | 20x |
n_strip = n_strip, |
| 327 | 20x |
combined = combined |
| 328 |
), |
|
| 329 | 20x |
class = "boxplot_artifact" |
| 330 |
) |
|
| 331 |
} |
|
| 332 | ||
| 333 |
.standalone_function_text <- function(name, fn) {
|
|
| 334 | ! |
paste0(name, " <- ", paste(deparse(fn), collapse = "\n")) |
| 335 |
} |
| 1 |
new_lifecycle_service <- function(repository) {
|
|
| 2 | 6x |
.validate_evidence_repository( |
| 3 | 6x |
repository, |
| 4 | 6x |
c("get_revision", "list_revisions", "create_revision")
|
| 5 |
) |
|
| 6 | ||
| 7 | 6x |
create_correction <- function( |
| 8 | 6x |
parent_revision_id, revision_id, creator_id, spec_hash, |
| 9 | 6x |
code_hash, image_hash, analytical_hash, rationale, |
| 10 | 6x |
provenance, idempotency_key, initial_status = "Draft" |
| 11 |
) {
|
|
| 12 | 7x |
if (!checkmate::test_string(rationale, min.chars = 1)) {
|
| 13 | 1x |
cli::cli_abort("Correction rationale is required")
|
| 14 |
} |
|
| 15 | 6x |
if (!checkmate::test_string(provenance, min.chars = 1)) {
|
| 16 | ! |
cli::cli_abort("Correction provenance is required")
|
| 17 |
} |
|
| 18 | 6x |
parent <- repository$get_revision(parent_revision_id) |
| 19 | 6x |
if (!parent$status %in% c("Rejected", "Reviewed")) {
|
| 20 | 2x |
cli::cli_abort("Only a closed revision can have a correction successor")
|
| 21 |
} |
|
| 22 | 4x |
revisions <- repository$list_revisions(parent$plot_id) |
| 23 | 4x |
repository$create_revision( |
| 24 | 4x |
parent$plot_id, revision_id, max(revisions$revision_number) + 1L, |
| 25 | 4x |
creator_id, spec_hash, code_hash, image_hash, analytical_hash, |
| 26 | 4x |
idempotency_key, |
| 27 | 4x |
parent_revision_id = parent_revision_id, correction_rationale = rationale, |
| 28 | 4x |
correction_provenance = provenance, initial_status = initial_status |
| 29 |
) |
|
| 30 | 4x |
invisible(repository$get_revision(revision_id)) |
| 31 |
} |
|
| 32 | 6x |
structure( |
| 33 | 6x |
list( |
| 34 | 6x |
create_correction = create_correction |
| 35 |
), |
|
| 36 | 6x |
class = "lifecycle_service" |
| 37 |
) |
|
| 38 |
} |
| 1 |
local_identity_provider <- function(actors) {
|
|
| 2 | 51x |
if (!is.list(actors) || !all(vapply( |
| 3 | 51x |
actors, inherits, logical(1), "submission_sync_actor" |
| 4 |
))) {
|
|
| 5 | ! |
cli::cli_abort("Local identities must be submission sync actors")
|
| 6 |
} |
|
| 7 | 51x |
ids <- vapply(actors, `[[`, character(1), "id") |
| 8 | 51x |
if (anyDuplicated(ids)) {
|
| 9 | ! |
cli::cli_abort("Local identity identifiers must be unique")
|
| 10 |
} |
|
| 11 | ||
| 12 | 51x |
get_actor <- function(actor_id) {
|
| 13 | 93x |
if (!checkmate::test_string(actor_id, min.chars = 1)) {
|
| 14 | ! |
cli::cli_abort("Actor identifier is required")
|
| 15 |
} |
|
| 16 | 93x |
match <- which(ids == actor_id) |
| 17 | 93x |
if (!length(match)) {
|
| 18 | ! |
cli::cli_abort("Authenticated actor {.val {actor_id}} was not found")
|
| 19 |
} |
|
| 20 | 93x |
actors[[match]] |
| 21 |
} |
|
| 22 | 51x |
structure(list(get_actor = get_actor), class = "local_identity_provider") |
| 23 |
} |
| 1 |
.verification_checks <- function( |
|
| 2 |
request, script, analysis_data, runner_result |
|
| 3 |
) {
|
|
| 4 | 19x |
result <- runner_result$result |
| 5 | 19x |
checks <- list( |
| 6 | 19x |
schema = inherits(request, "execution_request"), |
| 7 | 19x |
script_hash = identical(.execution_hash_text(script), request$script_hash), |
| 8 | 19x |
snapshot_hash = identical( |
| 9 | 19x |
canonical_hash(analysis_data), request$snapshot_hash |
| 10 |
), |
|
| 11 | 19x |
runner_success = identical(runner_result$status, "succeeded"), |
| 12 | 19x |
runner_manifest = identical( |
| 13 | 19x |
runner_result$manifest_version, |
| 14 | 19x |
"execution-runner-result-v1" |
| 15 |
), |
|
| 16 | 19x |
result_manifest = inherits(result, "execution_result") && |
| 17 | 19x |
identical(result$manifest_version, "execution-result-v1"), |
| 18 | 19x |
request_binding = FALSE, |
| 19 | 19x |
analytical_hash = FALSE, |
| 20 | 19x |
image_hash = FALSE, |
| 21 | 19x |
environment_fingerprint = FALSE |
| 22 |
) |
|
| 23 | 19x |
if (isTRUE(checks$result_manifest)) {
|
| 24 | 16x |
request_fields <- setdiff(names(unclass(request)), "manifest_version") |
| 25 | 16x |
checks$request_binding <- identical( |
| 26 | 16x |
unclass(result)[request_fields], |
| 27 | 16x |
unclass(request)[request_fields] |
| 28 |
) |
|
| 29 | 16x |
checks$analytical_hash <- identical( |
| 30 | 16x |
result$analytical_hash, |
| 31 | 16x |
canonical_hash(runner_result$analytical_output) |
| 32 |
) |
|
| 33 | 16x |
image_exists <- checkmate::test_string( |
| 34 | 16x |
runner_result$image_path, |
| 35 | 16x |
min.chars = 1L |
| 36 | 16x |
) && fs::file_exists(runner_result$image_path) && |
| 37 | 16x |
fs::file_size(runner_result$image_path) > 0 |
| 38 | 16x |
checks$image_hash <- image_exists && |
| 39 | 16x |
checkmate::test_string(result$image_hash, pattern = "^[0-9a-f]{64}$") &&
|
| 40 | 16x |
identical( |
| 41 | 16x |
.execution_hash_file(runner_result$image_path), |
| 42 | 16x |
result$image_hash |
| 43 |
) |
|
| 44 | 16x |
checks$environment_fingerprint <- checkmate::test_string( |
| 45 | 16x |
result$environment_fingerprint, |
| 46 | 16x |
pattern = "^[0-9a-f]{64}$"
|
| 47 |
) |
|
| 48 |
} |
|
| 49 | 19x |
checks |
| 50 |
} |
|
| 51 | ||
| 52 |
verify_execution_result <- function( |
|
| 53 |
request, script, analysis_data, runner_result |
|
| 54 |
) {
|
|
| 55 | 19x |
checks <- .verification_checks(request, script, analysis_data, runner_result) |
| 56 | 19x |
failed <- names(checks)[!unlist(checks, use.names = FALSE)] |
| 57 | 19x |
structure( |
| 58 | 19x |
list( |
| 59 | 19x |
verification_version = "execution-verification-v1", |
| 60 | 19x |
status = if (length(failed)) "failed" else "passed", |
| 61 | 19x |
checks = checks, |
| 62 | 19x |
failed_checks = failed |
| 63 |
), |
|
| 64 | 19x |
class = "execution_verification" |
| 65 |
) |
|
| 66 |
} |
|
| 67 | ||
| 68 |
new_verification_service <- function() {
|
|
| 69 | 12x |
structure( |
| 70 | 12x |
list(verify = verify_execution_result), |
| 71 | 12x |
class = "verification_service" |
| 72 |
) |
|
| 73 |
} |
| 1 |
#' Run the Shiny Application |
|
| 2 |
#' |
|
| 3 |
#' @param ... arguments to pass to golem_opts. |
|
| 4 |
#' See `?golem::get_golem_options` for more details. |
|
| 5 |
#' @param runtime_config Injected runtime configuration. |
|
| 6 |
#' @inheritParams shiny::shinyApp |
|
| 7 |
#' |
|
| 8 |
#' @export |
|
| 9 |
#' @importFrom shiny shinyApp |
|
| 10 |
#' @importFrom golem with_golem_options |
|
| 11 |
run_app <- function( |
|
| 12 |
onStart = NULL, # nolint: object_name_linter |
|
| 13 |
options = list(), |
|
| 14 |
enableBookmarking = NULL, # nolint: object_name_linter |
|
| 15 |
uiPattern = "/", # nolint: object_name_linter |
|
| 16 |
runtime_config = new_runtime_config(), |
|
| 17 |
... |
|
| 18 |
) {
|
|
| 19 | ! |
with_golem_options( |
| 20 | ! |
app = shinyApp( |
| 21 | ! |
ui = function(request) app_ui(request, runtime_config = runtime_config), |
| 22 | ! |
server = function(input, output, session) {
|
| 23 | ! |
app_server(input, output, session, runtime_config = runtime_config) |
| 24 |
}, |
|
| 25 | ! |
onStart = onStart, |
| 26 | ! |
options = options, |
| 27 | ! |
enableBookmarking = enableBookmarking, |
| 28 | ! |
uiPattern = uiPattern |
| 29 |
), |
|
| 30 | ! |
golem_opts = list(...) |
| 31 |
) |
|
| 32 |
} |
| 1 |
mod_review_ui <- function(id) {
|
|
| 2 | 6x |
ns <- shiny::NS(id) |
| 3 | 6x |
bslib::card( |
| 4 | 6x |
class = "assurance-panel", |
| 5 | 6x |
bslib::card_header("Two-Person Review"),
|
| 6 | 6x |
bslib::card_body( |
| 7 | 6x |
shiny::selectInput( |
| 8 | 6x |
ns("actor_id"),
|
| 9 | 6x |
"Reviewer identity", |
| 10 | 6x |
choices = c( |
| 11 | 6x |
"Statistical programmer" = "stat-programmer", |
| 12 | 6x |
"Biostatistician" = "biostatistician", |
| 13 | 6x |
"Creator" = "creator" |
| 14 |
) |
|
| 15 |
), |
|
| 16 | 6x |
shiny::selectInput( |
| 17 | 6x |
ns("role"),
|
| 18 | 6x |
"Review role", |
| 19 | 6x |
choices = c( |
| 20 | 6x |
"Statistical programmer" = "statistical_programmer", |
| 21 | 6x |
"Biostatistician" = "biostatistician" |
| 22 |
) |
|
| 23 |
), |
|
| 24 | 6x |
shiny::textAreaInput( |
| 25 | 6x |
ns("comment"), "Comment or rejection rationale",
|
| 26 | 6x |
rows = 3 |
| 27 |
), |
|
| 28 | 6x |
div( |
| 29 | 6x |
class = "review-actions", |
| 30 | 6x |
shiny::actionButton(ns("approve"), "Approve", class = "btn-success"),
|
| 31 | 6x |
shiny::actionButton( |
| 32 | 6x |
ns("reject"), "Reject",
|
| 33 | 6x |
class = "btn-outline-danger" |
| 34 |
) |
|
| 35 |
), |
|
| 36 | 6x |
shiny::uiOutput(ns("message")),
|
| 37 | 6x |
shiny::tableOutput(ns("decisions"))
|
| 38 |
) |
|
| 39 |
) |
|
| 40 |
} |
|
| 41 | ||
| 42 |
mod_review_server <- function(id, current_revision, set_current_revision) {
|
|
| 43 | 12x |
shiny::moduleServer(id, function(input, output, session) {
|
| 44 | 12x |
message <- shiny::reactiveVal(NULL) |
| 45 | ||
| 46 | 12x |
output$message <- shiny::renderUI({
|
| 47 | 13x |
text <- message() |
| 48 | 13x |
if (is.null(text)) {
|
| 49 | 7x |
return(NULL) |
| 50 |
} |
|
| 51 | 6x |
div(class = "assurance-message", role = "status", text) |
| 52 |
}) |
|
| 53 | ||
| 54 | 12x |
decide <- function(decision) {
|
| 55 | 9x |
state <- current_revision() |
| 56 | 9x |
if (is.null(state$review_service) || is.null(state$revision)) {
|
| 57 | ! |
cli::cli_abort("Create and verify a revision before review")
|
| 58 |
} |
|
| 59 | 9x |
current <- state$repository$get_revision(state$revision$revision_id) |
| 60 | 9x |
key <- paste( |
| 61 | 9x |
decision, |
| 62 | 9x |
current$revision_id, |
| 63 | 9x |
input$actor_id, |
| 64 | 9x |
current$version, |
| 65 | 9x |
sep = ":" |
| 66 |
) |
|
| 67 | 9x |
if (identical(decision, "approved")) {
|
| 68 | 8x |
revision <- state$review_service$approve( |
| 69 | 8x |
current$revision_id, |
| 70 | 8x |
input$actor_id, |
| 71 | 8x |
as.integer(current$version), |
| 72 | 8x |
key, |
| 73 | 8x |
comment = input$comment, |
| 74 | 8x |
role = input$role |
| 75 |
) |
|
| 76 |
} else {
|
|
| 77 | 1x |
revision <- state$review_service$reject( |
| 78 | 1x |
current$revision_id, |
| 79 | 1x |
input$actor_id, |
| 80 | 1x |
as.integer(current$version), |
| 81 | 1x |
key, |
| 82 | 1x |
rationale = input$comment, |
| 83 | 1x |
role = input$role |
| 84 |
) |
|
| 85 |
} |
|
| 86 | 7x |
state$revision <- revision |
| 87 | 7x |
set_current_revision(state) |
| 88 | 7x |
message(paste("Recorded", decision, "decision."))
|
| 89 |
} |
|
| 90 | ||
| 91 | 12x |
shiny::observeEvent(input$approve, |
| 92 |
{
|
|
| 93 | 8x |
tryCatch( |
| 94 | 8x |
decide("approved"),
|
| 95 | 8x |
error = function(error) message(conditionMessage(error)) |
| 96 |
) |
|
| 97 |
}, |
|
| 98 | 12x |
ignoreInit = TRUE |
| 99 |
) |
|
| 100 | ||
| 101 | 12x |
shiny::observeEvent(input$reject, |
| 102 |
{
|
|
| 103 | 1x |
tryCatch( |
| 104 | 1x |
decide("rejected"),
|
| 105 | 1x |
error = function(error) message(conditionMessage(error)) |
| 106 |
) |
|
| 107 |
}, |
|
| 108 | 12x |
ignoreInit = TRUE |
| 109 |
) |
|
| 110 | ||
| 111 | 12x |
output$decisions <- shiny::renderTable( |
| 112 |
{
|
|
| 113 | 18x |
state <- current_revision() |
| 114 | 18x |
if (is.null(state$repository) || is.null(state$revision)) {
|
| 115 | 7x |
return(data.frame()) |
| 116 |
} |
|
| 117 | 11x |
decisions <- state$repository$list_review_decisions( |
| 118 | 11x |
state$revision$revision_id |
| 119 |
) |
|
| 120 | 11x |
if (!nrow(decisions)) {
|
| 121 | 4x |
return(data.frame()) |
| 122 |
} |
|
| 123 | 7x |
decisions[, c( |
| 124 | 7x |
"actor_id", "role", "decision", "created_at" |
| 125 | 7x |
), drop = FALSE] |
| 126 |
}, |
|
| 127 | 12x |
striped = TRUE, |
| 128 | 12x |
spacing = "s" |
| 129 |
) |
|
| 130 |
}) |
|
| 131 |
} |
| 1 |
.synthetic_data_root <- function() {
|
|
| 2 | 19x |
installed <- system.file( |
| 3 | 19x |
"extdata", "synthetic", |
| 4 | 19x |
package = "ADaMViz.SubmissionSync" |
| 5 |
) |
|
| 6 | 19x |
if (nzchar(installed)) {
|
| 7 | 19x |
return(installed) |
| 8 |
} |
|
| 9 | ! |
candidates <- c( |
| 10 | ! |
file.path("inst", "extdata", "synthetic"),
|
| 11 | ! |
file.path("..", "..", "inst", "extdata", "synthetic")
|
| 12 |
) |
|
| 13 | ! |
existing <- candidates[dir.exists(candidates)] |
| 14 | ! |
if (length(existing)) {
|
| 15 | ! |
normalizePath(existing[[1]], winslash = "/") |
| 16 |
} else {
|
|
| 17 | ! |
candidates[[1]] |
| 18 |
} |
|
| 19 |
} |
|
| 20 | ||
| 21 |
#' Create the governed local study-data provider |
|
| 22 |
#' |
|
| 23 |
#' @param root Directory containing `scenario-manifest.json` and its pinned RDS |
|
| 24 |
#' snapshots. The default resolves package installation data and then the |
|
| 25 |
#' source-tree location. |
|
| 26 |
#' |
|
| 27 |
#' @return A local `study_data_provider`. |
|
| 28 |
#' @export |
|
| 29 |
local_study_data_provider <- function(root = .synthetic_data_root()) {
|
|
| 30 | 5x |
if (!checkmate::test_directory_exists(root, access = "r")) {
|
| 31 | ! |
cli::cli_abort("The local synthetic study-data directory is unavailable")
|
| 32 |
} |
|
| 33 | 5x |
manifest_path <- file.path(root, "scenario-manifest.json") |
| 34 | 5x |
if (!checkmate::test_file_exists(manifest_path, access = "r")) {
|
| 35 | ! |
cli::cli_abort("The governed synthetic scenario manifest is unavailable")
|
| 36 |
} |
|
| 37 | 5x |
manifest <- jsonlite::read_json(manifest_path, simplifyVector = FALSE) |
| 38 | 5x |
.validate_synthetic_manifest(manifest) |
| 39 | ||
| 40 | 5x |
structure( |
| 41 | 5x |
list( |
| 42 | 5x |
port = new_provider_port( |
| 43 | 5x |
"study_data", c("list_catalog", "read_snapshot")
|
| 44 |
), |
|
| 45 | 5x |
manifest = manifest, |
| 46 | 5x |
read_data = function(metadata) {
|
| 47 | 13x |
file_name <- metadata$file |
| 48 | 13x |
if (!checkmate::test_string( |
| 49 | 13x |
file_name, |
| 50 | 13x |
pattern = "^adlb-[a-z0-9-]+\\.rds$" |
| 51 |
)) {
|
|
| 52 | ! |
cli::cli_abort( |
| 53 | ! |
"The synthetic manifest contains an invalid snapshot file name" |
| 54 |
) |
|
| 55 |
} |
|
| 56 | 13x |
readRDS(file.path(root, file_name)) |
| 57 |
} |
|
| 58 |
), |
|
| 59 | 5x |
class = "study_data_provider" |
| 60 |
) |
|
| 61 |
} |
| 1 |
mod_plot_preview_ui <- function(id) {
|
|
| 2 | 6x |
ns <- shiny::NS(id) |
| 3 | 6x |
bslib::card( |
| 4 | 6x |
class = "assurance-panel", |
| 5 | 6x |
bslib::card_header("Plot preview"),
|
| 6 | 6x |
bslib::card_body( |
| 7 | 6x |
shiny::uiOutput(ns("plot")),
|
| 8 | 6x |
tags$details( |
| 9 | 6x |
class = "f001-panel", |
| 10 | 6x |
tags$summary("Generated R script"),
|
| 11 | 6x |
tags$div( |
| 12 | 6x |
class = "f001-panel-body", |
| 13 | 6x |
shiny::verbatimTextOutput(ns("code"), placeholder = TRUE)
|
| 14 |
) |
|
| 15 |
) |
|
| 16 |
) |
|
| 17 |
) |
|
| 18 |
} |
|
| 19 | ||
| 20 |
mod_plot_preview_server <- function(id, current_revision) {
|
|
| 21 | 13x |
shiny::moduleServer(id, function(input, output, session) {
|
| 22 | 13x |
output$plot <- shiny::renderUI({
|
| 23 | 13x |
state <- current_revision() |
| 24 | 13x |
image_hash <- state$revision$image_hash %||% NA_character_ |
| 25 | 13x |
store <- state$repository$artifact_store |
| 26 | 13x |
if (!is.null(store) && checkmate::test_string( |
| 27 | 13x |
image_hash, |
| 28 | 13x |
pattern = "^[0-9a-f]{64}$"
|
| 29 |
)) {
|
|
| 30 | 5x |
shiny::imageOutput(session$ns("accepted_plot"), height = "520px")
|
| 31 |
} else {
|
|
| 32 | 8x |
shiny::plotOutput(session$ns("experimental_plot"), height = "520px")
|
| 33 |
} |
|
| 34 |
}) |
|
| 35 | ||
| 36 | 13x |
output$experimental_plot <- shiny::renderPlot( |
| 37 |
{
|
|
| 38 | 9x |
state <- current_revision() |
| 39 | 9x |
shiny::validate(shiny::need( |
| 40 | 9x |
!is.null(state$artifact), "No plot artifact yet." |
| 41 |
)) |
|
| 42 | 1x |
state$artifact$combined |
| 43 |
}, |
|
| 44 | 13x |
alt = function() {
|
| 45 | 9x |
state <- current_revision() |
| 46 | 9x |
if (is.null(state$profile)) {
|
| 47 | 7x |
return("No governed plot revision has been generated.")
|
| 48 |
} |
|
| 49 | 2x |
paste( |
| 50 | 2x |
"Longitudinal boxplot for", |
| 51 | 2x |
state$spec$fields$paramcd, |
| 52 | 2x |
"using", |
| 53 | 2x |
state$spec$fields$y_variable, |
| 54 | 2x |
"faceted by", |
| 55 | 2x |
state$spec$fields$treatment_variable, |
| 56 | 2x |
"with N values in an aligned lower strip." |
| 57 |
) |
|
| 58 |
} |
|
| 59 |
) |
|
| 60 | ||
| 61 | 13x |
output$accepted_plot <- shiny::renderImage( |
| 62 |
{
|
|
| 63 | 13x |
state <- current_revision() |
| 64 | 13x |
image_hash <- state$revision$image_hash %||% NA_character_ |
| 65 | 13x |
store <- state$repository$artifact_store |
| 66 | 13x |
shiny::validate(shiny::need( |
| 67 | 13x |
!is.null(store) && checkmate::test_string( |
| 68 | 13x |
image_hash, |
| 69 | 13x |
pattern = "^[0-9a-f]{64}$"
|
| 70 |
), |
|
| 71 | 13x |
"No accepted plot artifact yet." |
| 72 |
)) |
|
| 73 | 5x |
image_path <- tempfile(fileext = ".png") |
| 74 | 5x |
writeBin(store$get(image_hash), image_path) |
| 75 | 5x |
list( |
| 76 | 5x |
src = image_path, |
| 77 | 5x |
contentType = "image/png", |
| 78 | 5x |
style = "width: 100%; height: 100%; object-fit: contain;", |
| 79 | 5x |
alt = paste( |
| 80 | 5x |
"Accepted longitudinal boxplot for", |
| 81 | 5x |
state$spec$fields$paramcd |
| 82 |
) |
|
| 83 |
) |
|
| 84 |
}, |
|
| 85 | 13x |
deleteFile = TRUE |
| 86 |
) |
|
| 87 | ||
| 88 | 13x |
output$code <- shiny::renderText({
|
| 89 | 13x |
state <- current_revision() |
| 90 | 13x |
code_hash <- state$revision$code_hash %||% NA_character_ |
| 91 | 13x |
store <- state$repository$artifact_store |
| 92 | 13x |
if (!is.null(store) && checkmate::test_string( |
| 93 | 13x |
code_hash, |
| 94 | 13x |
pattern = "^[0-9a-f]{64}$"
|
| 95 |
)) {
|
|
| 96 | 5x |
return(rawToChar(store$get(code_hash))) |
| 97 |
} |
|
| 98 | 8x |
shiny::validate(shiny::need( |
| 99 | 8x |
!is.null(state$script), "No executed R script yet." |
| 100 |
)) |
|
| 101 | 1x |
state$script |
| 102 |
}) |
|
| 103 |
}) |
|
| 104 |
} |
| 1 |
.standalone_script_expressions <- function(spec, low_n_policy) {
|
|
| 2 | 33x |
calculate_expression <- rlang::call2( |
| 3 |
"<-", |
|
| 4 | 33x |
rlang::sym("calculate_boxplot_statistics"),
|
| 5 | 33x |
rlang::call2( |
| 6 | 33x |
"function", |
| 7 | 33x |
formals(.standalone_boxplot_stats), # nolint: object_usage_linter. |
| 8 | 33x |
body(.standalone_boxplot_stats) # nolint: object_usage_linter. |
| 9 |
) |
|
| 10 |
) |
|
| 11 | 33x |
assemble_expression <- rlang::call2( |
| 12 |
"<-", |
|
| 13 | 33x |
rlang::sym("assemble_boxplot"),
|
| 14 | 33x |
rlang::call2( |
| 15 | 33x |
"function", |
| 16 | 33x |
formals(.standalone_assemble_boxplot), |
| 17 | 33x |
body(.standalone_assemble_boxplot) |
| 18 |
) |
|
| 19 |
) |
|
| 20 | 33x |
treatment_variable <- spec$fields$treatment_variable |
| 21 | 33x |
y_variable <- spec$fields$y_variable |
| 22 | 33x |
facet_levels <- spec$fields$treatment_levels |
| 23 | 33x |
visit_levels <- spec$fields$visits |
| 24 | 33x |
scale_mode <- spec$fields$scale_mode |
| 25 | 33x |
unit <- spec$fields$unit |
| 26 | 33x |
list( |
| 27 | 33x |
calculate_expression, |
| 28 | 33x |
assemble_expression, |
| 29 | 33x |
rlang::expr(boxplot_analysis <- calculate_boxplot_statistics( |
| 30 | 33x |
data = !!rlang::sym("analysis_data"),
|
| 31 | 33x |
treatment_variable = !!treatment_variable, |
| 32 | 33x |
y_variable = !!y_variable, |
| 33 | 33x |
facet_levels = !!facet_levels, |
| 34 | 33x |
visit_levels = !!visit_levels, |
| 35 | 33x |
low_n_policy = list( |
| 36 | 33x |
value = !!as.numeric(low_n_policy$value), |
| 37 | 33x |
rationale = !!low_n_policy$rationale, |
| 38 | 33x |
authority = !!low_n_policy$authority, |
| 39 | 33x |
version = !!low_n_policy$version |
| 40 |
) |
|
| 41 |
)), |
|
| 42 | 33x |
rlang::expr(boxplot_artifact <- assemble_boxplot( |
| 43 | 33x |
analysis = boxplot_analysis, |
| 44 | 33x |
scale_mode = !!scale_mode, |
| 45 | 33x |
unit = !!unit |
| 46 |
)), |
|
| 47 | 33x |
rlang::expr(if (sys.nframe() == 0L) print(boxplot_artifact$combined)) |
| 48 |
) |
|
| 49 |
} |
|
| 50 | ||
| 51 |
#' Compile the deterministic governed boxplot script |
|
| 52 |
#' |
|
| 53 |
#' The controlled runner provides validated selected records as `analysis_data`. |
|
| 54 |
#' The caller must supply `analysis_data`. The script contains resolved |
|
| 55 |
#' literals, inlined helpers, and uses `ggplot2`, `cli`, and `patchwork`. |
|
| 56 |
#' |
|
| 57 |
#' @param spec A confirmed `plot_spec`. |
|
| 58 |
#' @param low_n_policy Retained policy with `value`, `rationale`, `authority`, |
|
| 59 |
#' and `version`. |
|
| 60 |
#' |
|
| 61 |
#' @return A length-one UTF-8 R script. |
|
| 62 |
#' @export |
|
| 63 |
compile_boxplot_script <- function(spec, low_n_policy) {
|
|
| 64 | 33x |
.validate_plot_spec(spec) |
| 65 | 33x |
if (!identical(spec$state, "confirmed")) {
|
| 66 | ! |
cli::cli_abort("Only a confirmed plot specification can be compiled")
|
| 67 |
} |
|
| 68 | 33x |
.validate_boxplot_design( |
| 69 | 33x |
spec$fields$treatment_levels, |
| 70 | 33x |
spec$fields$visits, |
| 71 | 33x |
low_n_policy |
| 72 |
) |
|
| 73 | 33x |
script_expressions <- .standalone_script_expressions(spec, low_n_policy) |
| 74 | 33x |
script_text <- vapply( |
| 75 | 33x |
script_expressions, |
| 76 | 33x |
\(expression) rlang::expr_text(expression, width = 80L), |
| 77 | 33x |
character(1) |
| 78 |
) |
|
| 79 | 33x |
lines <- c( |
| 80 | 33x |
"# Boxplot Analysis Script", |
| 81 | 33x |
"# Required packages: ggplot2, cli, patchwork", |
| 82 | 33x |
"# The generated script does not require the source application package.", |
| 83 | 33x |
"# Source this script after defining analysis_data.", |
| 84 |
"", |
|
| 85 | 33x |
"library(ggplot2)", |
| 86 | 33x |
"library(cli)", |
| 87 | 33x |
"library(patchwork)", |
| 88 |
"", |
|
| 89 | 33x |
"# Data loading ----", |
| 90 | 33x |
"if (!exists(\"analysis_data\", inherits = FALSE)) {",
|
| 91 | 33x |
" stop(\"analysis_data must be supplied\", call. = FALSE)", |
| 92 |
"}", |
|
| 93 |
"", |
|
| 94 | 33x |
"# Analysis and plotting functions ----", |
| 95 | 33x |
script_text[1:2], |
| 96 |
"", |
|
| 97 | 33x |
"# Execute analysis ----", |
| 98 | 33x |
script_text[3:5] |
| 99 |
) |
|
| 100 | 33x |
enc2utf8(paste0(paste(lines, collapse = "\n"), "\n")) |
| 101 |
} |
| 1 |
.plot_spec_fields <- c( |
|
| 2 |
"dataset_id", "paramcd", "y_variable", "unit", "treatment_variable", |
|
| 3 |
"treatment_levels", "visits", "scale_mode" |
|
| 4 |
) |
|
| 5 | ||
| 6 |
new_plot_spec <- function( |
|
| 7 |
dataset_id, |
|
| 8 |
paramcd = NULL, |
|
| 9 |
y_variable = NULL, |
|
| 10 |
unit = NULL, |
|
| 11 |
treatment_variable = NULL, |
|
| 12 |
treatment_levels = NULL, |
|
| 13 |
visits = NULL, |
|
| 14 |
scale_mode = "fixed", |
|
| 15 |
provenance = list(), |
|
| 16 |
state = c("candidate", "confirmed")
|
|
| 17 |
) {
|
|
| 18 | 77x |
state <- match.arg(state) |
| 19 | 77x |
if (!checkmate::test_string(dataset_id, min.chars = 1)) {
|
| 20 | ! |
cli::cli_abort("{.arg dataset_id} must be one non-empty identifier")
|
| 21 |
} |
|
| 22 | 77x |
if (!is.null(y_variable) && !checkmate::test_choice( |
| 23 | 77x |
y_variable, |
| 24 | 77x |
choices = c("AVAL", "CHG", "PCHG")
|
| 25 |
)) {
|
|
| 26 | 1x |
cli::cli_abort(paste0( |
| 27 | 1x |
"{.arg y_variable} must be one of {.val ",
|
| 28 | 1x |
"{c('AVAL', 'CHG', 'PCHG')}}"
|
| 29 |
)) |
|
| 30 |
} |
|
| 31 | 76x |
if (!checkmate::test_choice(scale_mode, choices = c("fixed", "free"))) {
|
| 32 | ! |
cli::cli_abort("{.arg scale_mode} must be {.val fixed} or {.val free}")
|
| 33 |
} |
|
| 34 | 76x |
unknown_provenance <- setdiff(names(provenance), .plot_spec_fields) |
| 35 | 76x |
if (length(unknown_provenance)) {
|
| 36 | 1x |
cli::cli_abort(paste0( |
| 37 | 1x |
"Unknown provenance field{?s}: {.field {unknown_provenance}}"
|
| 38 |
)) |
|
| 39 |
} |
|
| 40 | 75x |
invalid_sources <- unlist(provenance, use.names = FALSE) |
| 41 | 75x |
if (length(invalid_sources) && any(!invalid_sources %in% c( |
| 42 | 75x |
"prompt", "metadata", "default", "user_confirmed" |
| 43 |
))) {
|
|
| 44 | ! |
cli::cli_abort(paste0( |
| 45 | ! |
"Provenance values must be prompt, metadata, default, or user_confirmed" |
| 46 |
)) |
|
| 47 |
} |
|
| 48 | ||
| 49 | 75x |
fields <- list( |
| 50 | 75x |
dataset_id = dataset_id, |
| 51 | 75x |
paramcd = paramcd, |
| 52 | 75x |
y_variable = y_variable, |
| 53 | 75x |
unit = unit, |
| 54 | 75x |
treatment_variable = treatment_variable, |
| 55 | 75x |
treatment_levels = treatment_levels, |
| 56 | 75x |
visits = visits, |
| 57 | 75x |
scale_mode = scale_mode |
| 58 |
) |
|
| 59 | 75x |
structure( |
| 60 | 75x |
list( |
| 61 | 75x |
schema_version = "plot-spec-v1", |
| 62 | 75x |
state = state, |
| 63 | 75x |
fields = fields, |
| 64 | 75x |
provenance = provenance, |
| 65 | 75x |
hash = canonical_hash(list(fields = fields, provenance = provenance)) |
| 66 |
), |
|
| 67 | 75x |
class = "plot_spec" |
| 68 |
) |
|
| 69 |
} |
|
| 70 | ||
| 71 |
plot_spec_clarifications <- function(spec) {
|
|
| 72 | 26x |
.validate_plot_spec(spec) |
| 73 | 26x |
required <- .plot_spec_fields[.plot_spec_fields != "scale_mode"] |
| 74 | 26x |
required[vapply(spec$fields[required], is.null, logical(1))] |
| 75 |
} |
|
| 76 | ||
| 77 |
confirm_plot_spec <- function(spec) {
|
|
| 78 | 12x |
missing_fields <- plot_spec_clarifications(spec) |
| 79 | 12x |
if (length(missing_fields)) {
|
| 80 | ! |
cli::cli_abort(paste0( |
| 81 | ! |
"The plot specification needs clarification for {.field {missing_fields}}"
|
| 82 |
)) |
|
| 83 |
} |
|
| 84 | 12x |
unconfirmed <- setdiff(.plot_spec_fields, names(spec$provenance)) |
| 85 | 12x |
if (length(unconfirmed)) {
|
| 86 | ! |
cli::cli_abort(paste0( |
| 87 | ! |
"The plot specification lacks provenance for {.field {unconfirmed}}"
|
| 88 |
)) |
|
| 89 |
} |
|
| 90 | 12x |
new_plot_spec( |
| 91 | 12x |
dataset_id = spec$fields$dataset_id, |
| 92 | 12x |
paramcd = spec$fields$paramcd, |
| 93 | 12x |
y_variable = spec$fields$y_variable, |
| 94 | 12x |
unit = spec$fields$unit, |
| 95 | 12x |
treatment_variable = spec$fields$treatment_variable, |
| 96 | 12x |
treatment_levels = spec$fields$treatment_levels, |
| 97 | 12x |
visits = spec$fields$visits, |
| 98 | 12x |
scale_mode = spec$fields$scale_mode, |
| 99 | 12x |
provenance = spec$provenance, |
| 100 | 12x |
state = "confirmed" |
| 101 |
) |
|
| 102 |
} |
|
| 103 | ||
| 104 |
.validate_plot_spec <- function(spec) {
|
|
| 105 | 89x |
if (!inherits(spec, "plot_spec")) {
|
| 106 | ! |
cli::cli_abort("{.arg spec} must be a {.cls plot_spec}")
|
| 107 |
} |
|
| 108 | 89x |
invisible(spec) |
| 109 |
} |
| 1 |
.export_receipt_id <- function(idempotency_key) {
|
|
| 2 | 25x |
paste0("receipt-", substr(canonical_hash(idempotency_key), 1L, 24L))
|
| 3 |
} |
|
| 4 | ||
| 5 |
.export_actor_roles <- c( |
|
| 6 |
"clinical_scientist", "statistical_programmer", |
|
| 7 |
"biostatistician", "export_publisher" |
|
| 8 |
) |
|
| 9 | ||
| 10 |
.authorize_export_actor <- function(actor) {
|
|
| 11 | 33x |
if (!inherits(actor, "submission_sync_actor")) {
|
| 12 | ! |
cli::cli_abort("Export authorization requires an authenticated actor")
|
| 13 |
} |
|
| 14 | 33x |
if (!isTRUE(actor$active)) {
|
| 15 | 2x |
cli::cli_abort("The current actor is not active")
|
| 16 |
} |
|
| 17 | 31x |
if (!any(actor$roles %in% .export_actor_roles)) {
|
| 18 | 3x |
cli::cli_abort("The current actor is not authorized to export revisions")
|
| 19 |
} |
|
| 20 | 28x |
invisible(TRUE) |
| 21 |
} |
|
| 22 | ||
| 23 |
.assert_exportable_revision <- function(revision, expected_version) {
|
|
| 24 | 28x |
if (!identical(as.integer(revision$version), as.integer(expected_version))) {
|
| 25 | 3x |
cli::cli_abort("The revision token is stale")
|
| 26 |
} |
|
| 27 | 25x |
if (!identical(revision$status, "Reviewed")) {
|
| 28 | 3x |
cli::cli_abort("Only Reviewed revisions can be exported")
|
| 29 |
} |
|
| 30 | 22x |
invisible(revision) |
| 31 |
} |
|
| 32 | ||
| 33 |
new_export_service <- function( |
|
| 34 |
repository, |
|
| 35 |
artifact_store, |
|
| 36 |
workspace_provider, |
|
| 37 |
identity_provider |
|
| 38 |
) {
|
|
| 39 | 17x |
.validate_evidence_repository( |
| 40 | 17x |
repository, |
| 41 | 17x |
c( |
| 42 | 17x |
"get_revision", "get_artifact_bundle", "record_export_receipt", |
| 43 | 17x |
"list_export_receipts" |
| 44 |
) |
|
| 45 |
) |
|
| 46 | 17x |
if (!is.list(artifact_store) || |
| 47 | 17x |
!is.function(artifact_store$get) || |
| 48 | 17x |
!is.function(artifact_store$verify)) {
|
| 49 | ! |
cli::cli_abort("Export service requires a readable artifact store")
|
| 50 |
} |
|
| 51 | 17x |
.validate_workspace_provider(workspace_provider) |
| 52 | 17x |
.validate_identity_provider(identity_provider) |
| 53 | ||
| 54 | 17x |
export_revision <- function( |
| 55 | 17x |
revision_id, |
| 56 | 17x |
actor_id, |
| 57 | 17x |
destination_id, |
| 58 | 17x |
expected_version, |
| 59 | 17x |
idempotency_key |
| 60 |
) {
|
|
| 61 | 25x |
.validate_identifier(revision_id, "revision_id") |
| 62 | 25x |
.validate_workspace_token(destination_id, "destination_id") |
| 63 | 25x |
.validate_identifier(idempotency_key, "idempotency_key") |
| 64 | ||
| 65 | 25x |
receipt_id <- .export_receipt_id(idempotency_key) |
| 66 | 25x |
actor <- identity_provider$get_actor(actor_id) |
| 67 | 25x |
.authorize_export_actor(actor) |
| 68 | 20x |
revision <- repository$get_revision(revision_id) |
| 69 | 20x |
.assert_exportable_revision(revision, expected_version) |
| 70 | 14x |
prior <- repository$list_export_receipts(revision_id) |
| 71 | 14x |
prior <- prior[prior$receipt_id == receipt_id, , drop = FALSE] |
| 72 | 14x |
if (nrow(prior)) {
|
| 73 | 4x |
prior <- as.list(prior[1, , drop = FALSE]) |
| 74 | 4x |
if (!identical(prior$destination_id, destination_id)) {
|
| 75 | 1x |
cli::cli_abort( |
| 76 | 1x |
"The idempotency key was already used for a different destination" |
| 77 |
) |
|
| 78 |
} |
|
| 79 | 3x |
published <- workspace_provider$inspect_published( |
| 80 | 3x |
destination_id, receipt_id |
| 81 |
) |
|
| 82 | 3x |
if (is.null(published)) {
|
| 83 | ! |
cli::cli_abort("The previously published export is missing")
|
| 84 |
} |
|
| 85 | 3x |
if (!identical(published$code_hash, prior$code_hash) || |
| 86 | 3x |
!identical(published$image_hash, prior$image_hash)) {
|
| 87 | ! |
cli::cli_abort("Published export hashes do not match the receipt")
|
| 88 |
} |
|
| 89 | 3x |
return(invisible(c( |
| 90 | 3x |
list(receipt_id = receipt_id, revision_id = revision_id), |
| 91 | 3x |
published |
| 92 |
))) |
|
| 93 |
} |
|
| 94 | 10x |
bundle <- repository$get_artifact_bundle(revision_id) |
| 95 | ||
| 96 | 9x |
code_bytes <- artifact_store$get(bundle$code_hash) |
| 97 | 9x |
image_bytes <- artifact_store$get(bundle$image_hash) |
| 98 | 9x |
export_id <- receipt_id |
| 99 | ||
| 100 | 9x |
published <- workspace_provider$inspect_published(destination_id, export_id) |
| 101 | 8x |
if (is.null(published)) {
|
| 102 | 8x |
stage <- workspace_provider$stage_bundle( |
| 103 | 8x |
destination_id = destination_id, |
| 104 | 8x |
export_id = export_id, |
| 105 | 8x |
code_bytes = code_bytes, |
| 106 | 8x |
image_bytes = image_bytes |
| 107 |
) |
|
| 108 | ||
| 109 | 8x |
actor <- identity_provider$get_actor(actor_id) |
| 110 | 8x |
.authorize_export_actor(actor) |
| 111 | 8x |
current <- repository$get_revision(revision_id) |
| 112 | 8x |
.assert_exportable_revision(current, expected_version) |
| 113 | 8x |
if (!identical(current$code_hash, bundle$code_hash) || |
| 114 | 8x |
!identical(current$image_hash, bundle$image_hash)) {
|
| 115 | ! |
workspace_provider$quarantine_staged(stage, "stale-revision") |
| 116 | ! |
cli::cli_abort( |
| 117 | ! |
"The accepted artifact bundle no longer matches the revision" |
| 118 |
) |
|
| 119 |
} |
|
| 120 | 8x |
published <- workspace_provider$publish_staged(stage) |
| 121 |
} |
|
| 122 | ||
| 123 | 8x |
if (!identical(published$code_hash, bundle$code_hash) || |
| 124 | 8x |
!identical(published$image_hash, bundle$image_hash)) {
|
| 125 | ! |
cli::cli_abort("Published export hashes do not match the accepted bundle")
|
| 126 |
} |
|
| 127 | ||
| 128 | 8x |
repository$record_export_receipt( |
| 129 | 8x |
receipt_id = receipt_id, |
| 130 | 8x |
revision_id = revision_id, |
| 131 | 8x |
destination_id = destination_id, |
| 132 | 8x |
code_hash = bundle$code_hash, |
| 133 | 8x |
image_hash = bundle$image_hash, |
| 134 | 8x |
idempotency_key = idempotency_key |
| 135 |
) |
|
| 136 | 8x |
invisible(c( |
| 137 | 8x |
list(receipt_id = receipt_id, revision_id = revision_id), |
| 138 | 8x |
published |
| 139 |
)) |
|
| 140 |
} |
|
| 141 | ||
| 142 | 17x |
structure( |
| 143 | 17x |
list(export_revision = export_revision), |
| 144 | 17x |
class = "export_service" |
| 145 |
) |
|
| 146 |
} |
| 1 |
local_workspace_provider <- function(destinations) {
|
|
| 2 | 26x |
if (is.character(destinations)) {
|
| 3 | 26x |
roots <- as.list(destinations) |
| 4 | ! |
} else if (is.list(destinations)) {
|
| 5 | ! |
roots <- destinations |
| 6 |
} else {
|
|
| 7 | ! |
cli::cli_abort( |
| 8 | ! |
"{.arg destinations} must be a named character vector or list"
|
| 9 |
) |
|
| 10 |
} |
|
| 11 | 26x |
destination_ids <- names(roots) |
| 12 | 26x |
if (!length(destination_ids) || |
| 13 | 26x |
any(!nzchar(destination_ids)) || |
| 14 | 26x |
anyDuplicated(destination_ids)) {
|
| 15 | ! |
cli::cli_abort( |
| 16 | ! |
"Workspace destinations must have unique logical identifiers" |
| 17 |
) |
|
| 18 |
} |
|
| 19 | 26x |
lapply(destination_ids, .validate_workspace_token, name = "destination_id") |
| 20 | 26x |
roots <- lapply(roots, function(root) {
|
| 21 | 26x |
checkmate::assert_string(root, min.chars = 1L) |
| 22 | 26x |
if (!fs::dir_exists(root)) {
|
| 23 | ! |
cli::cli_abort("Workspace root {.path {root}} must already exist")
|
| 24 |
} |
|
| 25 | 26x |
fs::path_abs(normalizePath(root, winslash = "/", mustWork = TRUE)) |
| 26 |
}) |
|
| 27 | ||
| 28 | 26x |
destination_root <- function(destination_id) {
|
| 29 | 52x |
.validate_workspace_token(destination_id, "destination_id") |
| 30 | 52x |
root <- roots[[destination_id]] |
| 31 | 52x |
if (is.null(root)) {
|
| 32 | 2x |
cli::cli_abort( |
| 33 | 2x |
"Workspace destination {.val {destination_id}} is not registered"
|
| 34 |
) |
|
| 35 |
} |
|
| 36 | 50x |
.validate_workspace_root(root) |
| 37 | 50x |
root |
| 38 |
} |
|
| 39 | ||
| 40 | 26x |
export_paths <- function(destination_id, export_id) {
|
| 41 | 50x |
root <- destination_root(destination_id) |
| 42 | 48x |
.validate_workspace_token(export_id, "export_id") |
| 43 | 43x |
list( |
| 44 | 43x |
root = root, |
| 45 | 43x |
staging = fs::path(root, ".staging", export_id), |
| 46 | 43x |
final = fs::path(root, export_id), |
| 47 | 43x |
quarantine = fs::path(root, ".quarantine") |
| 48 |
) |
|
| 49 |
} |
|
| 50 | ||
| 51 | 26x |
stage_bundle <- function(destination_id, export_id, code_bytes, image_bytes) {
|
| 52 | 17x |
if (!is.raw(code_bytes) || !is.raw(image_bytes)) {
|
| 53 | ! |
cli::cli_abort("Workspace bundles must be supplied as raw vectors")
|
| 54 |
} |
|
| 55 | 17x |
paths <- export_paths(destination_id, export_id) |
| 56 | 11x |
if (fs::dir_exists(paths$staging) || fs::dir_exists(paths$final)) {
|
| 57 | 1x |
cli::cli_abort("Workspace export {.val {export_id}} already exists")
|
| 58 |
} |
|
| 59 | 10x |
fs::dir_create(paths$staging, recurse = TRUE) |
| 60 | 10x |
writeBin(code_bytes, fs::path(paths$staging, "script.R")) |
| 61 | 10x |
writeBin(image_bytes, fs::path(paths$staging, "plot.png")) |
| 62 | 10x |
hashes <- .workspace_pair_hashes(paths$staging) |
| 63 | 10x |
writeLines( |
| 64 | 10x |
canonical_serialize(list( |
| 65 | 10x |
destination_id = destination_id, |
| 66 | 10x |
export_id = export_id, |
| 67 | 10x |
code_hash = hashes$code_hash, |
| 68 | 10x |
image_hash = hashes$image_hash, |
| 69 | 10x |
staged_at = .utc_timestamp() |
| 70 |
)), |
|
| 71 | 10x |
fs::path(paths$staging, "manifest.json"), |
| 72 | 10x |
useBytes = TRUE |
| 73 |
) |
|
| 74 | 10x |
structure( |
| 75 | 10x |
c( |
| 76 | 10x |
paths, hashes, |
| 77 | 10x |
list(destination_id = destination_id, export_id = export_id) |
| 78 |
), |
|
| 79 | 10x |
class = "workspace_staged_export" |
| 80 |
) |
|
| 81 |
} |
|
| 82 | ||
| 83 | 26x |
publish_staged <- function(stage) {
|
| 84 | 9x |
if (!inherits(stage, "workspace_staged_export")) {
|
| 85 | ! |
cli::cli_abort("{.arg stage} must be a staged workspace export")
|
| 86 |
} |
|
| 87 | 9x |
paths <- export_paths(stage$destination_id, stage$export_id) |
| 88 | 9x |
if (!fs::dir_exists(paths$staging)) {
|
| 89 | ! |
cli::cli_abort( |
| 90 | ! |
"Staged workspace export {.val {stage$export_id}} is missing"
|
| 91 |
) |
|
| 92 |
} |
|
| 93 | 9x |
if (fs::dir_exists(paths$final)) {
|
| 94 | ! |
cli::cli_abort("Workspace export {.val {stage$export_id}} already exists")
|
| 95 |
} |
|
| 96 | 9x |
hashes <- .workspace_pair_hashes(paths$staging) |
| 97 | 9x |
if (is.null(hashes) || |
| 98 | 9x |
!identical(hashes$code_hash, stage$code_hash) || |
| 99 | 9x |
!identical(hashes$image_hash, stage$image_hash)) {
|
| 100 | ! |
quarantine_staged(stage, "hash-mismatch") |
| 101 | ! |
cli::cli_abort( |
| 102 | ! |
"Staged workspace export hashes changed before publication" |
| 103 |
) |
|
| 104 |
} |
|
| 105 | 9x |
fs::file_delete(fs::path(paths$staging, "manifest.json")) |
| 106 | 9x |
fs::file_move(paths$staging, paths$final) |
| 107 | 9x |
published <- inspect_published(stage$destination_id, stage$export_id) |
| 108 | 9x |
if (is.null(published) || |
| 109 | 9x |
!identical(published$code_hash, stage$code_hash) || |
| 110 | 9x |
!identical(published$image_hash, stage$image_hash)) {
|
| 111 | ! |
cli::cli_abort( |
| 112 | ! |
"Published workspace export hashes do not match the staged bundle" |
| 113 |
) |
|
| 114 |
} |
|
| 115 | 9x |
invisible(published) |
| 116 |
} |
|
| 117 | ||
| 118 | 26x |
inspect_published <- function(destination_id, export_id) {
|
| 119 | 22x |
paths <- export_paths(destination_id, export_id) |
| 120 | 21x |
hashes <- .workspace_pair_hashes(paths$final) |
| 121 | 21x |
if (is.null(hashes)) {
|
| 122 | 8x |
return(NULL) |
| 123 |
} |
|
| 124 | 13x |
c( |
| 125 | 13x |
list( |
| 126 | 13x |
destination_id = destination_id, |
| 127 | 13x |
export_id = export_id, |
| 128 | 13x |
path = paths$final, |
| 129 | 13x |
code_path = fs::path(paths$final, "script.R"), |
| 130 | 13x |
image_path = fs::path(paths$final, "plot.png") |
| 131 |
), |
|
| 132 | 13x |
hashes |
| 133 |
) |
|
| 134 |
} |
|
| 135 | ||
| 136 | 26x |
quarantine_staged <- function(stage, reason = "orphaned") {
|
| 137 | 1x |
if (!inherits(stage, "workspace_staged_export")) {
|
| 138 | ! |
cli::cli_abort("{.arg stage} must be a staged workspace export")
|
| 139 |
} |
|
| 140 | 1x |
paths <- export_paths(stage$destination_id, stage$export_id) |
| 141 | 1x |
if (!fs::dir_exists(paths$staging)) {
|
| 142 | ! |
return(invisible(NULL)) |
| 143 |
} |
|
| 144 | 1x |
fs::dir_create(paths$quarantine, recurse = TRUE) |
| 145 | 1x |
target <- fs::path( |
| 146 | 1x |
paths$quarantine, |
| 147 | 1x |
paste( |
| 148 | 1x |
stage$export_id, |
| 149 | 1x |
substr(canonical_hash(list(reason, .utc_timestamp())), 1L, 12L), |
| 150 | 1x |
sep = "-" |
| 151 |
) |
|
| 152 |
) |
|
| 153 | 1x |
fs::file_move(paths$staging, target) |
| 154 | 1x |
invisible(target) |
| 155 |
} |
|
| 156 | ||
| 157 | 26x |
reconcile <- function(destination_id) {
|
| 158 | 2x |
root <- destination_root(destination_id) |
| 159 | 2x |
staging_root <- fs::path(root, ".staging") |
| 160 | 2x |
if (!fs::dir_exists(staging_root)) {
|
| 161 | 1x |
return(data.frame( |
| 162 | 1x |
destination_id = character(), |
| 163 | 1x |
export_id = character(), |
| 164 | 1x |
action = character(), |
| 165 | 1x |
path = character() |
| 166 |
)) |
|
| 167 |
} |
|
| 168 | 1x |
staged <- fs::dir_ls(staging_root, type = "directory", fail = FALSE) |
| 169 | 1x |
results <- lapply(staged, function(path) {
|
| 170 | 1x |
export_id <- fs::path_file(path) |
| 171 | 1x |
.validate_workspace_token(export_id, "export_id") |
| 172 | 1x |
stage <- structure( |
| 173 | 1x |
c( |
| 174 | 1x |
export_paths(destination_id, export_id), |
| 175 | 1x |
.workspace_pair_hashes(path) %||% list( |
| 176 | 1x |
code_hash = NA_character_, image_hash = NA_character_ |
| 177 |
), |
|
| 178 | 1x |
list(destination_id = destination_id, export_id = export_id) |
| 179 |
), |
|
| 180 | 1x |
class = "workspace_staged_export" |
| 181 |
) |
|
| 182 | 1x |
quarantine <- quarantine_staged(stage, "reconciled-orphan") |
| 183 | 1x |
data.frame( |
| 184 | 1x |
destination_id = destination_id, |
| 185 | 1x |
export_id = export_id, |
| 186 | 1x |
action = "quarantined", |
| 187 | 1x |
path = quarantine, |
| 188 | 1x |
stringsAsFactors = FALSE |
| 189 |
) |
|
| 190 |
}) |
|
| 191 | 1x |
if (!length(results)) {
|
| 192 | ! |
return(data.frame( |
| 193 | ! |
destination_id = character(), |
| 194 | ! |
export_id = character(), |
| 195 | ! |
action = character(), |
| 196 | ! |
path = character() |
| 197 |
)) |
|
| 198 |
} |
|
| 199 | 1x |
do.call(rbind, results) |
| 200 |
} |
|
| 201 | ||
| 202 | 26x |
structure( |
| 203 | 26x |
list( |
| 204 | 26x |
assurance_class = "development-only", |
| 205 | 26x |
destinations = roots, |
| 206 | 26x |
list_destinations = function() names(roots), |
| 207 | 26x |
stage_bundle = stage_bundle, |
| 208 | 26x |
publish_staged = publish_staged, |
| 209 | 26x |
inspect_published = inspect_published, |
| 210 | 26x |
quarantine_staged = quarantine_staged, |
| 211 | 26x |
reconcile = reconcile |
| 212 |
), |
|
| 213 | 26x |
class = c("development_only_workspace_provider", "local_workspace_provider")
|
| 214 |
) |
|
| 215 |
} |
|
| 216 | ||
| 217 |
.validate_workspace_root <- function(root) {
|
|
| 218 | 50x |
if (isTRUE(fs::is_link(root))) {
|
| 219 | ! |
cli::cli_abort("Workspace roots cannot be symbolic links or reparse points")
|
| 220 |
} |
|
| 221 | 50x |
invisible(root) |
| 222 |
} |
| 1 |
mod_prompt_ui <- function(id) {
|
|
| 2 | ! |
ns <- shiny::NS(id) |
| 3 | ! |
bslib::card( |
| 4 | ! |
class = "assurance-panel", |
| 5 | ! |
bslib::card_header("Prompt and Dataset"),
|
| 6 | ! |
bslib::card_body( |
| 7 | ! |
shiny::selectInput( |
| 8 | ! |
ns("dataset_id"), "Synthetic BDS scenario",
|
| 9 | ! |
choices = character() |
| 10 |
), |
|
| 11 | ! |
shiny::textAreaInput( |
| 12 | ! |
ns("prompt"),
|
| 13 | ! |
"Plot request", |
| 14 | ! |
value = "Create a boxplot of ALT AVAL by treatment over all visits.", |
| 15 | ! |
width = "100%", |
| 16 | ! |
rows = 4 |
| 17 |
), |
|
| 18 | ! |
shiny::checkboxInput( |
| 19 | ! |
ns("confirm_free_scale"),
|
| 20 | ! |
paste( |
| 21 | ! |
"I understand free Y scales create an Experimental/Draft revision" |
| 22 |
), |
|
| 23 | ! |
value = FALSE |
| 24 |
), |
|
| 25 | ! |
.assurance_task_button( |
| 26 | ! |
ns("submit"), "Create revision",
|
| 27 | ! |
class = "btn-primary" |
| 28 |
), |
|
| 29 | ! |
shiny::uiOutput(ns("message"))
|
| 30 |
) |
|
| 31 |
) |
|
| 32 |
} |
|
| 33 | ||
| 34 |
mod_prompt_server <- function(id, catalog, create_revision) {
|
|
| 35 | 1x |
shiny::moduleServer(id, function(input, output, session) {
|
| 36 | 1x |
shiny::observe({
|
| 37 | 1x |
choices <- stats::setNames(catalog()$dataset_id, catalog()$purpose) |
| 38 | 1x |
shiny::updateSelectInput(session, "dataset_id", choices = choices) |
| 39 |
}) |
|
| 40 | ||
| 41 | 1x |
message <- shiny::reactiveVal(NULL) |
| 42 | 1x |
output$message <- shiny::renderUI({
|
| 43 | 1x |
text <- message() |
| 44 | 1x |
if (is.null(text)) {
|
| 45 | 1x |
return(NULL) |
| 46 |
} |
|
| 47 | ! |
div(class = "assurance-message", role = "status", text) |
| 48 |
}) |
|
| 49 | ||
| 50 | 1x |
shiny::observeEvent(input$submit, |
| 51 |
{
|
|
| 52 | 1x |
message(NULL) |
| 53 | 1x |
tryCatch( |
| 54 | 1x |
create_revision( |
| 55 | 1x |
dataset_id = input$dataset_id, |
| 56 | 1x |
prompt = input$prompt, |
| 57 | 1x |
confirm_free_scale = isTRUE(input$confirm_free_scale) |
| 58 |
), |
|
| 59 | 1x |
error = function(error) {
|
| 60 | ! |
message(conditionMessage(error)) |
| 61 |
} |
|
| 62 |
) |
|
| 63 |
}, |
|
| 64 | 1x |
ignoreInit = TRUE |
| 65 |
) |
|
| 66 |
}) |
|
| 67 |
} |
|
| 68 | ||
| 69 |
.assurance_task_button <- function(input_id, label, ...) {
|
|
| 70 | 17x |
task_button <- get0( |
| 71 | 17x |
"input_task_button", |
| 72 | 17x |
envir = asNamespace("bslib"),
|
| 73 | 17x |
inherits = FALSE |
| 74 |
) |
|
| 75 | 17x |
if (is.function(task_button)) {
|
| 76 | 17x |
return(task_button(input_id, label, ...)) |
| 77 |
} |
|
| 78 | ! |
shiny::actionButton(input_id, label, ...) |
| 79 |
} |
| 1 |
.validate_workspace_provider <- function(provider) {
|
|
| 2 | 19x |
capabilities <- c( |
| 3 | 19x |
"list_destinations", "stage_bundle", "publish_staged", |
| 4 | 19x |
"inspect_published", "quarantine_staged", "reconcile" |
| 5 |
) |
|
| 6 | 19x |
if (!is.list(provider) || !all(vapply( |
| 7 | 19x |
capabilities, |
| 8 | 19x |
\(capability) is.function(provider[[capability]]), |
| 9 | 19x |
logical(1) |
| 10 |
))) {
|
|
| 11 | ! |
cli::cli_abort( |
| 12 | ! |
"Workspace provider must implement {.fn {capabilities}}"
|
| 13 |
) |
|
| 14 |
} |
|
| 15 | 19x |
invisible(provider) |
| 16 |
} |
|
| 17 | ||
| 18 |
.validate_workspace_token <- function(value, name = "workspace token") {
|
|
| 19 | 154x |
reserved <- c( |
| 20 | 154x |
"CON", "PRN", "AUX", "NUL", |
| 21 | 154x |
paste0("COM", 1:9), paste0("LPT", 1:9)
|
| 22 |
) |
|
| 23 | 154x |
if (!checkmate::test_string(value, min.chars = 1L) || |
| 24 | 154x |
!grepl("^[A-Za-z0-9._-]+$", value) ||
|
| 25 | 154x |
value %in% c(".", "..") ||
|
| 26 | 154x |
toupper(tools::file_path_sans_ext(value)) %in% reserved) {
|
| 27 | 5x |
cli::cli_abort("{.arg {name}} must be a safe logical identifier")
|
| 28 |
} |
|
| 29 | 149x |
invisible(value) |
| 30 |
} |
|
| 31 | ||
| 32 |
.workspace_pair_hashes <- function(path) {
|
|
| 33 | 41x |
code_path <- fs::path(path, "script.R") |
| 34 | 41x |
image_path <- fs::path(path, "plot.png") |
| 35 | 41x |
if (!fs::file_exists(code_path) || !fs::file_exists(image_path)) {
|
| 36 | 8x |
return(NULL) |
| 37 |
} |
|
| 38 | 33x |
list( |
| 39 | 33x |
code_hash = digest::digest(file = code_path, algo = "sha256"), |
| 40 | 33x |
image_hash = digest::digest(file = image_path, algo = "sha256") |
| 41 |
) |
|
| 42 |
} |
| 1 |
local_artifact_store <- function(root) {
|
|
| 2 | 47x |
checkmate::assert_string(root, min.chars = 1) |
| 3 | 47x |
fs::dir_create(root, recurse = TRUE) |
| 4 | 47x |
root <- fs::path_abs(root) |
| 5 | ||
| 6 | 47x |
artifact_path <- function(hash) {
|
| 7 | 325x |
.validate_artifact_hash(hash) |
| 8 | 325x |
fs::path(root, substr(hash, 1L, 2L), hash) |
| 9 |
} |
|
| 10 | ||
| 11 | 47x |
put <- function(bytes) {
|
| 12 | 78x |
if (!is.raw(bytes)) {
|
| 13 | ! |
cli::cli_abort("Artifact content must be a raw vector")
|
| 14 |
} |
|
| 15 | 78x |
hash <- digest::digest(bytes, algo = "sha256", serialize = FALSE) |
| 16 | 78x |
path <- artifact_path(hash) |
| 17 | 78x |
if (fs::file_exists(path)) {
|
| 18 | 16x |
verify(hash) |
| 19 |
} else {
|
|
| 20 | 62x |
fs::dir_create(fs::path_dir(path), recurse = TRUE) |
| 21 | 62x |
temporary <- fs::file_temp( |
| 22 | 62x |
tmp_dir = fs::path_dir(path), |
| 23 | 62x |
pattern = "artifact-" |
| 24 |
) |
|
| 25 | 62x |
writeBin(bytes, temporary) |
| 26 | 62x |
if (!identical(digest::digest(file = temporary, algo = "sha256"), hash)) {
|
| 27 | ! |
fs::file_delete(temporary) |
| 28 | ! |
cli::cli_abort("Artifact hash verification failed before publication")
|
| 29 |
} |
|
| 30 | 62x |
if (!fs::file_exists(path)) {
|
| 31 | 62x |
fs::file_move(temporary, path) |
| 32 |
} else {
|
|
| 33 | ! |
fs::file_delete(temporary) |
| 34 |
} |
|
| 35 |
} |
|
| 36 | 77x |
hash |
| 37 |
} |
|
| 38 | ||
| 39 | 47x |
get <- function(hash) {
|
| 40 | 32x |
path <- artifact_path(hash) |
| 41 | 32x |
if (!fs::file_exists(path)) {
|
| 42 | ! |
cli::cli_abort("Artifact {.val {hash}} does not exist")
|
| 43 |
} |
|
| 44 | 32x |
verify(hash) |
| 45 | 31x |
readBin(path, what = "raw", n = fs::file_size(path)) |
| 46 |
} |
|
| 47 | ||
| 48 | 47x |
verify <- function(hash) {
|
| 49 | 209x |
path <- artifact_path(hash) |
| 50 | 209x |
if (!fs::file_exists(path) || |
| 51 | 209x |
!identical(digest::digest(file = path, algo = "sha256"), hash)) {
|
| 52 | 5x |
cli::cli_abort("Artifact hash verification failed for {.val {hash}}")
|
| 53 |
} |
|
| 54 | 204x |
TRUE |
| 55 |
} |
|
| 56 | ||
| 57 | 47x |
exists <- function(hash) unname(fs::file_exists(artifact_path(hash))) |
| 58 | ||
| 59 | 47x |
cleanup_orphans <- function(referenced_hashes, minimum_age_seconds) {
|
| 60 | 2x |
if (!is.character(referenced_hashes)) {
|
| 61 | ! |
cli::cli_abort("Referenced artifact hashes must be a character vector")
|
| 62 |
} |
|
| 63 | 2x |
if (length(referenced_hashes)) {
|
| 64 | 2x |
invisible(lapply(referenced_hashes, .validate_artifact_hash)) |
| 65 |
} |
|
| 66 | 2x |
files <- fs::dir_ls(root, recurse = TRUE, type = "file", fail = FALSE) |
| 67 | 2x |
relative <- fs::path_rel(files, start = root) |
| 68 | 2x |
components <- strsplit(relative, "[/\\\\]") |
| 69 | 2x |
is_artifact <- vapply(components, function(parts) {
|
| 70 | 4x |
length(parts) == 2L && |
| 71 | 4x |
grepl("^[0-9a-f]{2}$", parts[[1]]) &&
|
| 72 | 4x |
grepl("^[0-9a-f]{64}$", parts[[2]]) &&
|
| 73 | 4x |
identical(parts[[1]], substr(parts[[2]], 1L, 2L)) |
| 74 | 2x |
}, logical(1)) |
| 75 | 2x |
artifact_paths <- files[is_artifact] |
| 76 | 2x |
orphan_paths <- artifact_paths[ |
| 77 | 2x |
!fs::path_file(artifact_paths) %in% referenced_hashes |
| 78 |
] |
|
| 79 | 2x |
if (length(orphan_paths)) {
|
| 80 | 1x |
age_seconds <- as.numeric( |
| 81 | 1x |
difftime( |
| 82 | 1x |
Sys.time(), |
| 83 | 1x |
fs::file_info(orphan_paths)$modification_time, |
| 84 | 1x |
units = "secs" |
| 85 |
) |
|
| 86 |
) |
|
| 87 | 1x |
orphan_paths <- orphan_paths[age_seconds >= minimum_age_seconds] |
| 88 |
} |
|
| 89 | 2x |
orphan_hashes <- fs::path_file(orphan_paths) |
| 90 | 1x |
if (length(orphan_paths)) fs::file_delete(orphan_paths) |
| 91 | 2x |
orphan_hashes |
| 92 |
} |
|
| 93 | ||
| 94 | 47x |
structure( |
| 95 | 47x |
list( |
| 96 | 47x |
root = fs::path_abs(root), put = put, get = get, verify = verify, |
| 97 | 47x |
exists = exists, path = artifact_path, .cleanup_orphans = cleanup_orphans |
| 98 |
), |
|
| 99 | 47x |
class = "local_artifact_store" |
| 100 |
) |
|
| 101 |
} |
| 1 |
.new_profile_diagnostic <- function( |
|
| 2 |
code, |
|
| 3 |
message, |
|
| 4 |
count = NULL, |
|
| 5 |
integrity_ref = NULL |
|
| 6 |
) {
|
|
| 7 | 73x |
compact <- list(code = code, message = message) |
| 8 | 71x |
if (!is.null(count)) compact$count <- as.integer(count) |
| 9 | 71x |
if (!is.null(integrity_ref)) compact$integrity_ref <- integrity_ref |
| 10 | 73x |
compact |
| 11 |
} |
|
| 12 | ||
| 13 |
.resolve_low_n_policy <- function(low_n_threshold) {
|
|
| 14 | 68x |
if (is.numeric(low_n_threshold) && length(low_n_threshold) == 1L && |
| 15 | 68x |
!is.na(low_n_threshold) && identical(as.numeric(low_n_threshold), 5)) {
|
| 16 | 66x |
return(list( |
| 17 | 66x |
value = 5, |
| 18 | 66x |
rationale = "Assurance Cell default", |
| 19 | 66x |
authority = "plot-pattern-assurance-cell-v1", |
| 20 | 66x |
version = "low-n-default-v1" |
| 21 |
)) |
|
| 22 |
} |
|
| 23 | 2x |
required <- c("value", "rationale", "authority", "version")
|
| 24 | 2x |
valid <- is.list(low_n_threshold) && |
| 25 | 2x |
identical(sort(names(low_n_threshold)), sort(required)) && |
| 26 | 2x |
checkmate::test_number(low_n_threshold$value, lower = 1, finite = TRUE) && |
| 27 | 2x |
all(vapply( |
| 28 | 2x |
low_n_threshold[required[-1]], |
| 29 | 2x |
checkmate::test_string, |
| 30 | 2x |
logical(1), |
| 31 | 2x |
min.chars = 1L |
| 32 |
)) |
|
| 33 | 2x |
if (!valid) {
|
| 34 | 1x |
cli::cli_abort( |
| 35 | 1x |
paste0( |
| 36 | 1x |
"A non-default low-N threshold requires sponsor-approved value, ", |
| 37 | 1x |
"rationale, authority, and version" |
| 38 |
) |
|
| 39 |
) |
|
| 40 |
} |
|
| 41 | 1x |
low_n_threshold$value <- as.numeric(low_n_threshold$value) |
| 42 | 1x |
low_n_threshold |
| 43 |
} |
|
| 44 | ||
| 45 |
.profile_result <- function(snapshot, selections, choices, policy) {
|
|
| 46 | 67x |
structure( |
| 47 | 67x |
list( |
| 48 | 67x |
profile_version = "visit-based-numeric-bds-v1", |
| 49 | 67x |
snapshot_id = snapshot$snapshot_id, |
| 50 | 67x |
snapshot_hash = snapshot$content_hash, |
| 51 | 67x |
blocked = FALSE, |
| 52 | 67x |
dataset_conformance = "not_assessed", |
| 53 | 67x |
selections = selections, |
| 54 | 67x |
choices = choices, |
| 55 | 67x |
facet_levels = choices$included_treatment_levels, |
| 56 | 67x |
visit_levels = choices$included_visits, |
| 57 | 67x |
low_n_policy = policy, |
| 58 | 67x |
selected_data = data.frame(), |
| 59 | 67x |
analysis_data = data.frame(), |
| 60 | 67x |
display_data = data.frame(), |
| 61 | 67x |
counts = data.frame(), |
| 62 | 67x |
blocking_diagnostics = list(), |
| 63 | 67x |
warnings = list(), |
| 64 | 67x |
authorized_diagnostics = list(), |
| 65 | 67x |
durable_diagnostics = list(), |
| 66 | 67x |
analytical_input_hash = NULL |
| 67 |
), |
|
| 68 | 67x |
class = "bds_profile_result" |
| 69 |
) |
|
| 70 |
} |
|
| 71 | ||
| 72 |
.block_profile <- function(result, diagnostic, authorized = list()) {
|
|
| 73 | 17x |
result$blocked <- TRUE |
| 74 | 17x |
result$blocking_diagnostics <- list(diagnostic) |
| 75 | 17x |
result$durable_diagnostics <- list(diagnostic) |
| 76 | 17x |
result$authorized_diagnostics <- authorized |
| 77 | 17x |
result |
| 78 |
} |
|
| 79 | ||
| 80 |
.sort_profile_data <- function(data, treatment_variable) {
|
|
| 81 | 105x |
columns <- intersect( |
| 82 | 105x |
c(treatment_variable, "AVISITN", "AVISIT", "USUBJID", "PARAMCD"), |
| 83 | 105x |
names(data) |
| 84 |
) |
|
| 85 | 105x |
ordering <- do.call( |
| 86 | 105x |
order, |
| 87 | 105x |
c(data[columns], list(method = "radix", na.last = TRUE)) |
| 88 |
) |
|
| 89 | 105x |
result <- data[ordering, , drop = FALSE] |
| 90 | 105x |
row.names(result) <- NULL |
| 91 | 105x |
result |
| 92 |
} |
|
| 93 | ||
| 94 |
.minimize_analysis_data <- function(data, treatment_variable, y_variable) {
|
|
| 95 | 50x |
result <- data[ |
| 96 | 50x |
unique(c( |
| 97 | 50x |
"USUBJID", "AVISIT", "AVISITN", treatment_variable, y_variable |
| 98 |
)) |
|
| 99 |
] |
|
| 100 | 50x |
subject_ids <- sort(unique(as.character(result$USUBJID)), method = "radix") |
| 101 | 50x |
prefix <- "SUBJ" |
| 102 | 50x |
repeat {
|
| 103 | 51x |
pseudonyms <- sprintf("%s-%06d", prefix, seq_along(subject_ids))
|
| 104 | 50x |
if (!any(pseudonyms %in% subject_ids)) break |
| 105 | 1x |
prefix <- paste0("PSEUDO-", prefix)
|
| 106 |
} |
|
| 107 | 50x |
names(pseudonyms) <- subject_ids |
| 108 | 50x |
result$USUBJID <- unname(pseudonyms[as.character(result$USUBJID)]) |
| 109 | 50x |
result |
| 110 |
} |
|
| 111 | ||
| 112 |
.validate_profile_inputs <- function(snapshot, selections) {
|
|
| 113 | 68x |
if (!inherits(snapshot, "study_data_snapshot")) {
|
| 114 | ! |
cli::cli_abort("{.arg snapshot} must be a {.cls study_data_snapshot}")
|
| 115 |
} |
|
| 116 | 68x |
required <- c( |
| 117 | 68x |
"paramcd", "y_variable", "unit", "treatment_variable", |
| 118 | 68x |
"treatment_levels", "visits" |
| 119 |
) |
|
| 120 | 68x |
if (!is.list(selections) || !setequal(names(selections), required)) {
|
| 121 | ! |
cli::cli_abort(paste0( |
| 122 | ! |
"{.arg selections} must contain the complete visit-based ",
|
| 123 | ! |
"BDS selection fields" |
| 124 |
)) |
|
| 125 |
} |
|
| 126 | 68x |
if (!checkmate::test_string(selections$paramcd, min.chars = 1L) || |
| 127 | 68x |
!checkmate::test_choice( |
| 128 | 68x |
selections$y_variable, |
| 129 | 68x |
c("AVAL", "CHG", "PCHG")
|
| 130 |
) || |
|
| 131 | 68x |
!checkmate::test_string( |
| 132 | 68x |
selections$treatment_variable, |
| 133 | 68x |
min.chars = 1L |
| 134 |
)) {
|
|
| 135 | ! |
cli::cli_abort(paste0( |
| 136 | ! |
"The parameter, Y variable, and treatment selections must each be ", |
| 137 | ! |
"singular and valid" |
| 138 |
)) |
|
| 139 |
} |
|
| 140 | 68x |
invisible(TRUE) |
| 141 |
} |
|
| 142 | ||
| 143 |
#' Validate the supported visit-based numeric BDS profile |
|
| 144 |
#' |
|
| 145 |
#' Explicit parameter, unit, treatment, and visit selections are applied before |
|
| 146 |
#' visit-key and selected-Y checks. Blocking diagnostics are separated from |
|
| 147 |
#' nonblocking sparse-data warnings. |
|
| 148 |
#' |
|
| 149 |
#' @param snapshot A pinned `study_data_snapshot`. |
|
| 150 |
#' @param selections A complete list containing `paramcd`, `y_variable`, `unit`, |
|
| 151 |
#' `treatment_variable`, `treatment_levels`, and `visits`. |
|
| 152 |
#' @param low_n_threshold The default value `5`, or a sponsor-approved list with |
|
| 153 |
#' `value`, `rationale`, `authority`, and `version`. |
|
| 154 |
#' |
|
| 155 |
#' @return A deterministic `bds_profile_result`. |
|
| 156 |
#' @export |
|
| 157 |
validate_bds_profile <- function(snapshot, selections, low_n_threshold = 5) {
|
|
| 158 | 68x |
.validate_profile_inputs(snapshot, selections) |
| 159 | 68x |
policy <- .resolve_low_n_policy(low_n_threshold) |
| 160 | 67x |
data <- snapshot$data |
| 161 | 67x |
treatment <- selections$treatment_variable |
| 162 | 67x |
y <- selections$y_variable |
| 163 | 67x |
choices <- list( |
| 164 | 67x |
parameters = sort( |
| 165 | 67x |
unique(stats::na.omit(as.character(data$PARAMCD))), |
| 166 | 67x |
method = "radix" |
| 167 |
), |
|
| 168 | 67x |
y_variables = intersect(c("AVAL", "CHG", "PCHG"), names(data)),
|
| 169 | 67x |
units = character(), |
| 170 | 67x |
treatment_variables = snapshot$permitted_treatment_variables, |
| 171 | 67x |
treatment_levels = character(), |
| 172 | 67x |
visits = character(), |
| 173 | 67x |
included_treatment_levels = character(), |
| 174 | 67x |
included_visits = character() |
| 175 |
) |
|
| 176 | 67x |
result <- .profile_result(snapshot, selections, choices, policy) |
| 177 | ||
| 178 | 67x |
classification_allowed <- if (is.null(snapshot$classification)) {
|
| 179 | 8x |
identical(snapshot$source_metadata$source, "direct_upload") |
| 180 |
} else {
|
|
| 181 | 59x |
snapshot$classification %in% c("public", "synthetic", "deidentified")
|
| 182 |
} |
|
| 183 | 67x |
if (!classification_allowed || |
| 184 | 67x |
!identical(snapshot$adam_designation, "BDS")) {
|
| 185 | 1x |
return(.block_profile(result, .new_profile_diagnostic( |
| 186 | 1x |
"unsupported_source_metadata", |
| 187 | 1x |
paste0( |
| 188 | 1x |
"The authorized source is outside the permitted BDS profile ", |
| 189 | 1x |
"metadata envelope" |
| 190 |
) |
|
| 191 |
))) |
|
| 192 |
} |
|
| 193 | 66x |
required_columns <- unique(c( |
| 194 | 66x |
"USUBJID", "PARAMCD", "AVISIT", "AVISITN", y, treatment, |
| 195 | 66x |
if ("AVALU" %in% names(data)) "AVALU"
|
| 196 |
)) |
|
| 197 | 66x |
missing_columns <- setdiff(required_columns, names(data)) |
| 198 | 66x |
if (length(missing_columns)) {
|
| 199 | 1x |
return(.block_profile(result, .new_profile_diagnostic( |
| 200 | 1x |
"missing_required_variables", |
| 201 | 1x |
paste0( |
| 202 | 1x |
"The selected source lacks variables required by the supported ", |
| 203 | 1x |
"BDS profile" |
| 204 |
), |
|
| 205 | 1x |
length(missing_columns), |
| 206 | 1x |
canonical_hash(sort(missing_columns, method = "radix")) |
| 207 |
))) |
|
| 208 |
} |
|
| 209 | 65x |
if (!treatment %in% snapshot$permitted_treatment_variables) {
|
| 210 | ! |
return(.block_profile(result, .new_profile_diagnostic( |
| 211 | ! |
"treatment_not_permitted", |
| 212 | ! |
paste0( |
| 213 | ! |
"The selected treatment variable is not permitted by the ", |
| 214 | ! |
"authorized catalog" |
| 215 |
) |
|
| 216 |
))) |
|
| 217 |
} |
|
| 218 | 65x |
if (!selections$paramcd %in% choices$parameters) {
|
| 219 | ! |
return(.block_profile(result, .new_profile_diagnostic( |
| 220 | ! |
"parameter_not_available", |
| 221 | ! |
"The selected parameter is not present in the authorized snapshot" |
| 222 |
))) |
|
| 223 |
} |
|
| 224 | 65x |
if (!is.numeric(data[[y]])) {
|
| 225 | 1x |
return(.block_profile(result, .new_profile_diagnostic( |
| 226 | 1x |
"selected_y_not_numeric", |
| 227 | 1x |
"The selected Y variable must be numeric for this BDS profile" |
| 228 |
))) |
|
| 229 |
} |
|
| 230 | ||
| 231 | 64x |
selected <- data[ |
| 232 | 64x |
data$PARAMCD == selections$paramcd & !is.na(data$PARAMCD), , |
| 233 | 64x |
drop = FALSE |
| 234 |
] |
|
| 235 | 64x |
units <- if ("AVALU" %in% names(selected)) {
|
| 236 | 64x |
sort(unique(stats::na.omit(as.character(selected$AVALU))), method = "radix") |
| 237 |
} else {
|
|
| 238 | ! |
character() |
| 239 |
} |
|
| 240 | 64x |
result$choices$units <- units |
| 241 | 64x |
if (length(units) > 1L && is.null(selections$unit)) {
|
| 242 | 2x |
return(.block_profile(result, .new_profile_diagnostic( |
| 243 | 2x |
"unit_selection_required", |
| 244 | 2x |
"Multiple non-missing units require one explicit unit selection", |
| 245 | 2x |
length(units), |
| 246 | 2x |
canonical_hash(units) |
| 247 |
))) |
|
| 248 |
} |
|
| 249 | 62x |
if (!is.null(selections$unit)) {
|
| 250 | 62x |
if (!checkmate::test_string(selections$unit, min.chars = 1L) || |
| 251 | 62x |
!selections$unit %in% units) {
|
| 252 | ! |
return(.block_profile(result, .new_profile_diagnostic( |
| 253 | ! |
"unit_not_available", |
| 254 | ! |
"The selected unit is not present for the selected parameter" |
| 255 |
))) |
|
| 256 |
} |
|
| 257 | 62x |
selected <- selected[ |
| 258 | 62x |
!is.na(selected$AVALU) & selected$AVALU == selections$unit, , |
| 259 | 62x |
drop = FALSE |
| 260 |
] |
|
| 261 |
} |
|
| 262 | ||
| 263 | 62x |
missing_subject <- is.na(selected$USUBJID) | |
| 264 | 62x |
!nzchar(as.character(selected$USUBJID)) |
| 265 | 62x |
if (any(missing_subject)) {
|
| 266 | 1x |
return(.block_profile(result, .new_profile_diagnostic( |
| 267 | 1x |
"missing_profile_key", |
| 268 | 1x |
"The selected records contain a missing supported-profile key value", |
| 269 | 1x |
sum(missing_subject), |
| 270 | 1x |
canonical_hash(list( |
| 271 | 1x |
snapshot_id = snapshot$snapshot_id, |
| 272 | 1x |
count = sum(missing_subject) |
| 273 |
)) |
|
| 274 |
))) |
|
| 275 |
} |
|
| 276 | 61x |
missing_treatment <- is.na(selected[[treatment]]) | |
| 277 | 61x |
!nzchar(as.character(selected[[treatment]])) |
| 278 | 61x |
if (any(missing_treatment)) {
|
| 279 | 1x |
return(.block_profile(result, .new_profile_diagnostic( |
| 280 | 1x |
"missing_treatment_value", |
| 281 | 1x |
paste0( |
| 282 | 1x |
"The selected records contain a missing treatment value required ", |
| 283 | 1x |
"for faceting" |
| 284 |
), |
|
| 285 | 1x |
sum(missing_treatment), |
| 286 | 1x |
canonical_hash(list( |
| 287 | 1x |
snapshot_id = snapshot$snapshot_id, |
| 288 | 1x |
count = sum(missing_treatment) |
| 289 |
)) |
|
| 290 |
))) |
|
| 291 |
} |
|
| 292 | ||
| 293 | 60x |
treatment_levels <- sort( |
| 294 | 60x |
unique(stats::na.omit(as.character(selected[[treatment]]))), |
| 295 | 60x |
method = "radix" |
| 296 |
) |
|
| 297 | 60x |
result$choices$treatment_levels <- treatment_levels |
| 298 | 60x |
included_treatments <- selections$treatment_levels |
| 299 | ! |
if (is.null(included_treatments)) included_treatments <- treatment_levels |
| 300 | 60x |
if (!checkmate::test_character( |
| 301 | 60x |
included_treatments, |
| 302 | 60x |
any.missing = FALSE, |
| 303 | 60x |
unique = TRUE |
| 304 |
) || |
|
| 305 | 60x |
length(setdiff(included_treatments, treatment_levels))) {
|
| 306 | ! |
return(.block_profile(result, .new_profile_diagnostic( |
| 307 | ! |
"treatment_level_not_available", |
| 308 | ! |
paste0( |
| 309 | ! |
"Every included treatment level must be present in the ", |
| 310 | ! |
"authorized snapshot" |
| 311 |
) |
|
| 312 |
))) |
|
| 313 |
} |
|
| 314 | 60x |
included_treatments <- treatment_levels[ |
| 315 | 60x |
treatment_levels %in% included_treatments |
| 316 |
] |
|
| 317 | 60x |
selected <- selected[ |
| 318 | 60x |
!is.na(selected[[treatment]]) & |
| 319 | 60x |
selected[[treatment]] %in% included_treatments, , |
| 320 | 60x |
drop = FALSE |
| 321 |
] |
|
| 322 | ||
| 323 | 60x |
available_visits <- unique(data.frame( |
| 324 | 60x |
AVISIT = as.character(selected$AVISIT), |
| 325 | 60x |
AVISITN = selected$AVISITN, |
| 326 | 60x |
stringsAsFactors = FALSE |
| 327 |
)) |
|
| 328 | 60x |
requested_visits <- selections$visits |
| 329 | 60x |
if (!is.null(requested_visits)) {
|
| 330 | 60x |
if (!checkmate::test_character( |
| 331 | 60x |
requested_visits, |
| 332 | 60x |
any.missing = FALSE, |
| 333 | 60x |
unique = TRUE |
| 334 |
)) {
|
|
| 335 | ! |
return(.block_profile(result, .new_profile_diagnostic( |
| 336 | ! |
"visit_not_available", |
| 337 | ! |
"Included visits must be unique non-missing labels" |
| 338 |
))) |
|
| 339 |
} |
|
| 340 | 60x |
selected <- selected[ |
| 341 | 60x |
is.na(selected$AVISIT) | selected$AVISIT %in% requested_visits, , |
| 342 | 60x |
drop = FALSE |
| 343 |
] |
|
| 344 | 60x |
available_visits <- available_visits[ |
| 345 | 60x |
!is.na(available_visits$AVISIT) & |
| 346 | 60x |
available_visits$AVISIT %in% requested_visits, , |
| 347 | 60x |
drop = FALSE |
| 348 |
] |
|
| 349 |
} |
|
| 350 | ||
| 351 | 60x |
mapping_missing <- any( |
| 352 | 60x |
is.na(selected$AVISIT) | |
| 353 | 60x |
!nzchar(as.character(selected$AVISIT)) | |
| 354 | 60x |
is.na(selected$AVISITN) |
| 355 |
) |
|
| 356 | 60x |
label_counts <- tapply( |
| 357 | 60x |
selected$AVISITN, |
| 358 | 60x |
selected$AVISIT, |
| 359 | 60x |
function(x) length(unique(x[!is.na(x)])) |
| 360 |
) |
|
| 361 | 60x |
order_counts <- tapply( |
| 362 | 60x |
as.character(selected$AVISIT), |
| 363 | 60x |
selected$AVISITN, |
| 364 | 60x |
function(x) length(unique(x[!is.na(x)])) |
| 365 |
) |
|
| 366 | 60x |
mapping_conflict <- any(label_counts != 1L) || any(order_counts != 1L) |
| 367 | 60x |
if (mapping_missing || mapping_conflict) {
|
| 368 | 5x |
mapping_ref <- canonical_hash(list( |
| 369 | 5x |
missing_count = sum(is.na(selected$AVISIT) | is.na(selected$AVISITN)), |
| 370 | 5x |
mappings = .normalize_study_data(available_visits) |
| 371 |
)) |
|
| 372 | 5x |
return(.block_profile(result, .new_profile_diagnostic( |
| 373 | 5x |
"invalid_visit_mapping", |
| 374 | 5x |
paste0( |
| 375 | 5x |
"The selected AVISIT and AVISITN mapping does not provide a ", |
| 376 | 5x |
"deterministic visit order" |
| 377 |
), |
|
| 378 | 5x |
nrow(selected), |
| 379 | 5x |
mapping_ref |
| 380 |
))) |
|
| 381 |
} |
|
| 382 | ||
| 383 | 55x |
mapping <- unique(selected[c("AVISIT", "AVISITN")])
|
| 384 | 55x |
mapping <- mapping[ |
| 385 | 55x |
order(mapping$AVISITN, mapping$AVISIT, method = "radix"), , |
| 386 | 55x |
drop = FALSE |
| 387 |
] |
|
| 388 | 55x |
available_visit_labels <- as.character(mapping$AVISIT) |
| 389 | 55x |
if (!is.null(requested_visits) && |
| 390 | 55x |
length(setdiff(requested_visits, available_visit_labels))) {
|
| 391 | ! |
return(.block_profile(result, .new_profile_diagnostic( |
| 392 | ! |
"visit_not_available", |
| 393 | ! |
paste0( |
| 394 | ! |
"Every included visit must be present after explicit parameter, ", |
| 395 | ! |
"unit, and treatment selections" |
| 396 |
) |
|
| 397 |
))) |
|
| 398 |
} |
|
| 399 | 55x |
included_visits <- available_visit_labels |
| 400 | 55x |
result$choices$visits <- available_visit_labels |
| 401 | 55x |
result$choices$included_treatment_levels <- included_treatments |
| 402 | 55x |
result$choices$included_visits <- included_visits |
| 403 | 55x |
result$facet_levels <- included_treatments |
| 404 | 55x |
result$visit_levels <- included_visits |
| 405 | ||
| 406 | 55x |
key_columns <- c("USUBJID", "PARAMCD", "AVISIT")
|
| 407 | 55x |
duplicate_rows <- duplicated(selected[key_columns]) | |
| 408 | 55x |
duplicated(selected[key_columns], fromLast = TRUE) |
| 409 | 55x |
if (any(duplicate_rows)) {
|
| 410 | 5x |
duplicate_keys <- unique(selected[ |
| 411 | 5x |
duplicate_rows, key_columns, |
| 412 | 5x |
drop = FALSE |
| 413 |
]) |
|
| 414 | 5x |
duplicate_keys <- .sort_profile_data(duplicate_keys, "PARAMCD") |
| 415 | 5x |
additional_keys <- setdiff(snapshot$declared_keys, c( |
| 416 | 5x |
"STUDYID", "USUBJID", "PARAMCD", "AVISIT", "AVISITN" |
| 417 |
)) |
|
| 418 | 5x |
code <- if (length(additional_keys)) {
|
| 419 | 2x |
"unsupported_additional_timepoint_keys" |
| 420 |
} else {
|
|
| 421 | 3x |
"unsupported_duplicate_visit_key" |
| 422 |
} |
|
| 423 | 5x |
message <- if (length(additional_keys)) {
|
| 424 | 2x |
paste0( |
| 425 | 2x |
"The ADaM source declares additional legitimate keys and is ", |
| 426 | 2x |
"unsupported by this visit-based cell" |
| 427 |
) |
|
| 428 |
} else {
|
|
| 429 | 3x |
paste0( |
| 430 | 3x |
"Duplicate subject-parameter-visit keys are unsupported by this ", |
| 431 | 3x |
"visit-based cell" |
| 432 |
) |
|
| 433 |
} |
|
| 434 | 5x |
diagnostic <- .new_profile_diagnostic( |
| 435 | 5x |
code, |
| 436 | 5x |
message, |
| 437 | 5x |
nrow(duplicate_keys), |
| 438 | 5x |
canonical_hash(.normalize_study_data(duplicate_keys)) |
| 439 |
) |
|
| 440 | 5x |
return(.block_profile( |
| 441 | 5x |
result, |
| 442 | 5x |
diagnostic, |
| 443 | 5x |
list(duplicate_keys = duplicate_keys, additional_keys = additional_keys) |
| 444 |
)) |
|
| 445 |
} |
|
| 446 | ||
| 447 | 50x |
selected <- .sort_profile_data(selected, treatment) |
| 448 | 50x |
display <- selected[!is.na(selected[[y]]), , drop = FALSE] |
| 449 | 50x |
display <- .sort_profile_data(display, treatment) |
| 450 | 50x |
if (nrow(display)) {
|
| 451 | 50x |
groups <- split( |
| 452 | 50x |
seq_len(nrow(display)), |
| 453 | 50x |
interaction( |
| 454 | 50x |
display[[treatment]], display$AVISIT, |
| 455 | 50x |
drop = TRUE, lex.order = TRUE |
| 456 |
) |
|
| 457 |
) |
|
| 458 | 50x |
count_rows <- lapply(groups, function(index) {
|
| 459 | 280x |
rows <- display[index, , drop = FALSE] |
| 460 | 280x |
source_rows <- selected[ |
| 461 | 280x |
selected[[treatment]] == rows[[treatment]][1] & |
| 462 | 280x |
selected$AVISIT == rows$AVISIT[1], , |
| 463 | 280x |
drop = FALSE |
| 464 |
] |
|
| 465 | 280x |
data.frame( |
| 466 | 280x |
treatment_value = as.character(rows[[treatment]][1]), |
| 467 | 280x |
AVISIT = as.character(rows$AVISIT[1]), |
| 468 | 280x |
AVISITN = rows$AVISITN[1], |
| 469 | 280x |
source_n = length(unique(source_rows$USUBJID)), |
| 470 | 280x |
n = length(unique(rows$USUBJID)), |
| 471 | 280x |
stringsAsFactors = FALSE |
| 472 |
) |
|
| 473 |
}) |
|
| 474 | 50x |
counts <- do.call(rbind, count_rows) |
| 475 | 50x |
names(counts)[1] <- treatment |
| 476 | 50x |
counts$source_n <- as.integer(counts$source_n) |
| 477 | 50x |
counts$n <- as.integer(counts$n) |
| 478 | 50x |
counts$low_n <- counts$n < policy$value |
| 479 | 50x |
counts <- counts[ |
| 480 | 50x |
order(counts[[treatment]], counts$AVISITN, method = "radix"), , |
| 481 | 50x |
drop = FALSE |
| 482 |
] |
|
| 483 | 50x |
row.names(counts) <- NULL |
| 484 |
} else {
|
|
| 485 | ! |
counts <- data.frame() |
| 486 |
} |
|
| 487 | ||
| 488 | 50x |
all_combinations <- expand.grid( |
| 489 | 50x |
treatment_value = included_treatments, |
| 490 | 50x |
AVISIT = included_visits, |
| 491 | 50x |
stringsAsFactors = FALSE |
| 492 |
) |
|
| 493 | 50x |
displayed_keys <- if (nrow(counts)) {
|
| 494 | 50x |
paste(counts[[treatment]], counts$AVISIT, sep = "\u001f") |
| 495 |
} else {
|
|
| 496 | ! |
character() |
| 497 |
} |
|
| 498 | 50x |
empty_combinations <- all_combinations[ |
| 499 | 50x |
!paste( |
| 500 | 50x |
all_combinations$treatment_value, |
| 501 | 50x |
all_combinations$AVISIT, |
| 502 | 50x |
sep = "\u001f" |
| 503 | 50x |
) %in% displayed_keys, , |
| 504 | 50x |
drop = FALSE |
| 505 |
] |
|
| 506 | 50x |
warnings <- list() |
| 507 | 50x |
if (nrow(counts) && any(counts$low_n)) {
|
| 508 | 50x |
sparse <- counts[ |
| 509 | 50x |
counts$low_n, c(treatment, "AVISIT", "AVISITN", "n"), |
| 510 | 50x |
drop = FALSE |
| 511 |
] |
|
| 512 | 50x |
warnings[[length(warnings) + 1L]] <- .new_profile_diagnostic( |
| 513 | 50x |
"low_sample_size", |
| 514 | 50x |
paste0( |
| 515 | 50x |
"One or more displayed treatment-visit boxes are below the ", |
| 516 | 50x |
"retained low-N threshold" |
| 517 |
), |
|
| 518 | 50x |
nrow(sparse), |
| 519 | 50x |
canonical_hash(sparse) |
| 520 |
) |
|
| 521 |
} |
|
| 522 | 50x |
if (nrow(empty_combinations)) {
|
| 523 | 4x |
warnings[[length(warnings) + 1L]] <- .new_profile_diagnostic( |
| 524 | 4x |
"empty_treatment_visit", |
| 525 | 4x |
paste0( |
| 526 | 4x |
"One or more included treatment-visit combinations have no ", |
| 527 | 4x |
"non-missing selected Y value" |
| 528 |
), |
|
| 529 | 4x |
nrow(empty_combinations), |
| 530 | 4x |
canonical_hash(empty_combinations) |
| 531 |
) |
|
| 532 |
} |
|
| 533 | 50x |
empty_treatments <- setdiff( |
| 534 | 50x |
included_treatments, |
| 535 | 50x |
unique(as.character(display[[treatment]])) |
| 536 |
) |
|
| 537 | 50x |
if (length(empty_treatments)) {
|
| 538 | 2x |
warnings[[length(warnings) + 1L]] <- .new_profile_diagnostic( |
| 539 | 2x |
"empty_treatment_level", |
| 540 | 2x |
"One or more included treatment levels remain as empty facets", |
| 541 | 2x |
length(empty_treatments), |
| 542 | 2x |
canonical_hash(empty_treatments) |
| 543 |
) |
|
| 544 |
} |
|
| 545 | ||
| 546 | 50x |
result$selected_data <- selected |
| 547 | 50x |
result$analysis_data <- .minimize_analysis_data(selected, treatment, y) |
| 548 | 50x |
result$display_data <- display |
| 549 | 50x |
result$counts <- counts |
| 550 | 50x |
result$warnings <- warnings |
| 551 | 50x |
result$durable_diagnostics <- warnings |
| 552 | 50x |
result$analytical_input_hash <- canonical_hash(list( |
| 553 | 50x |
data = .normalize_study_data(display), |
| 554 | 50x |
selections = selections, |
| 555 | 50x |
facet_levels = result$facet_levels, |
| 556 | 50x |
visit_levels = result$visit_levels, |
| 557 | 50x |
low_n_policy = policy |
| 558 |
)) |
|
| 559 | 50x |
result |
| 560 |
} |
| 1 |
new_execution_request <- function( |
|
| 2 |
spec, |
|
| 3 |
script_hash, |
|
| 4 |
snapshot_id, |
|
| 5 |
snapshot_hash, |
|
| 6 |
harness_version |
|
| 7 |
) {
|
|
| 8 | 24x |
.validate_plot_spec(spec) |
| 9 | 24x |
values <- list(script_hash, snapshot_id, snapshot_hash, harness_version) |
| 10 | 24x |
if (!all(vapply(values, checkmate::test_string, logical(1), min.chars = 1))) {
|
| 11 | ! |
cli::cli_abort(paste0( |
| 12 | ! |
"Execution requests require non-empty script, snapshot, ", |
| 13 | ! |
"and harness identifiers" |
| 14 |
)) |
|
| 15 |
} |
|
| 16 | 24x |
structure( |
| 17 | 24x |
list( |
| 18 | 24x |
manifest_version = "execution-request-v1", |
| 19 | 24x |
spec_hash = spec$hash, |
| 20 | 24x |
script_hash = script_hash, |
| 21 | 24x |
snapshot_id = snapshot_id, |
| 22 | 24x |
snapshot_hash = snapshot_hash, |
| 23 | 24x |
harness_version = harness_version |
| 24 |
), |
|
| 25 | 24x |
class = "execution_request" |
| 26 |
) |
|
| 27 |
} |
|
| 28 | ||
| 29 |
new_execution_result <- function( |
|
| 30 |
request, |
|
| 31 |
analytical_hash, |
|
| 32 |
image_hash, |
|
| 33 |
environment_fingerprint |
|
| 34 |
) {
|
|
| 35 | 20x |
if (!inherits(request, "execution_request")) {
|
| 36 | ! |
cli::cli_abort("{.arg request} must be an {.cls execution_request}")
|
| 37 |
} |
|
| 38 | 20x |
values <- list(analytical_hash, image_hash, environment_fingerprint) |
| 39 | 20x |
if (!all(vapply(values, checkmate::test_string, logical(1), min.chars = 1))) {
|
| 40 | ! |
cli::cli_abort(paste0( |
| 41 | ! |
"Execution results require analytical, image, ", |
| 42 | ! |
"and environment identifiers" |
| 43 |
)) |
|
| 44 |
} |
|
| 45 | 20x |
manifest <- unclass(request) |
| 46 | 20x |
manifest$manifest_version <- "execution-result-v1" |
| 47 | 20x |
structure( |
| 48 | 20x |
c(manifest, list( |
| 49 | 20x |
analytical_hash = analytical_hash, |
| 50 | 20x |
image_hash = image_hash, |
| 51 | 20x |
environment_fingerprint = environment_fingerprint |
| 52 |
)), |
|
| 53 | 20x |
class = "execution_result" |
| 54 |
) |
|
| 55 |
} |
| 1 |
.execution_hash_text <- function(value) {
|
|
| 2 | 66x |
digest::digest( |
| 3 | 66x |
.execution_text_bytes(value), |
| 4 | 66x |
algo = "sha256", |
| 5 | 66x |
serialize = FALSE |
| 6 |
) |
|
| 7 |
} |
|
| 8 | ||
| 9 |
.execution_text_bytes <- function(value) {
|
|
| 10 | 82x |
charToRaw(enc2utf8(paste(value, collapse = "\n"))) |
| 11 |
} |
|
| 12 | ||
| 13 |
.execution_hash_file <- function(path) {
|
|
| 14 | 29x |
digest::digest(file = path, algo = "sha256") |
| 15 |
} |
|
| 16 | ||
| 17 |
.bounded_text <- function(value, limit = 4000L) {
|
|
| 18 | 6x |
value <- paste(value, collapse = "\n") |
| 19 | 6x |
if (nchar(value, type = "bytes") <= limit) {
|
| 20 | 6x |
return(value) |
| 21 |
} |
|
| 22 | ! |
paste0(substr(value, 1L, limit), "\n[truncated]") |
| 23 |
} |
|
| 24 | ||
| 25 |
.execution_environment_details <- function() {
|
|
| 26 | 29x |
packages <- c( |
| 27 | 29x |
ADaMViz.SubmissionSync = utils::packageVersion("ADaMViz.SubmissionSync"),
|
| 28 | 29x |
ggplot2 = utils::packageVersion("ggplot2"),
|
| 29 | 29x |
patchwork = utils::packageVersion("patchwork")
|
| 30 |
) |
|
| 31 | 29x |
list( |
| 32 | 29x |
version = "execution-environment-details-v1", |
| 33 | 29x |
r_version = as.character(getRversion()), |
| 34 | 29x |
platform = R.version$platform, |
| 35 | 29x |
os = Sys.info()[["sysname"]], |
| 36 | 29x |
release = Sys.info()[["release"]], |
| 37 | 29x |
packages = lapply(packages, as.character), |
| 38 | 29x |
graphics_device = "png", |
| 39 | 29x |
image_width = NA_integer_, |
| 40 | 29x |
image_height = NA_integer_ |
| 41 |
) |
|
| 42 |
} |
|
| 43 | ||
| 44 |
.environment_fingerprint <- function() {
|
|
| 45 | 10x |
canonical_hash(.execution_environment_details()) # nolint: object_usage_linter. |
| 46 |
} |
|
| 47 | ||
| 48 |
.validate_execution_input <- function( |
|
| 49 |
request, |
|
| 50 |
script, |
|
| 51 |
analysis_data, |
|
| 52 |
data_classification |
|
| 53 |
) {
|
|
| 54 | 8x |
if (!inherits(request, "execution_request")) {
|
| 55 | ! |
cli::cli_abort("{.arg request} must be an {.cls execution_request}")
|
| 56 |
} |
|
| 57 | 8x |
if (!checkmate::test_string(script, min.chars = 1L)) {
|
| 58 | ! |
cli::cli_abort("{.arg script} must be a non-empty R script")
|
| 59 |
} |
|
| 60 | 8x |
if (!is.data.frame(analysis_data)) {
|
| 61 | ! |
cli::cli_abort("{.arg analysis_data} must be a data frame")
|
| 62 |
} |
|
| 63 | 8x |
if (!data_classification %in% c("synthetic", "deidentified")) {
|
| 64 | 1x |
cli::cli_abort(paste0( |
| 65 | 1x |
"The local POC runner accepts only synthetic or de-identified data" |
| 66 |
)) |
|
| 67 |
} |
|
| 68 | 7x |
script_hash <- .execution_hash_text(script) |
| 69 | 7x |
if (!identical(script_hash, request$script_hash)) {
|
| 70 | 1x |
cli::cli_abort("Execution script hash does not match the immutable request")
|
| 71 |
} |
|
| 72 | 6x |
snapshot_hash <- canonical_hash(analysis_data) # nolint: object_usage_linter. |
| 73 | 6x |
if (!identical(snapshot_hash, request$snapshot_hash)) {
|
| 74 | ! |
cli::cli_abort("Execution data hash does not match the immutable request")
|
| 75 |
} |
|
| 76 | 6x |
invisible(TRUE) |
| 77 |
} |
|
| 78 | ||
| 79 |
run_execution_locally <- function( |
|
| 80 |
request, |
|
| 81 |
script, |
|
| 82 |
analysis_data, |
|
| 83 |
data_classification = "synthetic", |
|
| 84 |
work_dir = tempfile("execution-run-"),
|
|
| 85 |
image_width = 1200, |
|
| 86 |
image_height = 840, |
|
| 87 |
diagnostics_limit = 4000L |
|
| 88 |
) {
|
|
| 89 | 8x |
.validate_execution_input(request, script, analysis_data, data_classification) |
| 90 | 6x |
checkmate::assert_string(work_dir, min.chars = 1L) |
| 91 | 6x |
checkmate::assert_number(image_width, lower = 1, finite = TRUE) |
| 92 | 6x |
checkmate::assert_number(image_height, lower = 1, finite = TRUE) |
| 93 | 6x |
checkmate::assert_int(diagnostics_limit, lower = 256L) |
| 94 | ||
| 95 | 6x |
fs::dir_create(work_dir, recurse = TRUE) |
| 96 | 6x |
env <- new.env(parent = globalenv()) |
| 97 | 6x |
env$analysis_data <- analysis_data |
| 98 | 6x |
script_path <- fs::path(work_dir, "script.R") |
| 99 | 6x |
image_path <- fs::path(work_dir, "plot.png") |
| 100 | 6x |
writeLines(enc2utf8(script), script_path, useBytes = TRUE) |
| 101 | ||
| 102 | 6x |
stdout <- character() |
| 103 | 6x |
result <- tryCatch( |
| 104 |
{
|
|
| 105 | 6x |
stdout <- utils::capture.output( |
| 106 | 6x |
sys.source(script_path, envir = env, keep.source = FALSE), |
| 107 | 6x |
type = "output" |
| 108 |
) |
|
| 109 | 6x |
if (!inherits(env$boxplot_analysis, "boxplot_analysis") || |
| 110 | 6x |
!inherits(env$boxplot_artifact, "boxplot_artifact")) {
|
| 111 | ! |
cli::cli_abort( |
| 112 | ! |
"Execution script did not create the governed runtime objects" |
| 113 |
) |
|
| 114 |
} |
|
| 115 | 6x |
ggplot2::ggsave( |
| 116 | 6x |
filename = image_path, |
| 117 | 6x |
plot = env$boxplot_artifact$combined, |
| 118 | 6x |
device = function(...) {
|
| 119 | 6x |
grDevices::png(..., units = "in", res = 100, type = "cairo") |
| 120 |
}, |
|
| 121 | 6x |
width = image_width / 100, |
| 122 | 6x |
height = image_height / 100, |
| 123 | 6x |
dpi = 100, |
| 124 | 6x |
units = "in" |
| 125 |
) |
|
| 126 | 6x |
if (!fs::file_exists(image_path) || fs::file_size(image_path) <= 0) {
|
| 127 | ! |
cli::cli_abort("Execution did not produce a non-empty PNG image")
|
| 128 |
} |
|
| 129 | 6x |
list(error = NULL) |
| 130 |
}, |
|
| 131 | 6x |
error = function(error) {
|
| 132 | ! |
list(error = conditionMessage(error)) |
| 133 |
} |
|
| 134 |
) |
|
| 135 | ||
| 136 | 6x |
if (!is.null(result$error)) {
|
| 137 | ! |
return(structure( |
| 138 | ! |
list( |
| 139 | ! |
status = "failed", |
| 140 | ! |
manifest_version = "execution-runner-result-v1", |
| 141 | ! |
diagnostics = list( |
| 142 | ! |
stdout = .bounded_text(stdout, diagnostics_limit), |
| 143 | ! |
stderr = .bounded_text(result$error, diagnostics_limit) |
| 144 |
) |
|
| 145 |
), |
|
| 146 | ! |
class = "execution_runner_result" |
| 147 |
)) |
|
| 148 |
} |
|
| 149 | ||
| 150 | 6x |
analytical_hash <- canonical_hash(env$boxplot_analysis) # nolint: object_usage_linter. |
| 151 | 6x |
image_hash <- .execution_hash_file(image_path) |
| 152 | 6x |
environment <- .execution_environment_details() |
| 153 | 6x |
environment$image_width <- image_width |
| 154 | 6x |
environment$image_height <- image_height |
| 155 | 6x |
environment_fingerprint <- canonical_hash(environment) # nolint: object_usage_linter. |
| 156 | 6x |
manifest <- new_execution_result( # nolint: object_usage_linter. |
| 157 | 6x |
request, |
| 158 | 6x |
analytical_hash = analytical_hash, |
| 159 | 6x |
image_hash = image_hash, |
| 160 | 6x |
environment_fingerprint = environment_fingerprint |
| 161 |
) |
|
| 162 | 6x |
structure( |
| 163 | 6x |
list( |
| 164 | 6x |
status = "succeeded", |
| 165 | 6x |
manifest_version = "execution-runner-result-v1", |
| 166 | 6x |
result = manifest, |
| 167 | 6x |
analytical_output = env$boxplot_analysis, |
| 168 | 6x |
image_path = image_path, |
| 169 | 6x |
code_hash = request$script_hash, |
| 170 | 6x |
environment = environment, |
| 171 | 6x |
diagnostics = list( |
| 172 | 6x |
stdout = .bounded_text(stdout, diagnostics_limit), |
| 173 | 6x |
stderr = "" |
| 174 |
) |
|
| 175 |
), |
|
| 176 | 6x |
class = "execution_runner_result" |
| 177 |
) |
|
| 178 |
} |
|
| 179 | ||
| 180 |
new_execution_runner <- function(run = run_execution_locally) {
|
|
| 181 | ! |
if (!is.function(run)) cli::cli_abort("{.arg run} must be a function")
|
| 182 | 12x |
structure(list(run = run), class = "execution_runner") |
| 183 |
} |
| 1 |
#' Create the injected runtime configuration provider. |
|
| 2 |
#' |
|
| 3 |
#' Runtime configuration contains provider identifiers and logical references, |
|
| 4 |
#' never secret values. Secret material is resolved only through the injected |
|
| 5 |
#' provider when a domain service explicitly requests it. |
|
| 6 |
#' |
|
| 7 |
#' @param profile Runtime profile: `local`, `test`, or `production`. |
|
| 8 |
#' @param prompt_provider Logical prompt-provider identifier. |
|
| 9 |
#' @param workspace Logical workspace identifier. |
|
| 10 |
#' @param evidence_root Directory containing durable revision evidence and |
|
| 11 |
#' artifacts. |
|
| 12 |
#' @param secret_provider Function accepting a secret reference and returning |
|
| 13 |
#' its value, or `NULL` when unavailable. |
|
| 14 |
#' @param required_secret_references Character vector of required references. |
|
| 15 |
#' @return An object of class `submission_sync_runtime_config`. |
|
| 16 |
#' @importFrom cli cli_abort |
|
| 17 |
#' @export |
|
| 18 |
new_runtime_config <- function( |
|
| 19 |
profile = Sys.getenv("ADAMVIZ_PROFILE", "local"),
|
|
| 20 |
prompt_provider = Sys.getenv("ADAMVIZ_PROMPT_PROVIDER", "mock"),
|
|
| 21 |
workspace = Sys.getenv("ADAMVIZ_WORKSPACE", "local"),
|
|
| 22 |
evidence_root = Sys.getenv( |
|
| 23 |
"ADAMVIZ_EVIDENCE_ROOT", |
|
| 24 |
fs::path( |
|
| 25 |
tools::R_user_dir("ADaMViz.SubmissionSync", which = "data"),
|
|
| 26 |
"evidence" |
|
| 27 |
) |
|
| 28 |
), |
|
| 29 |
secret_provider = NULL, |
|
| 30 |
required_secret_references = character() |
|
| 31 |
) {
|
|
| 32 | 16x |
profiles <- c("local", "test", "production")
|
| 33 | 16x |
if (!checkmate::test_choice(profile, choices = profiles)) {
|
| 34 | 1x |
cli::cli_abort("{.arg profile} must be one of {.val {profiles}}")
|
| 35 |
} |
|
| 36 | 15x |
if (!checkmate::test_string(prompt_provider, min.chars = 1)) {
|
| 37 | 1x |
cli::cli_abort("{.arg prompt_provider} must be one non-empty identifier")
|
| 38 |
} |
|
| 39 | 14x |
if (!checkmate::test_string(workspace, min.chars = 1)) {
|
| 40 | ! |
cli::cli_abort("{.arg workspace} must be one non-empty identifier")
|
| 41 |
} |
|
| 42 | 14x |
if (!checkmate::test_string(evidence_root, min.chars = 1)) {
|
| 43 | ! |
cli::cli_abort("{.arg evidence_root} must be one non-empty path")
|
| 44 |
} |
|
| 45 | 14x |
if (profile != "production" && identical(prompt_provider, "production")) {
|
| 46 | 1x |
cli::cli_abort(c( |
| 47 | 1x |
"A production provider cannot be selected outside the production profile", |
| 48 | 1x |
"i" = "Use {.val mock} for local and test profiles."
|
| 49 |
)) |
|
| 50 |
} |
|
| 51 | 13x |
if (!checkmate::test_null(secret_provider) && |
| 52 | 13x |
!checkmate::test_function(secret_provider)) {
|
| 53 | 1x |
cli::cli_abort("{.arg secret_provider} must be a function or {.val NULL}")
|
| 54 |
} |
|
| 55 | 12x |
if (!checkmate::test_character( |
| 56 | 12x |
required_secret_references, |
| 57 | 12x |
any.missing = FALSE |
| 58 |
)) {
|
|
| 59 | ! |
cli::cli_abort( |
| 60 | ! |
"{.arg required_secret_references} must contain character values"
|
| 61 |
) |
|
| 62 |
} |
|
| 63 | ||
| 64 | 12x |
config <- structure( |
| 65 | 12x |
list( |
| 66 | 12x |
profile = profile, |
| 67 | 12x |
prompt_provider = prompt_provider, |
| 68 | 12x |
workspace = workspace, |
| 69 | 12x |
evidence_root = as.character(fs::path_abs(evidence_root)), |
| 70 | 12x |
required_secret_references = unique(required_secret_references), |
| 71 | 12x |
secret_provider = secret_provider |
| 72 |
), |
|
| 73 | 12x |
class = "submission_sync_runtime_config" |
| 74 |
) |
|
| 75 | ||
| 76 | 12x |
for (reference in config$required_secret_references) {
|
| 77 | 1x |
if (is.null(config$secret_provider)) {
|
| 78 | ! |
cli::cli_abort("Secret reference {.val {reference}} is not configured")
|
| 79 |
} |
|
| 80 | 1x |
value <- config$secret_provider(reference) |
| 81 | 1x |
if (is.null(value) || length(value) != 1L || !nzchar(value)) {
|
| 82 | ! |
cli::cli_abort("Secret reference {.val {reference}} is not configured")
|
| 83 |
} |
|
| 84 |
} |
|
| 85 | ||
| 86 | 12x |
config |
| 87 |
} |
|
| 88 | ||
| 89 |
#' Resolve one runtime secret through the injected provider. |
|
| 90 |
#' |
|
| 91 |
#' @param runtime_config A `submission_sync_runtime_config` object. |
|
| 92 |
#' @param reference Logical secret reference. |
|
| 93 |
#' @return The resolved secret value. |
|
| 94 |
#' @export |
|
| 95 |
resolve_runtime_secret <- function(runtime_config, reference) {
|
|
| 96 | 2x |
if (!checkmate::test_class( |
| 97 | 2x |
runtime_config, |
| 98 | 2x |
classes = "submission_sync_runtime_config" |
| 99 |
)) {
|
|
| 100 | ! |
cli::cli_abort( |
| 101 | ! |
"{.arg runtime_config} must be a {.cls submission_sync_runtime_config}"
|
| 102 |
) |
|
| 103 |
} |
|
| 104 | 2x |
if (!checkmate::test_string(reference, min.chars = 1)) {
|
| 105 | ! |
cli::cli_abort("{.arg reference} must be one non-empty identifier")
|
| 106 |
} |
|
| 107 | 2x |
if (is.null(runtime_config$secret_provider)) {
|
| 108 | ! |
cli::cli_abort("Secret reference {.val {reference}} is not configured")
|
| 109 |
} |
|
| 110 | 2x |
value <- runtime_config$secret_provider(reference) |
| 111 | 2x |
if (is.null(value) || length(value) != 1L || !nzchar(value)) {
|
| 112 | 1x |
cli::cli_abort("Secret reference {.val {reference}} is not configured")
|
| 113 |
} |
|
| 114 | 1x |
value |
| 115 |
} |
| 1 |
.factor_boxplot_layers <- function(analysis) {
|
|
| 2 | 14x |
layers <- analysis[c("boxes", "outliers", "medians", "n_strip")]
|
| 3 | 14x |
layers <- lapply(layers, function(data) {
|
| 4 | 56x |
data$treatment <- factor(data$treatment, levels = analysis$facet_levels) |
| 5 | 56x |
data$visit <- factor(data$visit, levels = analysis$visit_levels) |
| 6 | 56x |
data |
| 7 |
}) |
|
| 8 | 14x |
layers |
| 9 |
} |
|
| 10 | ||
| 11 |
#' Assemble the governed boxplot and aligned N strip |
|
| 12 |
#' |
|
| 13 |
#' Uses the independent analytical object as identity statistics, preserving |
|
| 14 |
#' treatment facets and global visit positions in both panels. |
|
| 15 |
#' |
|
| 16 |
#' @param analysis A `boxplot_analysis` object. |
|
| 17 |
#' @param scale_mode Either `"fixed"` or `"free"` for facet Y scales. |
|
| 18 |
#' @param unit Optional selected unit appended to the Y-axis label. |
|
| 19 |
#' |
|
| 20 |
#' @return A `boxplot_artifact` containing plot, N strip, composition, and data. |
|
| 21 |
#' @export |
|
| 22 |
assemble_boxplot <- function( |
|
| 23 |
analysis, |
|
| 24 |
scale_mode = c("fixed", "free"),
|
|
| 25 |
unit = NULL |
|
| 26 |
) {
|
|
| 27 | 14x |
if (!inherits(analysis, "boxplot_analysis")) {
|
| 28 | ! |
cli::cli_abort("{.arg analysis} must be a {.cls boxplot_analysis}")
|
| 29 |
} |
|
| 30 | 14x |
scale_mode <- match.arg(scale_mode) |
| 31 | 14x |
plot_data <- .factor_boxplot_layers(analysis) |
| 32 | 14x |
y_label <- if (is.null(unit) || !nzchar(unit)) {
|
| 33 | ! |
analysis$y_variable |
| 34 |
} else {
|
|
| 35 | 14x |
paste0(analysis$y_variable, " (", unit, ")")
|
| 36 |
} |
|
| 37 | 14x |
facet_scales <- if (identical(scale_mode, "free")) "free_y" else "fixed" |
| 38 | ||
| 39 | 14x |
plot <- ggplot2::ggplot( |
| 40 | 14x |
plot_data$boxes, |
| 41 | 14x |
ggplot2::aes( |
| 42 | 14x |
x = visit, |
| 43 | 14x |
ymin = ymin, |
| 44 | 14x |
lower = lower, |
| 45 | 14x |
middle = middle, |
| 46 | 14x |
upper = upper, |
| 47 | 14x |
ymax = ymax |
| 48 |
) |
|
| 49 |
) + |
|
| 50 | 14x |
ggplot2::geom_boxplot( |
| 51 | 14x |
stat = "identity", outlier.shape = NA, fill = "white", |
| 52 | 14x |
color = "black", linewidth = 0.6 |
| 53 |
) + |
|
| 54 | 14x |
ggplot2::geom_line( |
| 55 | 14x |
data = plot_data$medians, |
| 56 | 14x |
mapping = ggplot2::aes(x = visit, y = median, group = treatment), |
| 57 | 14x |
inherit.aes = FALSE, |
| 58 | 14x |
linewidth = 0.8, |
| 59 | 14x |
color = "black" |
| 60 |
) + |
|
| 61 | 14x |
ggplot2::geom_point( |
| 62 | 14x |
data = plot_data$medians, |
| 63 | 14x |
mapping = ggplot2::aes(x = visit, y = median), |
| 64 | 14x |
inherit.aes = FALSE, |
| 65 | 14x |
shape = 21, |
| 66 | 14x |
size = 3, |
| 67 | 14x |
fill = "white", |
| 68 | 14x |
color = "black", |
| 69 | 14x |
stroke = 1.2 |
| 70 |
) + |
|
| 71 | 14x |
ggplot2::geom_point( |
| 72 | 14x |
data = plot_data$outliers, |
| 73 | 14x |
mapping = ggplot2::aes(x = visit, y = value), |
| 74 | 14x |
inherit.aes = FALSE, |
| 75 | 14x |
shape = 1, |
| 76 | 14x |
size = 2.5, |
| 77 | 14x |
color = "black", |
| 78 | 14x |
stroke = 1 |
| 79 |
) + |
|
| 80 | 14x |
ggplot2::facet_wrap( |
| 81 | 14x |
ggplot2::vars(treatment), |
| 82 | 14x |
scales = facet_scales, |
| 83 | 14x |
drop = FALSE |
| 84 |
) + |
|
| 85 | 14x |
ggplot2::scale_x_discrete(drop = FALSE, limits = analysis$visit_levels) + |
| 86 | 14x |
ggplot2::labs(x = NULL, y = y_label) + |
| 87 | 14x |
ggplot2::theme_bw(base_size = 12) + |
| 88 | 14x |
ggplot2::theme( |
| 89 | 14x |
text = ggplot2::element_text(face = "bold"), |
| 90 | 14x |
panel.border = ggplot2::element_rect( |
| 91 | 14x |
color = "black", fill = NA, linewidth = 1 |
| 92 |
), |
|
| 93 | 14x |
panel.grid.major = ggplot2::element_line( |
| 94 | 14x |
color = "grey90", linewidth = 0.3 |
| 95 |
), |
|
| 96 | 14x |
panel.grid.minor = ggplot2::element_blank(), |
| 97 | 14x |
axis.text = ggplot2::element_text(face = "bold", color = "black"), |
| 98 | 14x |
axis.text.x = ggplot2::element_blank(), |
| 99 | 14x |
axis.ticks.x = ggplot2::element_blank(), |
| 100 | 14x |
strip.background = ggplot2::element_rect( |
| 101 | 14x |
fill = "grey85", color = "black", linewidth = 1 |
| 102 |
), |
|
| 103 | 14x |
strip.text = ggplot2::element_text(face = "bold", color = "black") |
| 104 |
) |
|
| 105 | ||
| 106 | 14x |
n_strip <- ggplot2::ggplot( |
| 107 | 14x |
plot_data$n_strip, |
| 108 | 14x |
ggplot2::aes(x = visit, y = 1, label = label) |
| 109 |
) + |
|
| 110 | 14x |
ggplot2::geom_text( |
| 111 | 14x |
na.rm = TRUE, size = 4, color = "black", fontface = "bold" |
| 112 |
) + |
|
| 113 | 14x |
ggplot2::facet_wrap(ggplot2::vars(treatment), drop = FALSE) + |
| 114 | 14x |
ggplot2::scale_x_discrete(drop = FALSE, limits = analysis$visit_levels) + |
| 115 | 14x |
ggplot2::scale_y_continuous(limits = c(0.5, 1.5)) + |
| 116 | 14x |
ggplot2::labs(x = "Analysis Visit", y = NULL) + |
| 117 | 14x |
ggplot2::theme_bw(base_size = 12) + |
| 118 | 14x |
ggplot2::theme( |
| 119 | 14x |
text = ggplot2::element_text(face = "bold"), |
| 120 | 14x |
panel.border = ggplot2::element_rect( |
| 121 | 14x |
color = "black", fill = NA, linewidth = 1 |
| 122 |
), |
|
| 123 | 14x |
axis.text = ggplot2::element_text(face = "bold", color = "black"), |
| 124 | 14x |
axis.text.y = ggplot2::element_blank(), |
| 125 | 14x |
axis.ticks.y = ggplot2::element_blank(), |
| 126 | 14x |
panel.grid = ggplot2::element_blank(), |
| 127 | 14x |
strip.text = ggplot2::element_blank(), |
| 128 | 14x |
strip.background = ggplot2::element_rect( |
| 129 | 14x |
fill = "grey85", color = "black", linewidth = 1 |
| 130 |
) |
|
| 131 |
) |
|
| 132 | ||
| 133 | 14x |
structure( |
| 134 | 14x |
list( |
| 135 | 14x |
artifact_version = "boxplot-artifact-v1", |
| 136 | 14x |
status = .initial_revision_status(scale_mode), |
| 137 | 14x |
warning = if (identical(scale_mode, "free")) {
|
| 138 | 3x |
"Free Y scales weaken cross-facet visual comparison." |
| 139 |
} else {
|
|
| 140 | 11x |
NULL |
| 141 |
}, |
|
| 142 | 14x |
scale_mode = scale_mode, |
| 143 | 14x |
analysis = analysis, |
| 144 | 14x |
plot_data = plot_data, |
| 145 | 14x |
plot = plot, |
| 146 | 14x |
n_strip = n_strip, |
| 147 | 14x |
combined = patchwork::wrap_plots( |
| 148 | 14x |
plot, n_strip, |
| 149 | 14x |
ncol = 1L, heights = c(4, 1) |
| 150 |
) |
|
| 151 |
), |
|
| 152 | 14x |
class = "boxplot_artifact" |
| 153 |
) |
|
| 154 |
} |
| 1 |
.execution_source_root <- function() {
|
|
| 2 | 14x |
configured_root <- Sys.getenv( |
| 3 | 14x |
"ADAMVIZ_SUBMISSIONSYNC_SOURCE_ROOT", |
| 4 | 14x |
unset = "" |
| 5 |
) |
|
| 6 | 14x |
configured_roots <- if (nzchar(configured_root)) {
|
| 7 | ! |
normalizePath(configured_root, winslash = "/", mustWork = FALSE) |
| 8 |
} else {
|
|
| 9 | 14x |
character() |
| 10 |
} |
|
| 11 | 14x |
current <- normalizePath(".", winslash = "/", mustWork = FALSE)
|
| 12 | 14x |
roots <- character() |
| 13 | 14x |
repeat {
|
| 14 | 98x |
roots <- c(roots, current) |
| 15 | 98x |
parent <- dirname(current) |
| 16 | 14x |
if (identical(parent, current)) break |
| 17 | 84x |
current <- parent |
| 18 |
} |
|
| 19 | 14x |
roots <- c( |
| 20 | 14x |
configured_roots, |
| 21 | 14x |
roots, |
| 22 | 14x |
getNamespaceInfo(asNamespace("ADaMViz.SubmissionSync"), "path")
|
| 23 |
) |
|
| 24 | 14x |
is_source_root <- vapply(roots, function(root) {
|
| 25 | 112x |
file.exists(file.path(root, "DESCRIPTION")) && |
| 26 | 112x |
length(list.files(file.path(root, "R"), pattern = "\\.[Rr]$")) > 0L |
| 27 | 14x |
}, logical(1)) |
| 28 | 14x |
source_roots <- roots[is_source_root] |
| 29 | ! |
if (length(source_roots)) source_roots[[1]] else NULL |
| 30 |
} |
|
| 31 | ||
| 32 |
run_execution_callr <- function( |
|
| 33 |
request, |
|
| 34 |
script, |
|
| 35 |
analysis_data, |
|
| 36 |
data_classification = "synthetic", |
|
| 37 |
work_dir = tempfile("execution-callr-"),
|
|
| 38 |
timeout = 60, |
|
| 39 |
source_root = .execution_source_root() |
|
| 40 |
) {
|
|
| 41 | 5x |
if (!requireNamespace("callr", quietly = TRUE)) {
|
| 42 | ! |
cli::cli_abort( |
| 43 | ! |
"The callr execution runner requires the {.pkg callr} package"
|
| 44 |
) |
|
| 45 |
} |
|
| 46 | 5x |
checkmate::assert_number(timeout, lower = 1, finite = TRUE) |
| 47 | 5x |
worker <- function( |
| 48 | 5x |
request, |
| 49 | 5x |
script, |
| 50 | 5x |
analysis_data, |
| 51 | 5x |
data_classification, |
| 52 | 5x |
work_dir, |
| 53 | 5x |
source_root |
| 54 |
) {
|
|
| 55 | ! |
if (!is.null(source_root) && |
| 56 | ! |
file.exists(file.path(source_root, "DESCRIPTION"))) {
|
| 57 | ! |
devtools::load_all(source_root, quiet = TRUE) |
| 58 |
} |
|
| 59 | ! |
run_locally <- get( |
| 60 | ! |
"run_execution_locally", |
| 61 | ! |
envir = asNamespace("ADaMViz.SubmissionSync")
|
| 62 |
) |
|
| 63 | 5x |
run_locally( |
| 64 | 5x |
request = request, |
| 65 | 5x |
script = script, |
| 66 | 5x |
analysis_data = analysis_data, |
| 67 | 5x |
data_classification = data_classification, |
| 68 | 5x |
work_dir = work_dir |
| 69 |
) |
|
| 70 |
} |
|
| 71 | 5x |
tryCatch( |
| 72 | 5x |
callr::r( |
| 73 | 5x |
worker, |
| 74 | 5x |
args = list( |
| 75 | 5x |
request, |
| 76 | 5x |
script, |
| 77 | 5x |
analysis_data, |
| 78 | 5x |
data_classification, |
| 79 | 5x |
work_dir, |
| 80 | 5x |
source_root |
| 81 |
), |
|
| 82 | 5x |
libpath = .libPaths(), |
| 83 | 5x |
cmdargs = "--vanilla", |
| 84 | 5x |
system_profile = FALSE, |
| 85 | 5x |
user_profile = FALSE, |
| 86 | 5x |
env = c( |
| 87 | 5x |
R_PROFILE_USER = "", |
| 88 | 5x |
R_ENVIRON_USER = "", |
| 89 | 5x |
ADAMVIZ_SECRET_CANARY = "" |
| 90 |
), |
|
| 91 | 5x |
timeout = timeout |
| 92 |
), |
|
| 93 | 5x |
error = function(error) {
|
| 94 | ! |
structure( |
| 95 | ! |
list( |
| 96 | ! |
status = "failed", |
| 97 | ! |
manifest_version = "execution-runner-result-v1", |
| 98 | ! |
diagnostics = list(stdout = "", stderr = conditionMessage(error)) |
| 99 |
), |
|
| 100 | ! |
class = "execution_runner_result" |
| 101 |
) |
|
| 102 |
} |
|
| 103 |
) |
|
| 104 |
} |
|
| 105 | ||
| 106 |
new_callr_execution_runner <- function( |
|
| 107 |
timeout = 60, |
|
| 108 |
source_root = .execution_source_root() |
|
| 109 |
) {
|
|
| 110 | 4x |
new_execution_runner( |
| 111 | 4x |
function( |
| 112 | 4x |
request, |
| 113 | 4x |
script, |
| 114 | 4x |
analysis_data, |
| 115 | 4x |
data_classification, |
| 116 | 4x |
work_dir |
| 117 |
) {
|
|
| 118 | 4x |
run_execution_callr( |
| 119 | 4x |
request = request, |
| 120 | 4x |
script = script, |
| 121 | 4x |
analysis_data = analysis_data, |
| 122 | 4x |
data_classification = data_classification, |
| 123 | 4x |
work_dir = work_dir, |
| 124 | 4x |
timeout = timeout, |
| 125 | 4x |
source_root = source_root |
| 126 |
) |
|
| 127 |
}) |
|
| 128 |
} |
| 1 |
.canonicalize_value <- function(value) {
|
|
| 2 | 20518x |
if (is.list(value)) {
|
| 3 | 4042x |
value <- lapply(value, .canonicalize_value) |
| 4 | 4042x |
if (!is.null(names(value))) {
|
| 5 | 3927x |
value <- value[order(names(value), method = "radix")] |
| 6 |
} |
|
| 7 |
} |
|
| 8 | 20518x |
value |
| 9 |
} |
|
| 10 | ||
| 11 |
canonical_serialize <- function(value) {
|
|
| 12 | 1887x |
jsonlite::toJSON( |
| 13 | 1887x |
.canonicalize_value(value), |
| 14 | 1887x |
auto_unbox = TRUE, |
| 15 | 1887x |
null = "null", |
| 16 | 1887x |
digits = NA, |
| 17 | 1887x |
pretty = FALSE |
| 18 |
) |
|
| 19 |
} |
|
| 20 | ||
| 21 |
canonical_hash <- function(value, algorithm = "sha256") {
|
|
| 22 | 1420x |
digest::digest( |
| 23 | 1420x |
canonical_serialize(value), |
| 24 | 1420x |
algo = algorithm, |
| 25 | 1420x |
serialize = FALSE |
| 26 |
) |
|
| 27 |
} |
| 1 |
mod_evidence_ui <- function(id) {
|
|
| 2 | 6x |
ns <- shiny::NS(id) |
| 3 | 6x |
bslib::card( |
| 4 | 6x |
class = "assurance-panel", |
| 5 | 6x |
bslib::card_header("Evidence"),
|
| 6 | 6x |
bslib::card_body( |
| 7 | 6x |
shiny::tableOutput(ns("hashes")),
|
| 8 | 6x |
shiny::tableOutput(ns("checks"))
|
| 9 |
) |
|
| 10 |
) |
|
| 11 |
} |
|
| 12 | ||
| 13 |
mod_evidence_server <- function(id, current_revision) {
|
|
| 14 | 11x |
shiny::moduleServer(id, function(input, output, session) {
|
| 15 | 11x |
output$hashes <- shiny::renderTable( |
| 16 |
{
|
|
| 17 | 11x |
state <- current_revision() |
| 18 | 11x |
if (is.null(state$revision)) {
|
| 19 | 6x |
return(data.frame()) |
| 20 |
} |
|
| 21 | 5x |
data.frame( |
| 22 | 5x |
item = c("Specification", "Code", "Image", "Analytical output"),
|
| 23 | 5x |
hash = c( |
| 24 | 5x |
state$revision$spec_hash, |
| 25 | 5x |
state$revision$code_hash, |
| 26 | 5x |
state$revision$image_hash, |
| 27 | 5x |
state$revision$analytical_hash |
| 28 |
), |
|
| 29 | 5x |
stringsAsFactors = FALSE |
| 30 |
) |
|
| 31 |
}, |
|
| 32 | 11x |
striped = TRUE, |
| 33 | 11x |
spacing = "s" |
| 34 |
) |
|
| 35 | ||
| 36 | 11x |
output$checks <- shiny::renderTable( |
| 37 |
{
|
|
| 38 | 11x |
state <- current_revision() |
| 39 | 11x |
if (is.null(state$verification)) {
|
| 40 | 7x |
return(data.frame()) |
| 41 |
} |
|
| 42 | 4x |
checks <- state$verification$checks |
| 43 | 4x |
data.frame( |
| 44 | 4x |
check = names(checks), |
| 45 | 4x |
passed = unname(unlist(checks)), |
| 46 | 4x |
stringsAsFactors = FALSE |
| 47 |
) |
|
| 48 |
}, |
|
| 49 | 11x |
striped = TRUE, |
| 50 | 11x |
spacing = "s" |
| 51 |
) |
|
| 52 |
}) |
|
| 53 |
} |
| 1 |
#' Build minimum-necessary prompt context from an authorized snapshot |
|
| 2 |
#' |
|
| 3 |
#' @param snapshot A pinned `study_data_snapshot`. |
|
| 4 |
#' |
|
| 5 |
#' @return A `prompt_context` containing only authorized labels and aggregate |
|
| 6 |
#' choices. |
|
| 7 |
#' @export |
|
| 8 |
build_prompt_context <- function(snapshot) {
|
|
| 9 | 29x |
if (!inherits(snapshot, "study_data_snapshot")) {
|
| 10 | ! |
cli::cli_abort("{.arg snapshot} must be a {.cls study_data_snapshot}")
|
| 11 |
} |
|
| 12 | ||
| 13 | 29x |
data <- snapshot$data |
| 14 | 29x |
parameters <- .prompt_parameter_choices(data) |
| 15 | 29x |
visits <- .prompt_visit_choices(data) |
| 16 | 29x |
treatment_levels <- lapply( |
| 17 | 29x |
snapshot$permitted_treatment_variables, |
| 18 | 29x |
function(variable) {
|
| 19 | 29x |
sort( |
| 20 | 29x |
unique(stats::na.omit(as.character(data[[variable]]))), |
| 21 | 29x |
method = "radix" |
| 22 |
) |
|
| 23 |
} |
|
| 24 |
) |
|
| 25 | 29x |
names(treatment_levels) <- snapshot$permitted_treatment_variables |
| 26 | ||
| 27 | 29x |
labels <- list( |
| 28 | 29x |
dataset_id = snapshot$dataset_id, |
| 29 | 29x |
adam_designation = snapshot$adam_designation |
| 30 |
) |
|
| 31 | 29x |
if (!is.null(snapshot$classification)) {
|
| 32 | 17x |
labels$classification <- snapshot$classification |
| 33 |
} |
|
| 34 | ||
| 35 | 29x |
new_prompt_context( |
| 36 | 29x |
dataset_id = snapshot$dataset_id, |
| 37 | 29x |
labels = labels, |
| 38 | 29x |
aggregate_profile = list( |
| 39 | 29x |
parameters = parameters, |
| 40 | 29x |
y_variables = intersect(c("AVAL", "CHG", "PCHG"), names(data)),
|
| 41 | 29x |
treatment_variables = snapshot$permitted_treatment_variables, |
| 42 | 29x |
treatment_levels = treatment_levels, |
| 43 | 29x |
visits = visits, |
| 44 | 29x |
scale_modes = c("fixed", "free"),
|
| 45 | 29x |
default_scale_mode = "fixed" |
| 46 |
) |
|
| 47 |
) |
|
| 48 |
} |
|
| 49 | ||
| 50 |
.prompt_parameter_choices <- function(data) {
|
|
| 51 | 29x |
if (!all(c("PARAMCD", "PARAM") %in% names(data))) {
|
| 52 | ! |
return(list()) |
| 53 |
} |
|
| 54 | 29x |
parameter_rows <- unique( |
| 55 | 29x |
data[c("PARAMCD", "PARAM", intersect("AVALU", names(data)))]
|
| 56 |
) |
|
| 57 | 29x |
parameter_rows <- parameter_rows[ |
| 58 | 29x |
order(parameter_rows$PARAMCD, method = "radix"), , |
| 59 | 29x |
drop = FALSE |
| 60 |
] |
|
| 61 | 29x |
lapply(split(parameter_rows, parameter_rows$PARAMCD), function(rows) {
|
| 62 | 29x |
unit <- if ("AVALU" %in% names(rows)) {
|
| 63 | 29x |
sort(unique(stats::na.omit(as.character(rows$AVALU))), method = "radix") |
| 64 |
} else {
|
|
| 65 | ! |
character() |
| 66 |
} |
|
| 67 | 29x |
list( |
| 68 | 29x |
paramcd = rows$PARAMCD[[1]], |
| 69 | 29x |
label = rows$PARAM[[1]], |
| 70 | 29x |
units = unit |
| 71 |
) |
|
| 72 |
}) |
|
| 73 |
} |
|
| 74 | ||
| 75 |
.prompt_visit_choices <- function(data) {
|
|
| 76 | 29x |
if (!all(c("AVISIT", "AVISITN") %in% names(data))) {
|
| 77 | ! |
return(list()) |
| 78 |
} |
|
| 79 | 29x |
visits <- unique(data[c("AVISIT", "AVISITN")])
|
| 80 | 29x |
visits <- visits[ |
| 81 | 29x |
!is.na(visits$AVISIT) & !is.na(visits$AVISITN), , |
| 82 | 29x |
drop = FALSE |
| 83 |
] |
|
| 84 | 29x |
visits <- visits[ |
| 85 | 29x |
order(visits$AVISITN, visits$AVISIT, method = "radix"), , |
| 86 | 29x |
drop = FALSE |
| 87 |
] |
|
| 88 | 29x |
lapply(seq_len(nrow(visits)), function(i) {
|
| 89 | 87x |
list(label = as.character(visits$AVISIT[[i]]), order = visits$AVISITN[[i]]) |
| 90 |
}) |
|
| 91 |
} |
| 1 |
mod_revision_history_ui <- function(id) {
|
|
| 2 | 6x |
ns <- shiny::NS(id) |
| 3 | 6x |
bslib::card( |
| 4 | 6x |
class = "assurance-panel", |
| 5 | 6x |
bslib::card_header("Revision History"),
|
| 6 | 6x |
bslib::card_body( |
| 7 | 6x |
shiny::tableOutput(ns("history"))
|
| 8 |
) |
|
| 9 |
) |
|
| 10 |
} |
|
| 11 | ||
| 12 |
mod_revision_history_server <- function(id, current_revision) {
|
|
| 13 | 12x |
shiny::moduleServer(id, function(input, output, session) {
|
| 14 | 12x |
output$history <- shiny::renderTable( |
| 15 |
{
|
|
| 16 | 16x |
state <- current_revision() |
| 17 | 16x |
if (is.null(state$repository) || is.null(state$plot_id)) {
|
| 18 | 7x |
return(data.frame()) |
| 19 |
} |
|
| 20 | 9x |
revisions <- state$repository$list_revisions(state$plot_id) |
| 21 | 9x |
revisions$status <- vapply( |
| 22 | 9x |
revisions$revision_id, |
| 23 | 9x |
function(revision_id) {
|
| 24 | 10x |
state$repository$get_revision(revision_id)$status |
| 25 |
}, |
|
| 26 | 9x |
character(1) |
| 27 |
) |
|
| 28 | 9x |
revisions[, c( |
| 29 | 9x |
"revision_id", "revision_number", "initial_status", |
| 30 | 9x |
"status", "created_at" |
| 31 | 9x |
), drop = FALSE] |
| 32 |
}, |
|
| 33 | 12x |
striped = TRUE, |
| 34 | 12x |
spacing = "s" |
| 35 |
) |
|
| 36 |
}) |
|
| 37 |
} |
| 1 |
mod_run_status_ui <- function(id) {
|
|
| 2 | 6x |
ns <- shiny::NS(id) |
| 3 | 6x |
bslib::card( |
| 4 | 6x |
class = "assurance-panel", |
| 5 | 6x |
bslib::card_header("Run Status"),
|
| 6 | 6x |
bslib::card_body( |
| 7 | 6x |
shiny::uiOutput(ns("status")),
|
| 8 | 6x |
shiny::uiOutput(ns("warnings"))
|
| 9 |
) |
|
| 10 |
) |
|
| 11 |
} |
|
| 12 | ||
| 13 |
mod_run_status_server <- function(id, current_revision) {
|
|
| 14 | 11x |
shiny::moduleServer(id, function(input, output, session) {
|
| 15 | 11x |
output$status <- shiny::renderUI({
|
| 16 | 11x |
state <- current_revision() |
| 17 | 11x |
if (is.null(state$revision)) {
|
| 18 | 6x |
return(div( |
| 19 | 6x |
class = "text-body-secondary", "No revision has been created." |
| 20 |
)) |
|
| 21 |
} |
|
| 22 | 5x |
status <- state$revision$status |
| 23 | 5x |
div( |
| 24 | 5x |
class = "status-row", |
| 25 | 5x |
span(class = paste("status-badge", .status_class(status)), status),
|
| 26 | 5x |
span( |
| 27 | 5x |
class = "text-body-secondary", |
| 28 | 5x |
paste("Revision", state$revision$revision_id)
|
| 29 |
) |
|
| 30 |
) |
|
| 31 |
}) |
|
| 32 | ||
| 33 | 11x |
output$warnings <- shiny::renderUI({
|
| 34 | 11x |
state <- current_revision() |
| 35 | 11x |
if (is.null(state$profile)) {
|
| 36 | 7x |
return(NULL) |
| 37 |
} |
|
| 38 | 4x |
warnings <- state$profile$warnings |
| 39 | 4x |
if (!length(warnings)) {
|
| 40 | ! |
return(div( |
| 41 | ! |
class = "text-body-secondary", "No nonblocking profile warnings." |
| 42 |
)) |
|
| 43 |
} |
|
| 44 | 4x |
tags$ul( |
| 45 | 4x |
class = "warning-list", |
| 46 | 4x |
lapply(warnings, function(warning) {
|
| 47 | 4x |
tags$li( |
| 48 | 4x |
tags$strong(warning$code), |
| 49 | 4x |
paste0(": ", warning$message),
|
| 50 | 4x |
if (!is.null(warning$count)) {
|
| 51 | 4x |
paste0(" (", warning$count, ")")
|
| 52 |
} else {
|
|
| 53 |
"" |
|
| 54 |
} |
|
| 55 |
) |
|
| 56 |
}) |
|
| 57 |
) |
|
| 58 |
}) |
|
| 59 |
}) |
|
| 60 |
} |
|
| 61 | ||
| 62 |
.status_class <- function(status) {
|
|
| 63 | 9x |
switch(status, |
| 64 | 8x |
"Verified" = "status-verified", |
| 65 | ! |
"Reviewed" = "status-reviewed", |
| 66 | ! |
"Rejected" = "status-rejected", |
| 67 | ! |
"Experimental/Draft" = "status-experimental", |
| 68 | 1x |
"status-draft" |
| 69 |
) |
|
| 70 |
} |
| 1 |
.utc_timestamp <- function() {
|
|
| 2 | 1223x |
format(Sys.time(), "%Y-%m-%dT%H:%M:%OS6Z", tz = "UTC") |
| 3 |
} |
|
| 4 | ||
| 5 |
.validate_identifier <- function(value, name) {
|
|
| 6 | 685x |
if (!checkmate::test_string( |
| 7 | 685x |
value, |
| 8 | 685x |
min.chars = 1, |
| 9 | 685x |
pattern = "^[A-Za-z0-9._:-]+$" |
| 10 |
)) {
|
|
| 11 | ! |
cli::cli_abort("{.arg {name}} must be a non-empty stable identifier")
|
| 12 |
} |
|
| 13 | 685x |
invisible(value) |
| 14 |
} |
|
| 15 | ||
| 16 |
.validate_evidence_repository <- function(repository, capabilities) {
|
|
| 17 | 70x |
available <- is.list(repository) && all(vapply( |
| 18 | 70x |
capabilities, |
| 19 | 70x |
\(capability) is.function(repository[[capability]]), |
| 20 | 70x |
logical(1) |
| 21 |
)) |
|
| 22 | 70x |
if (!available) {
|
| 23 | ! |
cli::cli_abort( |
| 24 | ! |
"Evidence repository must implement {.fn {capabilities}}"
|
| 25 |
) |
|
| 26 |
} |
|
| 27 | 70x |
invisible(repository) |
| 28 |
} |
| 1 |
#' Create the pre-execution boxplot artifact manifest |
|
| 2 |
#' |
|
| 3 |
#' Binds the exact specification, script, analytical result, and expected-result |
|
| 4 |
#' contract. Runtime and image hashes remain unset until controlled execution. |
|
| 5 |
#' |
|
| 6 |
#' @param spec A confirmed `plot_spec`. |
|
| 7 |
#' @param script The exact compiled R script. |
|
| 8 |
#' @param analysis A `boxplot_analysis` object. |
|
| 9 |
#' @param expected_contract A list containing the expected-result `version`, |
|
| 10 |
#' `fixture_hash`, and `review_status`. |
|
| 11 |
#' |
|
| 12 |
#' @return A `boxplot_artifact_manifest`. |
|
| 13 |
#' @export |
|
| 14 |
new_artifact_manifest <- function( |
|
| 15 |
spec, |
|
| 16 |
script, |
|
| 17 |
analysis, |
|
| 18 |
expected_contract |
|
| 19 |
) {
|
|
| 20 | 4x |
.validate_plot_spec(spec) |
| 21 | 4x |
required_contract_fields <- c("version", "fixture_hash", "review_status")
|
| 22 | 4x |
if (!identical(spec$state, "confirmed") || |
| 23 | 4x |
!checkmate::test_string(script, min.chars = 1L) || |
| 24 | 4x |
!inherits(analysis, "boxplot_analysis") || |
| 25 | 4x |
!is.list(expected_contract) || |
| 26 | 4x |
!setequal(names(expected_contract), required_contract_fields) || |
| 27 | 4x |
!all(vapply( |
| 28 | 4x |
expected_contract, |
| 29 | 4x |
checkmate::test_string, |
| 30 | 4x |
logical(1), |
| 31 | 4x |
min.chars = 1L |
| 32 |
))) {
|
|
| 33 | ! |
cli::cli_abort( |
| 34 | ! |
"The artifact manifest requires confirmed, complete U4 inputs" |
| 35 |
) |
|
| 36 |
} |
|
| 37 | ||
| 38 | 4x |
authentic_spec_hash <- canonical_hash(list( |
| 39 | 4x |
fields = spec$fields, |
| 40 | 4x |
provenance = spec$provenance |
| 41 |
)) |
|
| 42 | 4x |
if (!identical(spec$hash, authentic_spec_hash)) {
|
| 43 | 1x |
cli::cli_abort( |
| 44 | 1x |
"The confirmed plot specification hash does not match its contents" |
| 45 |
) |
|
| 46 |
} |
|
| 47 | ||
| 48 | 3x |
analytical_metadata <- list( |
| 49 | 3x |
treatment_variable = spec$fields$treatment_variable, |
| 50 | 3x |
y_variable = spec$fields$y_variable, |
| 51 | 3x |
facet_levels = spec$fields$treatment_levels, |
| 52 | 3x |
visit_levels = spec$fields$visits |
| 53 |
) |
|
| 54 | 3x |
analytical_mismatch <- vapply( |
| 55 | 3x |
names(analytical_metadata), |
| 56 | 3x |
function(field) !identical(analysis[[field]], analytical_metadata[[field]]), |
| 57 | 3x |
logical(1) |
| 58 |
) |
|
| 59 | 3x |
if (any(analytical_mismatch)) {
|
| 60 | 1x |
cli::cli_abort( |
| 61 | 1x |
paste0( |
| 62 | 1x |
"The analytical result does not match the confirmed specification for ", |
| 63 | 1x |
"{.field {names(analytical_metadata)[analytical_mismatch]}}"
|
| 64 |
) |
|
| 65 |
) |
|
| 66 |
} |
|
| 67 | ||
| 68 | 2x |
expected_script <- compile_boxplot_script(spec, analysis$low_n_policy) |
| 69 | 2x |
if (!identical(script, expected_script)) {
|
| 70 | 1x |
cli::cli_abort(paste0( |
| 71 | 1x |
"The compiled script does not match the confirmed specification ", |
| 72 | 1x |
"and analytical policy" |
| 73 |
)) |
|
| 74 |
} |
|
| 75 | ||
| 76 | 1x |
structure( |
| 77 | 1x |
list( |
| 78 | 1x |
manifest_version = "boxplot-artifact-manifest-v1", |
| 79 | 1x |
status = .initial_revision_status(spec$fields$scale_mode), |
| 80 | 1x |
spec_hash = spec$hash, |
| 81 | 1x |
script_hash = .execution_hash_text(script), |
| 82 | 1x |
analytical_hash = canonical_hash(analysis), |
| 83 | 1x |
expected_contract_version = expected_contract$version, |
| 84 | 1x |
expected_contract_review_status = expected_contract$review_status, |
| 85 | 1x |
expected_contract_hash = canonical_hash(expected_contract), |
| 86 | 1x |
image_hash = NULL, |
| 87 | 1x |
runtime_artifact_hash = NULL |
| 88 |
), |
|
| 89 | 1x |
class = "boxplot_artifact_manifest" |
| 90 |
) |
|
| 91 |
} |
| 1 |
new_review_service <- function(repository, identity_provider) {
|
|
| 2 | 34x |
.validate_evidence_repository( |
| 3 | 34x |
repository, |
| 4 | 34x |
c("get_revision", "record_review_decision")
|
| 5 |
) |
|
| 6 | 34x |
.validate_identity_provider(identity_provider) |
| 7 | ||
| 8 | 34x |
record_decision <- function( |
| 9 | 34x |
revision_id, |
| 10 | 34x |
actor_id, |
| 11 | 34x |
decision, |
| 12 | 34x |
expected_version, |
| 13 | 34x |
idempotency_key, |
| 14 | 34x |
hashes, |
| 15 | 34x |
comment, |
| 16 | 34x |
role |
| 17 |
) {
|
|
| 18 | 60x |
actor <- identity_provider$get_actor(actor_id) |
| 19 | 60x |
if (is.null(hashes)) {
|
| 20 | 59x |
revision <- repository$get_revision(revision_id) |
| 21 | 59x |
hashes <- revision[c("code_hash", "image_hash", "analytical_hash")]
|
| 22 |
} |
|
| 23 | 60x |
repository$record_review_decision( |
| 24 | 60x |
revision_id, |
| 25 | 60x |
actor, |
| 26 | 60x |
decision, |
| 27 | 60x |
expected_version, |
| 28 | 60x |
idempotency_key, |
| 29 | 60x |
hashes, |
| 30 | 60x |
comment = comment, |
| 31 | 60x |
role = role |
| 32 |
) |
|
| 33 | 47x |
invisible(repository$get_revision(revision_id)) |
| 34 |
} |
|
| 35 | ||
| 36 | 34x |
approve <- function( |
| 37 | 34x |
revision_id, |
| 38 | 34x |
actor_id, |
| 39 | 34x |
expected_version, |
| 40 | 34x |
idempotency_key, |
| 41 | 34x |
hashes = NULL, |
| 42 | 34x |
comment = NULL, |
| 43 | 34x |
role = NULL |
| 44 |
) {
|
|
| 45 | 52x |
record_decision( |
| 46 | 52x |
revision_id, |
| 47 | 52x |
actor_id, |
| 48 | 52x |
"approved", |
| 49 | 52x |
expected_version, |
| 50 | 52x |
idempotency_key, |
| 51 | 52x |
hashes, |
| 52 | 52x |
comment, |
| 53 | 52x |
role |
| 54 |
) |
|
| 55 |
} |
|
| 56 | 34x |
reject <- function( |
| 57 | 34x |
revision_id, |
| 58 | 34x |
actor_id, |
| 59 | 34x |
expected_version, |
| 60 | 34x |
idempotency_key, |
| 61 | 34x |
rationale, |
| 62 | 34x |
hashes = NULL, |
| 63 | 34x |
role = NULL |
| 64 |
) {
|
|
| 65 | 8x |
record_decision( |
| 66 | 8x |
revision_id, |
| 67 | 8x |
actor_id, |
| 68 | 8x |
"rejected", |
| 69 | 8x |
expected_version, |
| 70 | 8x |
idempotency_key, |
| 71 | 8x |
hashes, |
| 72 | 8x |
rationale, |
| 73 | 8x |
role |
| 74 |
) |
|
| 75 |
} |
|
| 76 | 34x |
structure(list(approve = approve, reject = reject), class = "review_service") |
| 77 |
} |
| 1 |
.validate_identity_provider <- function(provider) {
|
|
| 2 | 51x |
if (!is.list(provider) || !is.function(provider$get_actor)) {
|
| 3 | ! |
cli::cli_abort("Identity provider must implement {.fn get_actor}")
|
| 4 |
} |
|
| 5 | 51x |
invisible(provider) |
| 6 |
} |
| 1 |
new_provider_port <- function(name, capabilities) {
|
|
| 2 | 24x |
if (!checkmate::test_string(name, min.chars = 1) || |
| 3 | 24x |
!checkmate::test_character( |
| 4 | 24x |
capabilities, |
| 5 | 24x |
min.len = 1L, any.missing = FALSE |
| 6 |
)) {
|
|
| 7 | ! |
cli::cli_abort("A provider port needs a name and declared capabilities")
|
| 8 |
} |
|
| 9 | 24x |
structure( |
| 10 | 24x |
list(name = name, capabilities = unique(capabilities)), |
| 11 | 24x |
class = "provider_port" |
| 12 |
) |
|
| 13 |
} |
| 1 |
.workflow_transitions <- list( |
|
| 2 |
Draft = c("Verified", "Experimental/Draft"),
|
|
| 3 |
"Experimental/Draft" = character(), |
|
| 4 |
Verified = c("Reviewed"),
|
|
| 5 |
Reviewed = character(), |
|
| 6 |
Rejected = character() |
|
| 7 |
) |
|
| 8 | ||
| 9 |
transition_revision_status <- function(status, next_status) {
|
|
| 10 | 2x |
if (!checkmate::test_choice(status, choices = names(.workflow_transitions))) {
|
| 11 | ! |
cli::cli_abort("Unknown revision status {.val {status}}")
|
| 12 |
} |
|
| 13 | 2x |
if (!checkmate::test_choice( |
| 14 | 2x |
next_status, |
| 15 | 2x |
choices = names(.workflow_transitions) |
| 16 |
)) {
|
|
| 17 | ! |
cli::cli_abort("Unknown next status {.val {next_status}}")
|
| 18 |
} |
|
| 19 | 2x |
if (!checkmate::test_choice( |
| 20 | 2x |
next_status, |
| 21 | 2x |
choices = .workflow_transitions[[status]] |
| 22 |
)) {
|
|
| 23 | 1x |
cli::cli_abort(paste0( |
| 24 | 1x |
"Cannot transition from {.val {status}} to {.val {next_status}}"
|
| 25 |
)) |
|
| 26 |
} |
|
| 27 | 1x |
next_status |
| 28 |
} |
|
| 29 | ||
| 30 |
.initial_revision_status <- function(scale_mode) {
|
|
| 31 | 5x |
if (identical(scale_mode, "free")) "Experimental/Draft" else "Draft" |
| 32 |
} |
| 1 |
.revision_evidence_id <- function(revision_id, evidence_type) {
|
|
| 2 | 8x |
paste( |
| 3 | 8x |
"evidence", |
| 4 | 8x |
substr(canonical_hash(list(revision_id, evidence_type)), 1L, 24L), |
| 5 | 8x |
sep = "-" |
| 6 |
) |
|
| 7 |
} |
|
| 8 | ||
| 9 |
.record_revision_context <- function(state, request, runner_result) {
|
|
| 10 | 7x |
if (is.null(state$repository) || is.null(state$revision)) {
|
| 11 | ! |
cli::cli_abort("Revision evidence requires a persisted revision")
|
| 12 |
} |
|
| 13 | 7x |
revision <- state$repository$get_revision(state$revision$revision_id) |
| 14 | 7x |
details <- .revision_context_details(state, request, runner_result, revision) |
| 15 | 7x |
state$repository$record_evidence( |
| 16 | 7x |
evidence_id = .revision_evidence_id( |
| 17 | 7x |
revision$revision_id, "revision_context" |
| 18 |
), |
|
| 19 | 7x |
revision_id = revision$revision_id, |
| 20 | 7x |
evidence_type = "revision_context", |
| 21 | 7x |
outcome = "recorded", |
| 22 | 7x |
idempotency_key = paste0( |
| 23 | 7x |
"evidence:revision-context:", revision$revision_id |
| 24 |
), |
|
| 25 | 7x |
details = details |
| 26 |
) |
|
| 27 | 7x |
invisible(details) |
| 28 |
} |
|
| 29 | ||
| 30 |
.revision_context_details <- function(state, request, runner_result, revision) {
|
|
| 31 | 7x |
profile <- state$profile |
| 32 | 7x |
snapshot <- state$snapshot |
| 33 | 7x |
analysis_data <- profile$analysis_data |
| 34 | 7x |
source_metadata <- snapshot$source_metadata %||% NULL |
| 35 | 7x |
if (!is.null(source_metadata)) {
|
| 36 | 5x |
source_metadata <- source_metadata[intersect( |
| 37 | 5x |
c("source", "file_type", "source_file_hash"),
|
| 38 | 5x |
names(source_metadata) |
| 39 |
)] |
|
| 40 |
} |
|
| 41 | 7x |
environment <- runner_result$environment %||% .execution_environment_details() |
| 42 | 7x |
environment$fingerprint <- runner_result$result$environment_fingerprint |
| 43 | 7x |
input_data <- c( |
| 44 | 7x |
list( |
| 45 | 7x |
dataset_id = snapshot$dataset_id, |
| 46 | 7x |
snapshot_id = snapshot$snapshot_id, |
| 47 | 7x |
snapshot_hash = request$snapshot_hash, |
| 48 | 7x |
content_hash = snapshot$content_hash, |
| 49 | 7x |
metadata_version = snapshot$metadata_version |
| 50 |
), |
|
| 51 | 7x |
if (!is.null(snapshot$classification)) {
|
| 52 | 3x |
list(classification = snapshot$classification) |
| 53 |
}, |
|
| 54 | 7x |
list( |
| 55 | 7x |
source_metadata = source_metadata, |
| 56 | 7x |
selected_data_columns = names(analysis_data), |
| 57 | 7x |
selected_data_classes = as.list( |
| 58 | 7x |
stats::setNames( |
| 59 | 7x |
vapply( |
| 60 | 7x |
analysis_data, |
| 61 | 7x |
\(column) class(column)[[1]], |
| 62 | 7x |
character(1) |
| 63 |
), |
|
| 64 | 7x |
names(analysis_data) |
| 65 |
) |
|
| 66 |
), |
|
| 67 | 7x |
selected_data = as.list(analysis_data) |
| 68 |
) |
|
| 69 |
) |
|
| 70 | 7x |
list( |
| 71 | 7x |
evidence_version = "revision-context-evidence-v1", |
| 72 | 7x |
revision = unclass(revision), |
| 73 | 7x |
prompt = state$prompt, |
| 74 | 7x |
specification = unclass(state$spec), |
| 75 | 7x |
input_data = input_data, |
| 76 | 7x |
generated_r_code = state$script, |
| 77 | 7x |
request = unclass(request), |
| 78 | 7x |
execution_result = unclass(runner_result$result), |
| 79 | 7x |
environment = environment, |
| 80 | 7x |
checks = list( |
| 81 | 7x |
bds_profile_blocked = profile$blocked, |
| 82 | 7x |
verification = unclass(state$verification %||% list()) |
| 83 |
), |
|
| 84 | 7x |
rendered_output = list( |
| 85 | 7x |
image_hash = revision$image_hash, |
| 86 | 7x |
analytical_hash = revision$analytical_hash |
| 87 |
), |
|
| 88 | 7x |
warnings = lapply(profile$warnings, unclass) |
| 89 |
) |
|
| 90 |
} |
| 1 |
#' The application User-Interface |
|
| 2 |
#' |
|
| 3 |
#' @param request Internal parameter for `{shiny}`.
|
|
| 4 |
#' DO NOT REMOVE. |
|
| 5 |
#' @param runtime_config Injected runtime configuration. |
|
| 6 |
#' @import shiny |
|
| 7 |
#' @importFrom bslib page_fillable card card_header card_body |
|
| 8 |
#' @noRd |
|
| 9 |
app_ui <- function(request, runtime_config = new_runtime_config()) {
|
|
| 10 | 5x |
tagList( |
| 11 | 5x |
golem_add_external_resources(), |
| 12 | 5x |
bslib::page_fillable( |
| 13 | 5x |
title = "ADaMViz SubmissionSync", |
| 14 | 5x |
theme = app_theme(), |
| 15 | 5x |
fillable = FALSE, |
| 16 | 5x |
div( |
| 17 | 5x |
class = "assurance-shell", |
| 18 | 5x |
tags$header( |
| 19 | 5x |
class = "f001-app-header", |
| 20 | 5x |
tags$span(class = "f001-app-title", "ADaMViz SubmissionSync"), |
| 21 | 5x |
tags$a( |
| 22 | 5x |
class = "f001-repo-link", |
| 23 | 5x |
href = "https://github.com/Ramdhadage/ADaMViz.SubmissionSync", |
| 24 | 5x |
target = "_blank", |
| 25 | 5x |
rel = "noopener noreferrer", |
| 26 | 5x |
`aria-label` = paste( |
| 27 | 5x |
"ADaMViz SubmissionSync on GitHub (opens in a new tab)" |
| 28 |
), |
|
| 29 | 5x |
shiny::icon("github")
|
| 30 |
) |
|
| 31 |
), |
|
| 32 | 5x |
mod_plot_generation_ui("plot_generation"),
|
| 33 | 5x |
tags$footer( |
| 34 | 5x |
class = "f001-app-footer", |
| 35 | 5x |
"This app is brought to you by Ramnath Dhadage. ", |
| 36 | 5x |
"For more information, please visit the ", |
| 37 | 5x |
tags$a( |
| 38 | 5x |
"Source Code", |
| 39 | 5x |
href = "https://github.com/Ramdhadage/ADaMViz.SubmissionSync", |
| 40 | 5x |
target = "_blank", |
| 41 | 5x |
rel = "noopener noreferrer" |
| 42 |
), |
|
| 43 | 5x |
" and ", |
| 44 | 5x |
tags$a( |
| 45 | 5x |
"Report Issues", |
| 46 | 5x |
href = paste0( |
| 47 | 5x |
"https://github.com/Ramdhadage/", |
| 48 | 5x |
"ADaMViz.SubmissionSync/issues/new" |
| 49 |
), |
|
| 50 | 5x |
target = "_blank", |
| 51 | 5x |
rel = "noopener noreferrer" |
| 52 |
), |
|
| 53 |
"." |
|
| 54 |
) |
|
| 55 |
) |
|
| 56 |
) |
|
| 57 |
) |
|
| 58 |
} |
|
| 59 | ||
| 60 |
#' Add external Resources to the Application |
|
| 61 |
#' |
|
| 62 |
#' This function is internally used to add external |
|
| 63 |
#' resources inside the Shiny application. |
|
| 64 |
#' |
|
| 65 |
#' @import shiny |
|
| 66 |
#' @importFrom golem add_resource_path activate_js favicon bundle_resources |
|
| 67 |
#' @noRd |
|
| 68 |
golem_add_external_resources <- function() {
|
|
| 69 | 5x |
add_resource_path( |
| 70 | 5x |
"www", |
| 71 | 5x |
app_sys("app/www")
|
| 72 |
) |
|
| 73 | ||
| 74 | 5x |
tags$head( |
| 75 | 5x |
favicon(), |
| 76 | 5x |
bundle_resources( |
| 77 | 5x |
path = app_sys("app/www"),
|
| 78 | 5x |
app_title = "ADaMViz.SubmissionSync" |
| 79 |
) |
|
| 80 |
# Add here other external resources |
|
| 81 |
# for example, you can add shinyalert::useShinyalert() |
|
| 82 |
) |
|
| 83 |
} |
| 1 |
new_plot_revision <- function(plot_id, creator_id, spec, version = 1L) {
|
|
| 2 | 2x |
.validate_plot_spec(spec) |
| 3 | 2x |
if (!checkmate::test_string(plot_id, min.chars = 1) || |
| 4 | 2x |
!checkmate::test_string(creator_id, min.chars = 1) || |
| 5 | 2x |
!checkmate::test_int(version, lower = 1L)) {
|
| 6 | ! |
cli::cli_abort(paste0( |
| 7 | ! |
"A revision requires valid plot, creator, ", |
| 8 | ! |
"and version identifiers" |
| 9 |
)) |
|
| 10 |
} |
|
| 11 | 2x |
status <- .initial_revision_status(spec$fields$scale_mode) |
| 12 | 2x |
structure( |
| 13 | 2x |
list( |
| 14 | 2x |
revision_id = paste(plot_id, version, sep = "-r"), |
| 15 | 2x |
plot_id = plot_id, |
| 16 | 2x |
creator_id = creator_id, |
| 17 | 2x |
version = version, |
| 18 | 2x |
spec_hash = spec$hash, |
| 19 | 2x |
status = status |
| 20 |
), |
|
| 21 | 2x |
class = "plot_revision" |
| 22 |
) |
|
| 23 |
} |
| 1 |
#' Access files in the current app |
|
| 2 |
#' |
|
| 3 |
#' NOTE: If you manually change your package name in the DESCRIPTION, |
|
| 4 |
#' don't forget to change it here too, and in the config file. |
|
| 5 |
#' For a safer name change mechanism, use |
|
| 6 |
#' `golem::set_golem_name()`. |
|
| 7 |
#' |
|
| 8 |
#' @param ... character vectors, specifying subdirectory and file(s) |
|
| 9 |
#' within your package. The default, none, returns the root of the app. |
|
| 10 |
#' |
|
| 11 |
#' @noRd |
|
| 12 |
app_sys <- function(...) {
|
|
| 13 | 10x |
system.file(..., package = "ADaMViz.SubmissionSync") |
| 14 |
} |
|
| 15 | ||
| 16 | ||
| 17 |
#' Read App Config |
|
| 18 |
#' |
|
| 19 |
#' @param value Value to retrieve from the config file. |
|
| 20 |
#' @param config GOLEM_CONFIG_ACTIVE value. If unset, R_CONFIG_ACTIVE. |
|
| 21 |
#' If unset, "default". |
|
| 22 |
#' @param use_parent Logical, scan the parent directory for config file. |
|
| 23 |
#' @param file Location of the config file |
|
| 24 |
#' |
|
| 25 |
#' @noRd |
|
| 26 |
get_golem_config <- function( |
|
| 27 |
value, |
|
| 28 |
config = Sys.getenv( |
|
| 29 |
"GOLEM_CONFIG_ACTIVE", |
|
| 30 |
Sys.getenv( |
|
| 31 |
"R_CONFIG_ACTIVE", |
|
| 32 |
"default" |
|
| 33 |
) |
|
| 34 |
), |
|
| 35 |
use_parent = TRUE, |
|
| 36 |
# Modify this if your config file is somewhere else |
|
| 37 |
file = app_sys("golem-config.yml")
|
|
| 38 |
) {
|
|
| 39 | ! |
config::get( |
| 40 | ! |
value = value, |
| 41 | ! |
config = config, |
| 42 | ! |
file = file, |
| 43 | ! |
use_parent = use_parent |
| 44 |
) |
|
| 45 |
} |
| 1 |
new_limits_policy <- function( |
|
| 2 |
max_prompt_chars = 4000L, |
|
| 3 |
max_levels = 50L, |
|
| 4 |
max_visits = 100L |
|
| 5 |
) {
|
|
| 6 | 1x |
values <- c(max_prompt_chars, max_levels, max_visits) |
| 7 | 1x |
if (!all(vapply(values, checkmate::test_int, logical(1), lower = 1L))) {
|
| 8 | ! |
cli::cli_abort("All content limits must be positive integers")
|
| 9 |
} |
|
| 10 | 1x |
structure(list( |
| 11 | 1x |
version = "limits-v1", |
| 12 | 1x |
max_prompt_chars = max_prompt_chars, |
| 13 | 1x |
max_levels = max_levels, |
| 14 | 1x |
max_visits = max_visits |
| 15 | 1x |
), class = "limits_policy") |
| 16 |
} |
|
| 17 | ||
| 18 |
enforce_limit <- function(value, limit, field) {
|
|
| 19 | 1x |
if (nchar(value, type = "chars") > limit) {
|
| 20 | 1x |
cli::cli_abort("{.field {field}} exceeds its configured limit")
|
| 21 |
} |
|
| 22 | ! |
invisible(value) |
| 23 |
} |
| 1 |
#' Create the disabled ellmer prompt interpreter placeholder |
|
| 2 |
#' |
|
| 3 |
#' The real provider remains unavailable until its retention, training, region, |
|
| 4 |
#' transport, and incident-handling terms are approved for the target use. |
|
| 5 |
#' |
|
| 6 |
#' @param ... Reserved for future provider-specific configuration. |
|
| 7 |
#' |
|
| 8 |
#' @return This function always aborts in the local POC. |
|
| 9 |
#' @export |
|
| 10 |
ellmer_prompt_interpreter <- function(...) {
|
|
| 11 | ! |
cli::cli_abort(paste( |
| 12 | ! |
"The ellmer prompt interpreter is disabled until provider terms", |
| 13 | ! |
"are approved" |
| 14 |
)) |
|
| 15 |
} |
| 1 |
#' Return the application Bootstrap theme. |
|
| 2 |
#' |
|
| 3 |
#' Keeping the theme in one function makes the visual contract explicit and |
|
| 4 |
#' gives later modules one stable place to extend application branding. |
|
| 5 |
#' |
|
| 6 |
#' @return A pinned Bootstrap 5 bslib theme. |
|
| 7 |
#' @noRd |
|
| 8 |
app_theme <- function() {
|
|
| 9 | 5x |
bslib::bs_theme( |
| 10 | 5x |
version = 5, |
| 11 | 5x |
primary = "#0B3C5D", |
| 12 | 5x |
secondary = "#5B6770", |
| 13 | 5x |
success = "#1B6E3A", |
| 14 | 5x |
base_font = bslib::font_collection( |
| 15 | 5x |
"Inter", |
| 16 | 5x |
"Segoe UI", |
| 17 | 5x |
"Arial", |
| 18 | 5x |
"sans-serif" |
| 19 |
), |
|
| 20 | 5x |
heading_font = bslib::font_collection( |
| 21 | 5x |
"Inter", |
| 22 | 5x |
"Segoe UI", |
| 23 | 5x |
"Arial", |
| 24 | 5x |
"sans-serif" |
| 25 |
) |
|
| 26 |
) |
|
| 27 |
} |
| 1 |
reconcile_workspace_exports <- function(workspace_provider, destination_id) {
|
|
| 2 | 2x |
.validate_workspace_provider(workspace_provider) |
| 3 | 2x |
.validate_workspace_token(destination_id, "destination_id") |
| 4 | 2x |
workspace_provider$reconcile(destination_id) |
| 5 |
} |