R/action_levels.R

Defines functions stock_warning stock_stoppage covert_actions prime_actions normalize_fraction_count normalize_fns_list stop_on_fail warn_on_fail action_levels

Documented in action_levels stop_on_fail warn_on_fail

#------------------------------------------------------------------------------#
# 
#                 _         _    _      _                _    
#                (_)       | |  | |    | |              | |   
#   _ __    ___   _  _ __  | |_ | |__  | |  __ _  _ __  | | __
#  | '_ \  / _ \ | || '_ \ | __|| '_ \ | | / _` || '_ \ | |/ /
#  | |_) || (_) || || | | || |_ | |_) || || (_| || | | ||   < 
#  | .__/  \___/ |_||_| |_| \__||_.__/ |_| \__,_||_| |_||_|\_\
#  | |                                                        
#  |_|                                                        
#  
#  This file is part of the 'rstudio/pointblank' project.
#  
#  Copyright (c) 2017-2024 pointblank authors
#  
#  For full copyright and license information, please look at
#  https://rstudio.github.io/pointblank/LICENSE.html
# 
#------------------------------------------------------------------------------#


#' Set action levels: failure thresholds and functions to invoke
#' 
#' @description
#' 
#' The `action_levels()` function works with the `actions` argument that is
#' present in the [create_agent()] function and in every validation step
#' function (which also has an `actions` argument). With it, we can provide
#' threshold *failure* values for any combination of `warn`, `stop`, or `notify`
#' failure states.
#' 
#' We can react to any entering of a state by supplying corresponding functions
#' to the `fns` argument. They will undergo evaluation at the time when the
#' matching state is entered. If provided to [create_agent()] then the policies
#' will be applied to every validation step, acting as a default for the
#' validation as a whole.
#' 
#' Calls of `action_levels()` could also be applied directly to any validation
#' step and this will act as an override if set also in [create_agent()]. Usage
#' of `action_levels()` is required to have any useful side effects (i.e.,
#' warnings, throwing errors) in the case of validation functions operating
#' directly on data (e.g., `mtcars %>% col_vals_lt("mpg", 35)`). There are two
#' helper functions that are convenient when using validation functions directly
#' on data (the `agent`-less workflow): `warn_on_fail()` and `stop_on_fail()`.
#' These helpers either warn or stop (default failure threshold for each is set
#' to `1`), and, they do so with informative warning or error messages. The
#' `stop_on_fail()` helper is applied by default when using validation functions
#' directly on data (more information on this is provided in *Details*).
#' 
#' @details
#' 
#' The output of the `action_levels()` call in `actions` will be interpreted
#' slightly differently if using an *agent* or using validation functions
#' directly on a data table. For convenience, when working directly on data, any
#' values supplied to `warn_at` or `stop_at` will be automatically given a stock
#' `warning()` or `stop()` function. For example using 
#' `small_table %>% col_is_integer("date")` will provide a detailed stop message
#' by default, indicating the reason for the failure. If you were to supply the
#' `fns` for `stop` or `warn` manually then the stock functions would be
#' overridden. Furthermore, if `actions` is NULL in this workflow (the default),
#' **pointblank** will use a `stop_at` value of `1` (providing a detailed,
#' context-specific error message if there are any *failing* units). We can
#' absolutely suppress this automatic stopping behavior by at each validation
#' step by setting `active = FALSE`. In this interactive data case, there is no
#' stock function given for `notify_at`. The `notify` failure state is less
#' commonly used in this workflow as it is in the *agent*-based one.
#' 
#' When using an *agent*, we often opt to not use any functions in `fns` as the
#' `warn`, `stop`, and `notify` failure states will be reported on when using
#' `create_agent_report()` (and, usually that's sufficient). Instead, using the
#' `end_fns` argument is a better choice since that scheme provides useful data
#' on the entire interrogation, allowing for finer control on side effects and
#' reducing potential for duplicating any side effects.
#' 
#' @param warn_at *Threshold value for the 'warn' failure state*
#' 
#'   `scalar<integer|numeric>(val>=0)` // *default:* `NULL` (`optional`)
#' 
#'   Either the threshold number or the threshold fraction of *failing* test
#'   units that result in entering the `warn` failure state.
#' 
#' @param stop_at *Threshold value for the 'stop' failure state*
#' 
#'   `scalar<integer|numeric>(val>=0)` // *default:* `NULL` (`optional`)
#' 
#'   Either the threshold number or the threshold fraction of *failing* test
#'   units that result in entering the `stop` failure state.
#' 
#' @param notify_at *Threshold value for the 'notify' failure state*
#' 
#'   `scalar<integer|numeric>(val>=0)` // *default:* `NULL` (`optional`)
#' 
#'   Either the threshold number or the threshold fraction of *failing* test
#'   units that result in entering the `notify` failure state.
#' 
#' @param fns *Functions to execute when entering failure states*
#' 
#'   `list` // *default:* `NULL` (`optional`)
#' 
#'   A named list of functions that is to be paired with the appropriate failure
#'   states. The syntax for this list involves using failure state names from
#'   the set of `warn`, `stop`, and `notify`. The functions corresponding to the
#'   failure states are provided as formulas (e.g., 
#'   `list(warn = ~ warning("Too many failures."))`. A series of expressions for
#'   each named state can be used by enclosing the set of statements with `{ }`.
#' 
#' @return An `action_levels` object.
#' 
#' @section Defining threshold values:
#' 
#' Any threshold values supplied for the `warn_at`, `stop_at`, or `notify_at`
#' arguments correspond to the `warn`, `stop`, and `notify` failure states,
#' respectively. A threshold value can either relates to an absolute number of
#' test units or a fraction-of-total test units that are *failing*. Exceeding
#' the threshold means entering one or more of the `warn`, `stop`, or `notify`
#' failure states.
#'
#' If a threshold value is a decimal value between `0` and `1` then it's a
#' proportional failure threshold (e.g., `0.15` indicates that if 15 percent of
#' the test units are found to be *failing*, then the designated failure state
#' is entered). Absolute values starting from `1` can be used instead, and this
#' constitutes an absolute failure threshold (e.g., `10` means that if 10 of the
#' test units are found to be *failing*, the failure state is entered).
#' 
#' @section Examples:
#' 
#' For these examples, we will use the included `small_table` dataset.
#' 
#' ```{r}
#' small_table
#' ```
#' 
#' Create an `action_levels` object with fractional values for the `warn`,
#' `stop`, and `notify` states.
#' 
#' ```r
#' al <- 
#'   action_levels(
#'     warn_at = 0.2,
#'     stop_at = 0.8,
#'     notify_at = 0.5
#'   )
#' ```
#'   
#' A summary of settings for the `al` object is shown by printing it.
#' 
#' Create a pointblank agent and apply the `al` object to `actions`. Add two
#' validation steps and interrogate the `small_table`.
#' 
#' ```r
#' agent_1 <-
#'   create_agent(
#'     tbl = small_table,
#'     actions = al
#'   ) %>%
#'   col_vals_gt(
#'     columns = a, value = 2
#'   ) %>%
#'   col_vals_lt(
#'     columns = d, value = 20000
#'   ) %>%
#'   interrogate()
#' ```
#' 
#' The report from the agent will show that the `warn` state has been entered
#' for the first validation step but not the second one. We can confirm this in
#' the console by inspecting the `warn` component in the agent's x-list.
#' 
#' ```r
#' x_list <- get_agent_x_list(agent = agent_1)
#' 
#' x_list$warn
#' ```
#' 
#' ```
#' ## [1]  TRUE FALSE
#' ```
#' 
#' Applying the `action_levels` object to the agent means that all validation
#' steps will inherit these settings but we can override this by applying
#' another such object to the validation step instead (this time using the
#' `warn_on_fail()` shorthand).
#' 
#' ```r
#' agent_2 <-
#'   create_agent(
#'     tbl = small_table,
#'     actions = al
#'   ) %>%
#'   col_vals_gt(
#'     columns = a, value = 2,
#'     actions = warn_on_fail(warn_at = 0.5)
#'   ) %>%
#'   col_vals_lt(
#'     columns = d, value = 20000
#'   ) %>%
#'   interrogate()
#' ```
#'
#' In this case, the first validation step has a less stringent failure
#' threshold for the `warn` state and it's high enough that the condition is not
#' entered. This can be confirmed in the console through inspection of the
#' x-list `warn` component.
#' 
#' ```r
#' x_list <- get_agent_x_list(agent = agent_2)
#' 
#' x_list$warn
#' ```
#' 
#' ```
#' ## [1] FALSE FALSE
#' ```
#'
#' In the context of using validation functions directly on data (i.e., no
#' involvement of an agent) we want to trigger warnings and raise errors. The
#' following will yield a warning if it is executed (returning the `small_table`
#' data).
#' 
#' ```r
#' small_table %>%
#'   col_vals_gt(
#'     columns = a, value = 2,
#'     actions = warn_on_fail(warn_at = 2)
#'   )
#' ```
#' 
#' \preformatted{## # A tibble: 13 × 8
#' ##    date_time           date           a b           c      d e    
#' ##    <dttm>              <date>     <int> <chr>   <dbl>  <dbl> <lgl>
#' ##  1 2016-01-04 11:00:00 2016-01-04     2 1-bcd-…     3  3423. TRUE 
#' ##  2 2016-01-04 00:32:00 2016-01-04     3 5-egh-…     8 10000. TRUE 
#' ##  3 2016-01-05 13:32:00 2016-01-05     6 8-kdg-…     3  2343. TRUE 
#' ##  4 2016-01-06 17:23:00 2016-01-06     2 5-jdo-…    NA  3892. FALSE
#' ##  5 2016-01-09 12:36:00 2016-01-09     8 3-ldm-…     7   284. TRUE 
#' ##  6 2016-01-11 06:15:00 2016-01-11     4 2-dhe-…     4  3291. TRUE 
#' ##  7 2016-01-15 18:46:00 2016-01-15     7 1-knw-…     3   843. TRUE 
#' ##  8 2016-01-17 11:27:00 2016-01-17     4 5-boe-…     2  1036. FALSE
#' ##  9 2016-01-20 04:30:00 2016-01-20     3 5-bce-…     9   838. FALSE
#' ## 10 2016-01-20 04:30:00 2016-01-20     3 5-bce-…     9   838. FALSE
#' ## 11 2016-01-26 20:07:00 2016-01-26     4 2-dmx-…     7   834. TRUE 
#' ## 12 2016-01-28 02:51:00 2016-01-28     2 7-dmx-…     8   108. FALSE
#' ## 13 2016-01-30 11:23:00 2016-01-30     1 3-dka-…    NA  2230. TRUE 
#' ## # … with 1 more variable: f <chr>
#' ## Warning message:
#' ## Exceedance of failed test units where values in `a` should have been >
#' ## `2`.
#' ## The `col_vals_gt()` validation failed beyond the absolute threshold
#' ## level (2).
#' ## * failure level (4) >= failure threshold (2)}
#' 
#' 
#' 
#' With the same pipeline, not supplying anything for `actions` (it's `NULL` by
#' default) will have the same effect as using `stop_on_fail(stop_at = 1)`.
#' 
#' ```r
#' small_table %>%
#'   col_vals_gt(columns = a, value = 2)
#' ```
#' 
#' ```
#' ## Error: Exceedance of failed test units where values in `a` should have
#' ## been > `2`.
#' ## The `col_vals_gt()` validation failed beyond the absolute threshold
#' ## level (1).
#' ## * failure level (4) >= failure threshold (1)
#' ```
#' 
#' 
#' Here's the equivalent set of statements:
#' 
#' ```r
#' small_table %>%
#'   col_vals_gt(
#'     columns = a, value = 2,
#'     actions = stop_on_fail(stop_at = 1)
#'   )
#' ```
#' 
#' ```
#' ## Error: Exceedance of failed test units where values in `a` should have
#' ## been > `2`.
#' ## The `col_vals_gt()` validation failed beyond the absolute threshold
#' ## level (1).
#' ## * failure level (4) >= failure threshold (1)
#' ```
#' 
#' 
#' This is because the `stop_on_fail()` call is auto-injected in the default
#' case (when operating on data) for your convenience. Behind the scenes a
#' 'secret agent' uses 'covert actions': all so you can type less.
#' 
#' @family Planning and Prep
#' @section Function ID:
#' 1-5
#' 
#' @name action_levels
NULL

