From b9aaff53d7011fa1b20b4c5b40c9a85a7a504683 Mon Sep 17 00:00:00 2001 From: Jeffrey Girard Date: Sun, 18 Oct 2026 17:41:40 -0500 Subject: [PATCH 01/11] M149 T1: verbose on nested_workflow_map() --- R/checks.R | 21 ++++++++ R/nested-workflow-map.R | 48 ++++++++++++++++++- cairn/ROADMAP.md | 2 +- cairn/milestones/M149-argument-gaps.md | 6 ++- man/nested_workflow_map.Rd | 7 ++- .../_snaps/nested-workflow-map-verbose.md | 10 ++++ .../test-nested-workflow-map-checks.R | 34 ++++++++++++- .../test-nested-workflow-map-verbose.R | 36 ++++++++++++++ 8 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 tests/testthat/_snaps/nested-workflow-map-verbose.md create mode 100644 tests/testthat/test-nested-workflow-map-verbose.R diff --git a/R/checks.R b/R/checks.R index e09c556c..fb2543f8 100644 --- a/R/checks.R +++ b/R/checks.R @@ -2951,6 +2951,27 @@ check_map_fn <- function(fn, call = rlang::caller_env()) { ) } +# The map's `verbose` (M149): one `TRUE` or `FALSE`, as workflowsets' +# `check_bool()` reads it, refused before any workflow runs. It sits in +# `workflow_map()`'s place, before `...`, so a third argument passed by +# position lands here; the hint says the rest are matched by name, which +# the dots check said before `verbose` took the place. +check_map_verbose <- function(verbose, call = rlang::caller_env()) { + if (rlang::is_bool(verbose)) { + return(invisible(verbose)) + } + cli::cli_abort( + c( + "{.arg verbose} must be {.code TRUE} or {.code FALSE}.", + x = "Got {.obj_type_friendly {verbose}}.", + i = "Every argument of {.arg fn} must be named, such as \\ + {.code resamples = }." + ), + class = "nestedtune_bad_verbose", + call = call + ) +} + # What an orchestrator accepts beyond the workflow: its formals other than # `object` (so `resamples` and everything behind the dots), and the `control` # every one of the six takes through `...` (D-042). Read off the function diff --git a/R/nested-workflow-map.R b/R/nested-workflow-map.R index c6d90129..e0b93556 100644 --- a/R/nested-workflow-map.R +++ b/R/nested-workflow-map.R @@ -56,6 +56,10 @@ #' `"nested_tune_race_anova"`, `"nested_tune_race_win_loss"` and #' `"nested_tune_sim_anneal"`. With `"nested_fit_resamples"`, no workflow #' can have a parameter to tune. +#' @param verbose Whether `nested_workflow_map()` writes a message as each +#' workflow starts and as it finishes. The default is `FALSE`. The finish +#' message gives the time the workflow took. A workflow that gives an +#' error has no finish message. #' @param ... The arguments of `fn`, each one named. `resamples`, the nested #' design, is required, and a `control` goes here as well. A name that `fn` #' does not take is an error. A `metrics` set reaches every workflow, and @@ -83,10 +87,16 @@ #' [collect_metrics.nested_results_set()], [nested_final_fit()], #' [workflowsets::workflow_map()] #' @export -nested_workflow_map <- function(object, fn = "nested_tune_grid", ...) { +nested_workflow_map <- function( + object, + fn = "nested_tune_grid", + verbose = FALSE, + ... +) { dots <- capture_dots(...) check_workflow_set(object) check_map_fn(fn) + check_map_verbose(verbose) check_map_dots(dots, fn) call <- rlang::current_env() @@ -141,11 +151,13 @@ nested_workflow_map <- function(object, fn = "nested_tune_grid", ...) { for (i in seq_along(ids)) { assign(".Random.seed", entry_seed, envir = globalenv()) args <- map_args(dots, object$option[[i]], routes[[i]], fn) + started <- log_map_start(verbose, i, ids, routes[[i]]) results[[i]] <- for_workflow( ids[[i]], call, run_orchestrator(routes[[i]], workflows[[i]], args) ) + log_map_end(verbose, i, ids, routes[[i]], started) } new_nested_results_set(ids, workflows, results, fn) } @@ -171,6 +183,40 @@ map_args <- function(dots, option, route, fn) { args[intersect(names(args), orchestrator_args(route))] } +# The `verbose` log (M149), in workflowsets' `log_progress()` form: a line as +# each workflow starts and one, with the time it took, as it finishes. A +# workflow that errors gets no end line, because the error stops the map +# and `for_workflow()` already names the workflow in it. The clock is read +# whether or not the log is on, and reading it draws no random number. +log_map_start <- function(verbose, i, ids, route) { + if (verbose) { + msg <- map_log_text(i, ids, route) + cli::cli_inform(c(i = "{msg}")) + } + proc.time()[["elapsed"]] +} + +log_map_end <- function(verbose, i, ids, route, started) { + if (!verbose) { + return(invisible(NULL)) + } + took <- proc.time()[["elapsed"]] - started + time <- paste0(format(round(took, 1L), nsmall = 1L), "s") + msg <- paste0(map_log_text(i, ids, route), " (", time, ")") + cli::cli_inform(c(v = "{msg}")) +} + +map_log_text <- function(i, ids, route) { + event <- if (identical(route, "nested_fit_resamples")) { + "resampling" + } else { + "tuning" + } + # Pasted, not interpolated, and handed to cli as a value, so a brace in + # an id is not read as cli markup (the M83 lesson). + paste0(i, " of ", length(ids), " ", event, ": ", ids[[i]]) +} + # One orchestrator call, built as a call in an environment that binds the # workflow and every argument to a name (the M05 lesson: a value inlined # into a call is deparsed into every condition raised beneath it, and a diff --git a/cairn/ROADMAP.md b/cairn/ROADMAP.md index b378d3f4..5d4c35e0 100644 --- a/cairn/ROADMAP.md +++ b/cairn/ROADMAP.md @@ -4,7 +4,7 @@ _Last hygiene check: 2026-10-18 M148 done. Archived M148, pruned M145's row, add ## Milestones | ID | Title | Status | Depends on | Priority | File/Archive | |---|---|---|---|---|---| -| M149 | Three arguments from tidymodels: verbose on the map, a wide inner table, one workflow's plot | planned | — | normal | milestones/M149-argument-gaps.md | +| M149 | Three arguments from tidymodels: verbose on the map, a wide inner table, one workflow's plot | in-progress | — | normal | milestones/M149-argument-gaps.md | | M148 | Each export and result column is set beside its tidymodels counterpart | done | — | normal | milestones/archive/M148-export-review.md | | M147 | CI runs the tests CRAN runs, with the Suggests and without them | done | — | normal | milestones/archive/M147-cran-subset-ci.md | | M146 | DESCRIPTION's Title and Description say what the package does, in CRAN's form | done | — | normal | milestones/archive/M146-description-text.md | diff --git a/cairn/milestones/M149-argument-gaps.md b/cairn/milestones/M149-argument-gaps.md index 46830264..3205f7fa 100644 --- a/cairn/milestones/M149-argument-gaps.md +++ b/cairn/milestones/M149-argument-gaps.md @@ -1,13 +1,13 @@ # M149: Three arguments from tidymodels: verbose on the map, a wide inner table, one workflow's plot -- **Status:** planned +- **Status:** in-progress - **Priority:** normal - **Depends on:** — - **Driving RR:** — - **Principles touched:** GP1 - **Resolves:** — - **Surface tier:** user-facing — three new arguments on exported functions -- **Branch/PR:** — +- **Branch/PR:** m149-argument-gaps ## Goal @@ -54,6 +54,8 @@ The three argument gaps that M148's review proposed are closed in the tidymodels - 2026-10-18: plan gate chose no failure line under `verbose` over workflowsets' "failed with" line, because the error stops the run and already names the workflow; falsified by `nested_workflow_map()` coming to keep running after a workflow fails. - 2026-10-18: plan gate chose a one-time default comparison recorded in the page over a new section of `benchmarks/export-surface.R`, so the script's promise does not widen; falsified by a later review needing the default comparison again. +- 2026-10-18: T1 code and tests in place: `check_map_verbose()` (class `nestedtune_bad_verbose`), start and finish lines through `cli::cli_inform()` with the text passed as a value. A design passed third by position now lands in `verbose`, so its refusal gains a hint that every argument of `fn` must be named, and the old unnamed-dots test moved to a fourth positional. Full suite pending. + ## Decisions ## Review diff --git a/man/nested_workflow_map.Rd b/man/nested_workflow_map.Rd index f21f113f..01b017f4 100644 --- a/man/nested_workflow_map.Rd +++ b/man/nested_workflow_map.Rd @@ -4,7 +4,7 @@ \alias{nested_workflow_map} \title{Run every workflow of a workflow set through one nested design} \usage{ -nested_workflow_map(object, fn = "nested_tune_grid", ...) +nested_workflow_map(object, fn = "nested_tune_grid", verbose = FALSE, ...) } \arguments{ \item{object}{A workflow set.} @@ -15,6 +15,11 @@ default is \code{"nested_tune_grid"}. The others are \code{"nested_tune_bayes"}, \code{"nested_tune_sim_anneal"}. With \code{"nested_fit_resamples"}, no workflow can have a parameter to tune.} +\item{verbose}{Whether \code{nested_workflow_map()} writes a message as each +workflow starts and as it finishes. The default is \code{FALSE}. The finish +message gives the time the workflow took. A workflow that gives an +error has no finish message.} + \item{...}{The arguments of \code{fn}, each one named. \code{resamples}, the nested design, is required, and a \code{control} goes here as well. A name that \code{fn} does not take is an error. A \code{metrics} set reaches every workflow, and diff --git a/tests/testthat/_snaps/nested-workflow-map-verbose.md b/tests/testthat/_snaps/nested-workflow-map-verbose.md new file mode 100644 index 00000000..40659f1a --- /dev/null +++ b/tests/testthat/_snaps/nested-workflow-map-verbose.md @@ -0,0 +1,10 @@ +# AC1: verbose = TRUE logs each workflow's start and finish, and the result does not change + + Code + loud <- nested_workflow_map(wset, verbose = TRUE, resamples = folds, grid = det_grid()) + Message + i 1 of 2 tuning: tuned + v 1 of 2 tuning: tuned (