#' @rdname action_levels
#' @export
action_levels <- function(
    warn_at = NULL,
    stop_at = NULL,
    notify_at = NULL,
    fns = NULL
) {
  
  fns <- normalize_fns_list(fns = fns)
  
  warn_list <- normalize_fraction_count(warn_at)
  stop_list <- normalize_fraction_count(stop_at)
  notify_list <- normalize_fraction_count(notify_at)
  
  action_levels <- 
    list(
      warn_fraction = warn_list$fraction,
      warn_count = warn_list$count,
      stop_fraction = stop_list$fraction,
      stop_count = stop_list$count,
      notify_fraction = notify_list$fraction,
      notify_count = notify_list$count,
      fns = fns
    )
  
  # Assign the class attribute value `action_levels` to
  # the `action_levels` object
  attr(action_levels, "class") <- "action_levels"
  
  action_levels
}

#' @rdname action_levels
#' @export
warn_on_fail <- function(warn_at = 1) {
  action_levels(warn_at = warn_at, fns = list(warn = ~stock_warning(x = x)))
}

#' @rdname action_levels
#' @export
stop_on_fail <- function(stop_at = 1) {
  action_levels(stop_at = stop_at, fns = list(stop = ~stock_stoppage(x = x)))
}

normalize_fns_list <- function(fns) {
  
  if (is.null(fns) || (is.list(fns) && length(fns) == 0)) {
    return(list(warn = NULL, stop = NULL, notify = NULL))
  }
  
  are_formulas <-
    fns %>%
    vapply(
      FUN.VALUE = logical(1), USE.NAMES = FALSE,
      FUN = function(x) rlang::is_formula(x)
    )
  
  if (!all(are_formulas)) {
    
    stop(
      "All components of the `fns` list must be formulas.",
      call. = FALSE
    )
  }
  
  if ("" %in% names(fns)) {
    
    stop("The `fns` list must be fully named.", call. = FALSE)
  }
  
  if (!all(names(fns) %in% c("warn", "stop", "notify"))) {
    
    stop(
      "All names in the `fns` list must be one of `warn`, `stop`, or `notify`.",
      call. = FALSE
    )
  }
  
  fns
}

normalize_fraction_count <- function(x) {
  
  if (!is.null(x) && !any(c(inherits(x, "numeric"), inherits(x, "integer")))) {
    
    stop(
      "All values provided to `action_levels()` must be either ",
      "`numeric` or `integer` types.",
      call. = FALSE
    )
  }
  
  if (!is.null(x) && x <= 0) {
    
    stop(
      "All values provided to `action_levels()` must be `>=0`.",
      call. = FALSE
    )
  }
  
  if (!is.null(x)) {
    if (x < 1) {
      fraction <- x
      count <- NULL
    } else if (x >= 1) {
      count <- floor(x) %>% as.numeric()
      fraction <- NULL
    }
  } else {
    fraction <- NULL
    count <- NULL
  }
  
  list(fraction = fraction, count = count)
}

prime_actions <- function(actions) {

  if (!is.null(actions)) {
    if (is.null(actions$fns$warn)) {
      actions$fns$warn <- ~stock_warning(x = x)
    }
    if (is.null(actions$fns$stop)) {
      actions$fns$stop <- ~stock_stoppage(x = x)
    }
  } else {
    actions <- 
      action_levels(
        stop_at = 1,
        fns = list(stop = ~stock_stoppage(x = x))
      )
  }
  
  actions
}

covert_actions <- function(actions, agent) {
  
  if (is.null(actions)) {
    actions <- agent$actions
  } 
  
  actions
}

stock_stoppage <- function(x) {

  fn_name <- x$type
  column_text <- prep_column_text(x$column)
  values_text <- prep_values_text(values = x$values, limit = 3, lang = "en")
  operator <- prep_operator_text(fn_name = x$type)
  
  if (grepl("between", fn_name)) {
    value_1 <- prep_values_text(x$values) %>% tidy_gsub(",.*", "")
    value_2 <- prep_values_text(x$values) %>% tidy_gsub(".*, ", "")
  }
  
  if (grepl("col_is", fn_name)) {
    col_type <- prep_col_type(fn_name = fn_name)
  }
  
  if (!is.null(x$actions$stop_count)) {
    threshold <- x$actions$stop_count
    failed_amount <- x$n_failed
    threshold_type <- "absolute"
  } else if (!is.null(x$actions$stop_fraction)) {
    threshold <- x$actions$stop_fraction
    failed_amount <- x$f_failed
    threshold_type <- "proportional"
  }
  
  failure_message <- 
    glue::glue(failure_message_gluestring(fn_name = fn_name, lang = "en"))
  
  stop(failure_message, call. = FALSE)
}

stock_warning <- function(x) {

  fn_name <- x$type
  column_text <- prep_column_text(x$column)
  values_text <- prep_values_text(values = x$values, limit = 3, lang = "en")
  operator <- prep_operator_text(fn_name = x$type)
  
  if (grepl("between", fn_name)) {
    value_1 <- prep_values_text(x$values) %>% tidy_gsub(",.*", "")
    value_2 <- prep_values_text(x$values) %>% tidy_gsub(".*, ", "")
  }
  
  if (grepl("col_is", fn_name)) {
    col_type <- prep_col_type(fn_name = fn_name)
  }
  
  if (!is.null(x$actions$warn_count)) {
    threshold <- x$actions$warn_count
    failed_amount <- x$n_failed
    threshold_type <- "absolute"
  } else if (!is.null(x$actions$warn_fraction)) {
    threshold <- x$actions$warn_fraction
    failed_amount <- x$f_failed
    threshold_type <- "proportional"
  }
  
  failure_message <- 
    glue::glue(failure_message_gluestring(fn_name = fn_name, lang = "en"))
  
  warning(failure_message, call. = FALSE)
}
rich-iannone/pointblank documentation built on March 29, 2024, 6:24 a.m.