diff --git a/DESCRIPTION b/DESCRIPTION index 868ce116..6bb88b23 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Package: report Type: Package Title: Automated Reporting of Results and Statistical Models -Version: 0.6.4 +Version: 0.6.5 Authors@R: c(person(given = "Dominique", family = "Makowski", @@ -117,6 +117,7 @@ Collate: 'report.stanreg.R' 'report.brmsfit.R' 'report.character.R' + 'report.check_outliers.R' 'report.compare.loo.R' 'report.compare_performance.R' 'report.coxph.R' @@ -135,6 +136,7 @@ Collate: 'report.test_performance.R' 'report.zeroinfl.R' 'report_ai.R' + 'report_assumptions.R' 'report_effectsize.R' 'report_htest_chi2.R' 'report_htest_cor.R' @@ -162,3 +164,4 @@ Collate: 'zzz.R' Roxygen: list(markdown = TRUE) Config/roxygen2/version: 8.0.0 +RoxygenNote: 7.3.3 diff --git a/NAMESPACE b/NAMESPACE index 3447bd88..174a6ad6 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -38,6 +38,7 @@ S3method(report,bayesfactor_inclusion) S3method(report,bayesfactor_models) S3method(report,brmsfit) S3method(report,character) +S3method(report,check_outliers) S3method(report,compare.loo) S3method(report,compare_performance) S3method(report,coxph) @@ -247,6 +248,7 @@ S3method(report_text,bayesfactor_inclusion) S3method(report_text,bayesfactor_models) S3method(report_text,brmsfit) S3method(report_text,character) +S3method(report_text,check_outliers) S3method(report_text,compare_performance) S3method(report_text,coxph) S3method(report_text,data.frame) @@ -305,7 +307,7 @@ export(is.report) export(print_html) export(print_md) export(report) -export(report_ai) +export(report_assumptions) export(report_date) export(report_effectsize) export(report_info) diff --git a/NEWS.md b/NEWS.md index 3b4df89e..f0d39916 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,9 +1,23 @@ # report (devel) +New features + +* `report_assumptions()`: new function that reports model assumption checks for a model, covering influential observations (via `performance::check_outliers()`), collinearity (via `performance::check_collinearity()`), and homoskedasticity (via `performance::check_heteroskedasticity()`). Calling `summary()` on the result returns a compact one-sentence version integrating all checks. Supports `audience = "ai"` for a compact token-efficient structured output. + +* `report()` / `report_text()` for linear models: assumption checks (from `report_assumptions()`) are now automatically included right after the model description (before the performance sentence). For human audience, the compact one-sentence summary is inserted there; for AI audience, a structured `## Assumptions` section is added there in the output. + +Bug fixes + +* Fixed missing `@exportS3Method` roxygen2 tags on `report_ai` S3 methods (`default`, `lm`, `glm`, `merMod`, `glmmTMB`), which caused warnings during package loading. + +* `report_text()`: new method for `performance::check_outliers()` output, producing a brief sentence such as "2 observations (5.88%) were detected as potential outliers based on Cook's distance (threshold = 0.806)." + Bug fixes * Fixed an issue in `report()` where the reference level for logical predictors was incorrectly displayed as `[?]` instead of `FALSE` for the intercept (@M-Colley, #598). +* Fixed the *AI-Optimized Reports* vignette to no longer refer to the internal `report_ai()` function; all examples now use `report(x, audience = "ai")`. + # report 0.6.4 New features diff --git a/R/report.check_outliers.R b/R/report.check_outliers.R new file mode 100644 index 00000000..b2a860a0 --- /dev/null +++ b/R/report.check_outliers.R @@ -0,0 +1,112 @@ +#' Reporting outlier detection results +#' +#' Create a brief textual report for outlier detection results from +#' [performance::check_outliers()]. +#' +#' @param x Object of class `check_outliers` as returned by +#' [performance::check_outliers()]. +#' @inheritParams report +#' +#' @inherit report return seealso +#' +#' @examplesIf requireNamespace("performance", quietly = TRUE) +#' \donttest{ +#' library(report) +#' library(performance) +#' +#' mt2 <- rbind( +#' mtcars[, c("mpg", "disp", "hp")], +#' data.frame(mpg = c(37, 40), disp = c(300, 400), hp = c(110, 120)) +#' ) +#' model <- lm(disp ~ mpg + hp, data = mt2) +#' rez <- performance::check_outliers(model) +#' report(rez) +#' } +#' +#' @return An object of class [report_text()]. +#' @export +report_text.check_outliers <- function(x, ...) { + n_outliers <- sum(x) + n_total <- length(x) + pct <- n_outliers / n_total + + methods <- attr(x, "method") + thresholds <- attr(x, "threshold") + + method_labels <- .format_outlier_method_names(methods) + + method_parts <- vapply( + seq_along(methods), + function(i) { + m <- methods[i] + thresh <- thresholds[[m]] + if (!is.null(thresh) && length(thresh) == 1L && !is.na(thresh)) { + paste0( + method_labels[i], + " (threshold = ", + insight::format_value(thresh, digits = 3), + ")" + ) + } else { + method_labels[i] + } + }, + character(1L) + ) + + method_str <- paste(method_parts, collapse = " and ") + + if (n_outliers == 0L) { + text <- "No observations were detected as potential outliers." + } else if (n_outliers == 1L) { + text <- paste0( + "1 observation (", + insight::format_value(pct, as_percent = TRUE), + ") was detected as a potential outlier based on ", + method_str, + "." + ) + } else { + text <- paste0( + n_outliers, + " observations (", + insight::format_value(pct, as_percent = TRUE), + ") were detected as potential outliers based on ", + method_str, + "." + ) + } + + as.report_text(text) +} + + +#' @rdname report_text.check_outliers +#' @export +report.check_outliers <- function(x, ...) { + result_text <- report_text(x, ...) + as.report(text = result_text, ...) +} + + +# Helper ------------------------------------------------------------------ + +.format_outlier_method_names <- function(methods) { + method_map <- c( + cook = "Cook's distance", + mahalanobis = "Mahalanobis distance", + mahalanobis_robust = "robust Mahalanobis distance", + zscore = "z-score", + zscore_robust = "robust z-score", + iqr = "IQR", + ci = "confidence intervals", + optics = "OPTICS", + iforest = "Isolation Forest", + lof = "Local Outlier Factor", + mcd = "MCD" + ) + labels <- method_map[methods] + # Fall back to the raw method name for any unknown method + labels[is.na(labels)] <- methods[is.na(labels)] + unname(labels) +} diff --git a/R/report.lm.R b/R/report.lm.R index 87257637..f5e17190 100644 --- a/R/report.lm.R +++ b/R/report.lm.R @@ -8,6 +8,9 @@ #' @param include_diagnostic If `FALSE`, won't include diagnostic related #' indices for Bayesian models (ESS, Rhat). #' @param include_intercept If `FALSE`, won't include the intercept. +#' @param assumptions If `FALSE`, assumption checks (see +#' [report_assumptions()]) are skipped and not included in the report. +#' Defaults to `TRUE`. #' @param effectsize_method See documentation for #' [effectsize::effectsize()]. #' @param parameters Provide the output of `report_parameters()` to avoid @@ -55,6 +58,7 @@ report.lm <- function( x, include_effectsize = TRUE, effectsize_method = "refit", + assumptions = TRUE, ... ) { result_table <- report_table( @@ -63,7 +67,12 @@ report.lm <- function( effectsize_method = effectsize_method, ... ) - result_text <- report_text(x, table = result_table, ...) + result_text <- report_text( + x, + table = result_table, + assumptions = assumptions, + ... + ) as.report(result_text, table = result_table, ...) } @@ -729,7 +738,7 @@ report_info.lm <- function( #' @rdname report.lm #' @export -report_text.lm <- function(x, table = NULL, ...) { +report_text.lm <- function(x, table = NULL, assumptions = TRUE, ...) { params <- report_parameters(x, table = table, include_intercept = FALSE, ...) report_table_data <- attributes(params)$table @@ -753,6 +762,18 @@ report_text.lm <- function(x, table = NULL, ...) { ) } + # --- Assumptions (requires performance) --------------------------------- + assumptions_summary <- "" + if (isTRUE(assumptions) && requireNamespace("performance", quietly = TRUE)) { + assumptions_obj <- tryCatch( + report_assumptions(x), + error = function(e) NULL + ) + if (!is.null(assumptions_obj)) { + assumptions_summary <- as.character(summary(assumptions_obj)) + } + } + # Helpers sep_after <- function(x) { x <- trimws(x) @@ -773,6 +794,7 @@ report_text.lm <- function(x, table = NULL, ...) { "We fitted a ", model, ". ", + if (nzchar(assumptions_summary)) paste0(assumptions_summary, " ") else "", perf, sep_after(perf), intercept, @@ -787,6 +809,7 @@ report_text.lm <- function(x, table = NULL, ...) { "We fitted a ", summary(model), ". ", + if (nzchar(assumptions_summary)) paste0(assumptions_summary, " ") else "", summary(perf), sep_after(summary(perf)), summary(intercept), diff --git a/R/report_ai.R b/R/report_ai.R index e7a12389..02707528 100644 --- a/R/report_ai.R +++ b/R/report_ai.R @@ -3,6 +3,7 @@ report_ai <- function(x, ...) { UseMethod("report_ai") } +#' @exportS3Method report_ai.default <- function(x, ...) { insight::format_warning( paste0( @@ -14,16 +15,22 @@ report_ai.default <- function(x, ...) { report(x, ..., audience = "humans") } +#' @exportS3Method report_ai.lm <- function(x, ...) { .report_ai_models(x, ...) } -report_ai.glm <- report_ai.lm +#' @exportS3Method +report_ai.glm <- function(x, ...) { + .report_ai_models(x, ...) +} +#' @exportS3Method report_ai.merMod <- function(x, ...) { .report_ai_models(x, ...) } +#' @exportS3Method report_ai.glmmTMB <- function(x, ...) { .report_ai_models(x, ...) } @@ -79,6 +86,8 @@ report_ai.glmmTMB <- function(x, ...) { } else { desc_str <- "- No variables found." } + # Remove any leading blank lines introduced by the report() header + desc_str <- sub("^\\n+", "", desc_str) params <- parameters::model_parameters(x, ...) @@ -153,6 +162,12 @@ report_ai.glmmTMB <- function(x, ...) { perf_markdown <- insight::export_table(perf_table, format = "markdown") perf_str <- paste(perf_markdown, collapse = "\n") + # Assumptions (requires performance; silently skipped if unsupported) + assumptions_str <- tryCatch( + as.character(report_assumptions(x, audience = "ai")), + error = function(e) NULL + ) + if ("p" %in% names(fixed_params) && "Parameter" %in% names(fixed_params)) { sig_effects <- fixed_params$Parameter[ !is.na(fixed_params$p) & @@ -221,6 +236,7 @@ report_ai.glmmTMB <- function(x, ...) { "\n\n", "## Variables\n", desc_str, + if (!is.null(assumptions_str)) paste0("\n\n", assumptions_str) else "", "\n\n", param_section, "\n\n", diff --git a/R/report_assumptions.R b/R/report_assumptions.R new file mode 100644 index 00000000..1d896f63 --- /dev/null +++ b/R/report_assumptions.R @@ -0,0 +1,353 @@ +#' Report model assumption checks +#' +#' Produces a formatted summary of model assumption checks, covering +#' **influential observations** (via [performance::check_outliers()]), +#' **homoskedasticity** (via [performance::check_heteroskedasticity()]), and +#' **collinearity** (via [performance::check_collinearity()]). +#' Calling [summary()] on the result returns a single integrated sentence. +#' Set `audience = "ai"` for a compact, token-efficient version suitable for +#' passing to an LLM. +#' +#' This function is called automatically by [report()] for supported model +#' classes (e.g., `lm`, `glm`). Pass `assumptions = FALSE` to [report()] to +#' suppress assumption checks in the final report. +#' +#' @param x A statistical model object (e.g., `lm`, `glm`). +#' @param audience The intended audience. `"humans"` (default) produces a +#' readable bulleted report; `"ai"` produces a compact structured version. +#' The default can be changed globally with `options(report_audience = "ai")`. +#' Can be passed positionally as the second argument: +#' `report_assumptions(model, "ai")`. +#' @param ... Additional arguments passed to [performance::check_outliers()]. +#' +#' @return An object of class [report_text()]. [summary()] returns a compact +#' one-sentence version integrating all checks (for `audience = "humans"`). +#' +#' @examplesIf requireNamespace("performance", quietly = TRUE) +#' \donttest{ +#' model <- lm(mpg ~ wt + hp, data = mtcars) +#' report_assumptions(model) +#' summary(report_assumptions(model)) +#' report_assumptions(model, audience = "ai") +#' +#' mt2 <- rbind( +#' mtcars[, c("mpg", "disp", "hp")], +#' data.frame(mpg = c(37, 40), disp = c(300, 400), hp = c(110, 120)) +#' ) +#' model2 <- lm(disp ~ mpg + hp, data = mt2) +#' report_assumptions(model2) +#' summary(report_assumptions(model2)) +#' report_assumptions(model2, audience = "ai") +#' } +#' +#' @export +report_assumptions <- function( + x, + audience = getOption("report_audience", "humans"), + ... +) { + insight::check_if_installed("performance") + audience <- match.arg(audience, c("humans", "ai")) + + # TODO: add the following assumption checks: + # - Linearity performance::check_predictions() / check_linearity() + # - Normality performance::check_normality() + # - Autocorrelation performance::check_autocorrelation() + # - Overdispersion performance::check_overdispersion() + + # --- Influential observations ------------------------------------------- + outliers <- tryCatch( + performance::check_outliers(x, ...), + error = function(e) NULL + ) + + # --- Homoskedasticity --------------------------------------------------- + heterosk <- tryCatch( + suppressMessages(performance::check_heteroskedasticity(x)), + error = function(e) NULL + ) + if (!is.null(heterosk)) { + p_val <- as.numeric(heterosk) + p_fmt <- insight::format_p(p_val) + homosked_ok <- p_val >= 0.05 + } + + # --- Collinearity ------------------------------------------------------- + collin <- NULL + if (.can_check_collinearity(x)) { + collin <- tryCatch( + suppressMessages(performance::check_collinearity(x)), + error = function(e) NULL + ) + } + if (!is.null(collin)) { + collin_info <- .collinearity_info(collin) + } + + # --- AI output ---------------------------------------------------------- + if (audience == "ai") { + if (!is.null(outliers)) { + n_outliers <- sum(outliers) + n_total <- length(outliers) + if (n_outliers == 0L) { + outlier_ai <- "OK (none)" + } else { + pct <- insight::format_value(n_outliers / n_total, as_percent = TRUE) + methods <- attr(outliers, "method") + thresholds <- attr(outliers, "threshold") + method_labels <- .format_outlier_method_names(methods) + thresh_parts <- vapply( + seq_along(methods), + function(i) { + thresh <- thresholds[[methods[i]]] + if (!is.null(thresh) && length(thresh) == 1L && !is.na(thresh)) { + paste0( + method_labels[i], + ", threshold = ", + insight::format_value(thresh, digits = 3) + ) + } else { + method_labels[i] + } + }, + character(1L) + ) + outlier_ai <- paste0( + n_outliers, + "/", + n_total, + " (", + pct, + ") [", + paste(thresh_parts, collapse = "; "), + "]" + ) + } + } else { + outlier_ai <- "N/A" + } + + if (!is.null(heterosk)) { + heterosk_ai <- if (homosked_ok) { + paste0("OK (Breusch-Pagan, ", p_fmt, ")") + } else { + paste0("VIOLATED (Breusch-Pagan, ", p_fmt, ")") + } + } else { + heterosk_ai <- "N/A" + } + + if (!is.null(collin)) { + if (collin_info$severity == "none") { + collin_ai <- "OK (all VIF < 5)" + } else { + level_str <- if (collin_info$severity == "high") "HIGH" else "MODERATE" + flagged_str <- paste( + paste0( + collin_info$flagged_terms, + ": VIF = ", + insight::format_value(collin_info$flagged_vif) + ), + collapse = ", " + ) + collin_ai <- paste0(level_str, " (", flagged_str, ")") + } + } else { + collin_ai <- "N/A" + } + + lines <- c( + "## Assumptions", + paste0("- Influential Observations: ", outlier_ai), + paste0("- Homoskedasticity: ", heterosk_ai), + paste0("- Collinearity: ", collin_ai) + ) + res <- paste(lines, collapse = "\n") + class(res) <- c("report_ai", "character") + return(res) + } + + # --- Full bulleted output (humans) -------------------------------------- + lines_bullets <- c( + "Model Assumptions", + paste(rep("-", 24L), collapse = "") + ) + phrases <- character(0L) + + if (!is.null(outliers)) { + outlier_bullet <- as.character(report_text(outliers)) + lines_bullets <- c( + lines_bullets, + paste0("- Influential observations: ", outlier_bullet) + ) + phrases <- c(phrases, .outlier_summary_phrase(outliers)) + } + + if (!is.null(heterosk)) { + if (homosked_ok) { + heterosk_bullet <- paste0( + "Homoskedasticity was satisfied (Breusch-Pagan test, ", + p_fmt, + ")." + ) + heterosk_phrase <- paste0( + "the error variance appeared homoskedastic (", + p_fmt, + ")" + ) + } else { + heterosk_bullet <- paste0( + "Heteroskedasticity was detected (Breusch-Pagan test, ", + p_fmt, + ")." + ) + heterosk_phrase <- paste0("heteroskedasticity was detected (", p_fmt, ")") + } + lines_bullets <- c( + lines_bullets, + paste0("- Homoskedasticity: ", heterosk_bullet) + ) + phrases <- c(phrases, heterosk_phrase) + } + + if (!is.null(collin)) { + if (collin_info$severity == "none") { + collin_bullet <- "No collinearity detected (all VIF < 5)." + collin_phrase <- "no collinearity was detected" + } else { + level_str <- if (collin_info$severity == "high") "High" else "Moderate" + flagged_str <- paste( + paste0( + collin_info$flagged_terms, + " (VIF = ", + insight::format_value(collin_info$flagged_vif), + ")" + ), + collapse = ", " + ) + collin_bullet <- paste0( + level_str, + " collinearity detected: ", + flagged_str, + "." + ) + collin_phrase <- paste0( + tolower(level_str), + " collinearity was detected (", + paste(collin_info$flagged_terms, collapse = ", "), + ")" + ) + } + lines_bullets <- c(lines_bullets, paste0("- Collinearity: ", collin_bullet)) + phrases <- c(phrases, collin_phrase) + } + + if (length(phrases) == 0L) { + lines_bullets <- c( + lines_bullets, + "- Assumption checks could not be performed for this model class." + ) + text_summary <- "Assumption checks could not be performed for this model class." + } else { + text_summary <- paste0( + "The model's assumptions were checked: ", + datawizard::text_concatenate(phrases), + "." + ) + } + + as.report_text(paste(lines_bullets, collapse = "\n"), summary = text_summary) +} + + +# Helpers ----------------------------------------------------------------- + +.outlier_summary_phrase <- function(outliers) { + n_outliers <- sum(outliers) + + if (n_outliers == 0L) { + return("no influential observations were detected") + } + + pct <- n_outliers / length(outliers) + methods <- attr(outliers, "method") + method_labels <- .format_outlier_method_names(methods) + method_str <- paste(method_labels, collapse = " and ") + + paste0( + n_outliers, + " influential ", + if (n_outliers == 1L) "observation" else "observations", + " (", + insight::format_value(pct, as_percent = TRUE), + ")", + " were detected (", + method_str, + ")" + ) +} + +#' @keywords internal +.can_check_collinearity <- function(x) { + predictors <- tryCatch( + insight::find_predictors(x, effects = "fixed", flatten = TRUE), + error = function(e) NULL + ) + + if (is.null(predictors)) { + return(FALSE) + } + + if (is.list(predictors)) { + predictors <- unlist(predictors, use.names = FALSE) + } + + predictors <- unique(stats::na.omit(as.character(predictors))) + + length(predictors) > 1L +} + +#' @keywords internal +.collinearity_info <- function(collin) { + vif_col <- if ("VIF" %in% names(collin)) { + "VIF" + } else if ("GVIF" %in% names(collin)) { + "GVIF" + } else { + NULL + } + + if (is.null(vif_col) || nrow(collin) == 0L) { + return(list( + severity = "none", + flagged_terms = character(0L), + flagged_vif = numeric(0L) + )) + } + + vif_vals <- collin[[vif_col]] + terms <- collin[["Term"]] + + high_idx <- which(vif_vals >= 10) + mod_idx <- which(vif_vals >= 5 & vif_vals < 10) + + if (length(high_idx) > 0L) { + list( + severity = "high", + flagged_terms = terms[high_idx], + flagged_vif = vif_vals[high_idx] + ) + } else if (length(mod_idx) > 0L) { + list( + severity = "moderate", + flagged_terms = terms[mod_idx], + flagged_vif = vif_vals[mod_idx] + ) + } else { + list( + severity = "none", + flagged_terms = character(0L), + flagged_vif = numeric(0L) + ) + } +} diff --git a/man/reexports.Rd b/man/reexports.Rd index eef46699..87b8b45b 100644 --- a/man/reexports.Rd +++ b/man/reexports.Rd @@ -13,6 +13,6 @@ These objects are imported from other packages. Follow the links below to see their documentation. \describe{ - \item{insight}{\code{\link[insight:display]{display()}}, \code{\link[insight:print_html]{print_html()}}, \code{\link[insight:print_md]{print_md()}}} + \item{insight}{\code{\link[insight]{display}}, \code{\link[insight:display]{print_html}}, \code{\link[insight:display]{print_md}}} }} diff --git a/man/report-package.Rd b/man/report-package.Rd index 86d18867..ce41d492 100644 --- a/man/report-package.Rd +++ b/man/report-package.Rd @@ -28,7 +28,6 @@ Useful links: Authors: \itemize{ - \item Rémi Thériault \email{remi.theriault@mail.mcgill.ca} (\href{https://orcid.org/0000-0003-4315-6788}{ORCID}) \item Dominique Makowski \email{dom.makowski@gmail.com} (\href{https://orcid.org/0000-0001-5375-9967}{ORCID}) \item Daniel Lüdecke \email{d.luedecke@uke.de} (\href{https://orcid.org/0000-0002-8895-3206}{ORCID}) \item Indrajeet Patil \email{patilindrajeet.science@gmail.com} (\href{https://orcid.org/0000-0003-1995-6531}{ORCID}) diff --git a/man/report.Rd b/man/report.Rd index 181175af..f95cc5b6 100644 --- a/man/report.Rd +++ b/man/report.Rd @@ -13,10 +13,15 @@ objects above).} \item{...}{Arguments passed to or from other methods.} \item{audience}{The intended audience for the report. \code{"humans"} (default) -produces the standard formatted text report. \code{"ai"} produces a compact, -structured output optimised for consumption by a Large Language Model (LLM) -or AI agent via \code{\link[=report_ai]{report_ai()}}. The default can be changed globally with -\code{options(report_audience = "ai")}.} +produces the standard narrative text report. \code{"ai"} produces a compact, +structured Markdown output designed for consumption by a Large Language +Model (LLM) or AI agent. It strikes a careful balance between +comprehensiveness, specificity, and compactness, giving the model the +clearest and most relevant analytical information at the lowest possible +token cost. The output is a single character vector of class \code{report_ai} +that can be pasted directly into a chat window or fed to an LLM API. +The default can be changed globally with \code{options(report_audience = "ai")}. +See \code{vignette("report_ai", package = "report")} for details and examples.} } \value{ A list-object of class \code{report}, which contains further diff --git a/man/report.compare.loo.Rd b/man/report.compare.loo.Rd index 7e715b70..ae839530 100644 --- a/man/report.compare.loo.Rd +++ b/man/report.compare.loo.Rd @@ -7,7 +7,7 @@ \method{report}{compare.loo}(x, include_IC = TRUE, include_ENP = FALSE, ...) } \arguments{ -\item{x}{An object of class \link[brms:loo_compare]{brms::loo_compare}.} +\item{x}{An object of class \link[brms:loo_compare.brmsfit]{brms::loo_compare}.} \item{include_IC}{Whether to include the information criteria (IC).} diff --git a/man/report.lm.Rd b/man/report.lm.Rd index 20a816c9..37a1e4d7 100644 --- a/man/report.lm.Rd +++ b/man/report.lm.Rd @@ -14,7 +14,13 @@ \alias{report_random.merMod} \title{Reporting (General) Linear Models} \usage{ -\method{report}{lm}(x, include_effectsize = TRUE, effectsize_method = "refit", ...) +\method{report}{lm}( + x, + include_effectsize = TRUE, + effectsize_method = "refit", + assumptions = TRUE, + ... +) \method{report_effectsize}{lm}(x, effectsize_method = "refit", ...) @@ -50,7 +56,7 @@ ... ) -\method{report_text}{lm}(x, table = NULL, ...) +\method{report_text}{lm}(x, table = NULL, assumptions = TRUE, ...) \method{report_random}{merMod}(x, ...) } @@ -63,6 +69,10 @@ indices (standardized coefficients, etc.).} \item{effectsize_method}{See documentation for \code{\link[effectsize:effectsize]{effectsize::effectsize()}}.} +\item{assumptions}{If \code{FALSE}, assumption checks (see +\code{\link[=report_assumptions]{report_assumptions()}}) are skipped and not included in the report. +Defaults to \code{TRUE}.} + \item{...}{Arguments passed to or from other methods.} \item{table}{Provide the output of \code{report_table()} to avoid its re-computation.} diff --git a/man/report_ai.Rd b/man/report_ai.Rd deleted file mode 100644 index f2488289..00000000 --- a/man/report_ai.Rd +++ /dev/null @@ -1,45 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/report_ai.R -\name{report_ai} -\alias{report_ai} -\alias{report_ai.merMod} -\alias{report_ai.glmmTMB} -\title{Generate AI-optimized reports} -\usage{ -report_ai(x, ...) - -\method{report_ai}{merMod}(x, ...) - -\method{report_ai}{glmmTMB}(x, ...) -} -\arguments{ -\item{x}{A statistical model.} - -\item{...}{Arguments passed to other functions, like \code{parameters::model_parameters()}, -\code{performance::model_performance()} or \code{insight::format_table()}.} -} -\value{ -A character vector of class \code{report_ai} containing the formatted text. -} -\description{ -This function is designed to produce AI-optimized output for statistical models. -It strikes a careful balance between comprehensiveness, specificity, and compactness. -The primary goal is to provide a Large Language Model (LLM) or AI agent with the -clearest and most relevant analytical information at the lowest possible token cost. -} -\examples{ -m <- lm(mpg ~ wt + hp, data = mtcars) -report_ai(m) -\dontshow{if (requireNamespace("lme4", quietly = TRUE)) withAutoprint(\{ # examplesIf} -\donttest{ -m <- lme4::lmer(Reaction ~ Days + (1 | Subject), data = lme4::sleepstudy) -report_ai(m) -} -\dontshow{\}) # examplesIf} -\dontshow{if (requireNamespace("glmmTMB", quietly = TRUE)) withAutoprint(\{ # examplesIf} -\donttest{ -m <- glmmTMB::glmmTMB(count ~ mined + (1 | site), family = poisson(), data = glmmTMB::Salamanders) -report_ai(m) -} -\dontshow{\}) # examplesIf} -} diff --git a/man/report_assumptions.Rd b/man/report_assumptions.Rd new file mode 100644 index 00000000..b58d9d3a --- /dev/null +++ b/man/report_assumptions.Rd @@ -0,0 +1,56 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/report_assumptions.R +\name{report_assumptions} +\alias{report_assumptions} +\title{Report model assumption checks} +\usage{ +report_assumptions(x, audience = getOption("report_audience", "humans"), ...) +} +\arguments{ +\item{x}{A statistical model object (e.g., \code{lm}, \code{glm}).} + +\item{audience}{The intended audience. \code{"humans"} (default) produces a +readable bulleted report; \code{"ai"} produces a compact structured version. +The default can be changed globally with \code{options(report_audience = "ai")}. +Can be passed positionally as the second argument: +\code{report_assumptions(model, "ai")}.} + +\item{...}{Additional arguments passed to \code{\link[performance:check_outliers]{performance::check_outliers()}}.} +} +\value{ +An object of class \code{\link[=report_text]{report_text()}}. \code{\link[=summary]{summary()}} returns a compact +one-sentence version integrating all checks (for \code{audience = "humans"}). +} +\description{ +Produces a formatted summary of model assumption checks, covering +\strong{influential observations} (via \code{\link[performance:check_outliers]{performance::check_outliers()}}), +\strong{homoskedasticity} (via \code{\link[performance:check_heteroscedasticity]{performance::check_heteroskedasticity()}}), and +\strong{collinearity} (via \code{\link[performance:check_collinearity]{performance::check_collinearity()}}). +Calling \code{\link[=summary]{summary()}} on the result returns a single integrated sentence. +Set \code{audience = "ai"} for a compact, token-efficient version suitable for +passing to an LLM. +} +\details{ +This function is called automatically by \code{\link[=report]{report()}} for supported model +classes (e.g., \code{lm}, \code{glm}). Pass \code{assumptions = FALSE} to \code{\link[=report]{report()}} to +suppress assumption checks in the final report. +} +\examples{ +\dontshow{if (requireNamespace("performance", quietly = TRUE)) withAutoprint(\{ # examplesIf} +\donttest{ +model <- lm(mpg ~ wt + hp, data = mtcars) +report_assumptions(model) +summary(report_assumptions(model)) +report_assumptions(model, audience = "ai") + +mt2 <- rbind( + mtcars[, c("mpg", "disp", "hp")], + data.frame(mpg = c(37, 40), disp = c(300, 400), hp = c(110, 120)) +) +model2 <- lm(disp ~ mpg + hp, data = mt2) +report_assumptions(model2) +summary(report_assumptions(model2)) +report_assumptions(model2, audience = "ai") +} +\dontshow{\}) # examplesIf} +} diff --git a/man/report_text.check_outliers.Rd b/man/report_text.check_outliers.Rd new file mode 100644 index 00000000..d49ca4b2 --- /dev/null +++ b/man/report_text.check_outliers.Rd @@ -0,0 +1,74 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/report.check_outliers.R +\name{report_text.check_outliers} +\alias{report_text.check_outliers} +\alias{report.check_outliers} +\title{Reporting outlier detection results} +\usage{ +\method{report_text}{check_outliers}(x, ...) + +\method{report}{check_outliers}(x, ...) +} +\arguments{ +\item{x}{Object of class \code{check_outliers} as returned by +\code{\link[performance:check_outliers]{performance::check_outliers()}}.} + +\item{...}{Arguments passed to or from other methods.} +} +\value{ +An object of class \code{\link[=report_text]{report_text()}}. +} +\description{ +Create a brief textual report for outlier detection results from +\code{\link[performance:check_outliers]{performance::check_outliers()}}. +} +\examples{ +\dontshow{if (requireNamespace("performance", quietly = TRUE)) withAutoprint(\{ # examplesIf} +\donttest{ +library(report) +library(performance) + +mt2 <- rbind( + mtcars[, c("mpg", "disp", "hp")], + data.frame(mpg = c(37, 40), disp = c(300, 400), hp = c(110, 120)) +) +model <- lm(disp ~ mpg + hp, data = mt2) +rez <- performance::check_outliers(model) +report(rez) +} +\dontshow{\}) # examplesIf} +} +\seealso{ +Specific components of reports (especially for stats models): +\itemize{ +\item \code{\link[=report_table]{report_table()}} +\item \code{\link[=report_parameters]{report_parameters()}} +\item \code{\link[=report_statistics]{report_statistics()}} +\item \code{\link[=report_effectsize]{report_effectsize()}} +\item \code{\link[=report_model]{report_model()}} +\item \code{\link[=report_priors]{report_priors()}} +\item \code{\link[=report_random]{report_random()}} +\item \code{\link[=report_performance]{report_performance()}} +\item \code{\link[=report_info]{report_info()}} +\item \code{\link[=report_text]{report_text()}} +} + +Other types of reports: +\itemize{ +\item \code{\link[=report_system]{report_system()}} +\item \code{\link[=report_packages]{report_packages()}} +\item \code{\link[=report_participants]{report_participants()}} +\item \code{\link[=report_sample]{report_sample()}} +\item \code{\link[=report_date]{report_date()}} +} + +Methods: +\itemize{ +\item \code{\link[=as.report]{as.report()}} +} + +Template file for supporting new models: +\itemize{ +\item \code{\link[=report.default]{report.default()}} +} +} diff --git a/pkgdown/_pkgdown.yml b/pkgdown/_pkgdown.yml index a5094afa..66b1cad3 100644 --- a/pkgdown/_pkgdown.yml +++ b/pkgdown/_pkgdown.yml @@ -62,6 +62,7 @@ reference: - report_s - report_sample - report_statistics + - report_assumptions - title: Formatting desc: | @@ -77,12 +78,6 @@ reference: - report_table - report_text - - title: AI-Optimized Reports - desc: | - Functions for generating AI-optimized reports - contents: - - report_ai - - title: Report Statistical Objects desc: | Helper functions for reporting of statistical objects @@ -99,6 +94,7 @@ reference: - report.estimate_contrasts - report.compare.loo - report.BFBayesFactor + - report_text.check_outliers - title: Report Non-Statistical Objects desc: | diff --git a/tests/testthat/_snaps/windows/report.ivreg.md b/tests/testthat/_snaps/windows/report.ivreg.md index 96f08a42..c9cedf41 100644 --- a/tests/testthat/_snaps/windows/report.ivreg.md +++ b/tests/testthat/_snaps/windows/report.ivreg.md @@ -9,10 +9,12 @@ See help("standardize") for how such terms are standardized. Output We fitted a linear model to predict packs with rprice, rincome and salestax - (formula: log(packs) ~ log(rprice) + log(rincome)). The model's explanatory - power is substantial (R2 = 0.42, adj. R2 = 0.39). The model's intercept, - corresponding to rprice = 0 and rincome = 0, is at 9.43 (95% CI [6.69, 12.17], - t(45) = 6.94, p < .001). Within this model: + (formula: log(packs) ~ log(rprice) + log(rincome)). The model's assumptions + were checked: no influential observations were detected, the error variance + appeared homoskedastic (p = 0.613) and no collinearity was detected. The + model's explanatory power is substantial (R2 = 0.42, adj. R2 = 0.39). The + model's intercept, corresponding to rprice = 0 and rincome = 0, is at 9.43 (95% + CI [6.69, 12.17], t(45) = 6.94, p < .001). Within this model: - The effect of rprice [log] is statistically significant and negative (beta = -1.14, 95% CI [-1.87, -0.42], t(45) = -3.18, p = 0.003; Std. beta = -0.53, 95% diff --git a/tests/testthat/_snaps/windows/report.lm.md b/tests/testthat/_snaps/windows/report.lm.md index 90890457..22bad2c5 100644 --- a/tests/testthat/_snaps/windows/report.lm.md +++ b/tests/testthat/_snaps/windows/report.lm.md @@ -4,7 +4,8 @@ report(glm(vs ~ disp, data = mtcars, family = binomial(link = "probit"))) Output We fitted a probit model (estimated using ML) to predict vs with disp (formula: - vs ~ disp). The model's explanatory power is substantial (Nagelkerke's R2 = + vs ~ disp). The model's assumptions were checked: no influential observations + were detected. The model's explanatory power is substantial (Nagelkerke's R2 = 0.66). The model's intercept, corresponding to disp = 0, is at 2.51 (95% CI [1.13, 4.28], p = 0.001). Within this model: @@ -21,7 +22,8 @@ report(glm(vs ~ mpg, data = mtcars, family = "poisson")) Output We fitted a poisson model (estimated using ML) to predict vs with mpg (formula: - vs ~ mpg). The model's explanatory power is substantial (Nagelkerke's R2 = + vs ~ mpg). The model's assumptions were checked: no influential observations + were detected. The model's explanatory power is substantial (Nagelkerke's R2 = 0.39). The model's intercept, corresponding to mpg = 0, is at -3.27 (95% CI [-5.46, -1.36], p = 0.002). Within this model: @@ -39,8 +41,10 @@ Output We fitted a constant (intercept-only) linear model (estimated using OLS) to predict d_wide$group0 - d_wide$group1 (formula: d_wide$group0 - d_wide$group1 ~ - 1). The model's intercept is at -1.58 (95% CI [-2.46, -0.70], t(9) = -4.06, p = - 0.003). + 1). The model's assumptions were checked: 1 influential observation (10.00%) + were detected (Cook's distance) and the error variance appeared homoskedastic + (p > .999). The model's intercept is at -1.58 (95% CI [-2.46, -0.70], t(9) = + -4.06, p = 0.003). Standardized parameters were obtained by fitting the model on a standardized version of the dataset. 95% Confidence Intervals (CIs) and p-values were diff --git a/tests/testthat/_snaps/windows/report.lmer.md b/tests/testthat/_snaps/windows/report.lmer.md index e9561fa2..8b0e226c 100644 --- a/tests/testthat/_snaps/windows/report.lmer.md +++ b/tests/testthat/_snaps/windows/report.lmer.md @@ -5,7 +5,9 @@ Output We fitted a linear mixed model (estimated using REML and nloptwrap optimizer) to predict Reaction with Days (formula: Reaction ~ Days). The model included - Days as random effects (formula: ~1 + Days | Subject). The model's total + Days as random effects (formula: ~1 + Days | Subject). The model's assumptions + were checked: 4 influential observations (2.22%) were detected (Cook's + distance) and heteroskedasticity was detected (p < .001). The model's total explanatory power is substantial (conditional R2 = 0.80) and the part related to the fixed effects alone (marginal R2) is of 0.28. The model's intercept, corresponding to Days = 0, is at 251.41 (95% CI [237.94, 264.87], t(174) = @@ -34,7 +36,9 @@ We fitted a linear mixed model (estimated using REML and nloptwrap optimizer) to predict Reaction with Days (formula: Reaction ~ Days). The model included mysubgrp as random effects (formula: list(~1 | mysubgrp:mygrp, ~1 | mygrp, ~1 | - Subject)). The model's explanatory power related to the fixed effects alone + Subject)). The model's assumptions were checked: 1 influential observation + (0.56%) were detected (Cook's distance) and heteroskedasticity was detected (p + < .001). The model's explanatory power related to the fixed effects alone (marginal R2) is 0.49. The model's intercept, corresponding to Days = 0, is at 252.09 (95% CI [232.50, 271.69], t(174) = 25.39, p < .001). Within this model: diff --git a/tests/testthat/_snaps/windows/report.stanreg.md b/tests/testthat/_snaps/windows/report.stanreg.md index 42313b27..a68a8423 100644 --- a/tests/testthat/_snaps/windows/report.stanreg.md +++ b/tests/testthat/_snaps/windows/report.stanreg.md @@ -7,18 +7,21 @@ of 300 iterations and a warmup of 150) to predict mpg with qsec and wt (formula: mpg ~ qsec + wt). Priors over parameters were all set as normal (mean = 0.00, SD = 8.43; mean = 0.00, SD = 15.40) distributions. The model's - explanatory power is substantial (R2 = 0.81, 95% CI [0.70, 0.90], adj. R2 = + assumptions were checked: no influential observations were detected, the error + variance appeared homoskedastic (p = 0.423) and no collinearity was detected. + The model's explanatory power is substantial (R2 = 0.81, 95% CI [0.70, 0.90], + adj. R2 = 0.80). The model's intercept, corresponding to qsec = 0 and wt = 0, is at 19.71 (95% CI [9.04, 30.18]). Within this model: - The effect of qsec (Median = 0.92, 95% CI [0.40, 1.47]) has a 99.83% probability of being positive (> 0), 99.00% of being significant (> 0.30), and 0.33% of being large (> 1.81). The estimation successfully converged (Rhat = - 0.999) but the indices are unreliable (ESS = 451) + 0.999) - The effect of wt (Median = -5.06, 95% CI [-6.02, -4.18]) has a 100.00% probability of being negative (< 0), 100.00% of being significant (< -0.30), and 100.00% of being large (< -1.81). However, the estimation might not have - successfuly converged (Rhat = 1.013) and the indices are unreliable (ESS = 478) + successfuly converged (Rhat = 1.013) Following the Sequential Effect eXistence and sIgnificance Testing (SEXIT) framework, we report the median of the posterior distribution and its 95% CI diff --git a/tests/testthat/_snaps/windows/report.survreg.md b/tests/testthat/_snaps/windows/report.survreg.md index 0707d687..d18e8126 100644 --- a/tests/testthat/_snaps/windows/report.survreg.md +++ b/tests/testthat/_snaps/windows/report.survreg.md @@ -17,9 +17,10 @@ `performance_pcp()` only works for models with binary response values. We fitted a logistic model to predict survival::Surv(futime, fustat) with ecog.ps and rx (formula: survival::Surv(futime, fustat) ~ ecog.ps + rx). The - model's explanatory power is weak (Nagelkerke's R2 = 0.07). The model's - intercept, corresponding to ecog.ps = 0 and rx = 0, is at 667.43 (95% CI - [-415.59, 1750.45], p = 0.227). Within this model: + model's assumptions were checked: no collinearity was detected. The model's + explanatory power is weak (Nagelkerke's R2 = 0.07). The model's intercept, + corresponding to ecog.ps = 0 and rx = 0, is at 667.43 (95% CI [-415.59, + 1750.45], p = 0.227). Within this model: - The effect of ecog ps is statistically non-significant and negative (beta = -210.59, 95% CI [-726.18, 305.01], p = 0.423; Std. beta = -107.06, 95% CI diff --git a/tests/testthat/test-report_assumptions.R b/tests/testthat/test-report_assumptions.R new file mode 100644 index 00000000..612dab15 --- /dev/null +++ b/tests/testthat/test-report_assumptions.R @@ -0,0 +1,292 @@ +skip_if_not_installed("performance") + +# --------------------------------------------------------------------------- +# Happy paths +# --------------------------------------------------------------------------- + +test_that("report_assumptions - all assumptions satisfied", { + m <- lm(mpg ~ wt + hp, data = mtcars) + result <- report_assumptions(m) + + expect_s3_class(result, "report_text") + expect_match(as.character(result), "Model Assumptions", fixed = TRUE) + expect_match(as.character(result), "Influential observations", fixed = TRUE) + expect_match(as.character(result), "Homoskedasticity", fixed = TRUE) +}) + +test_that("report_assumptions - summary sentence structure", { + m <- lm(mpg ~ wt + hp, data = mtcars) + summ <- as.character(summary(report_assumptions(m))) + + expect_match(summ, "The model's assumptions were checked", fixed = TRUE) + expect_match(summ, "no influential observations were detected", fixed = TRUE) + expect_match(summ, "homoskedastic", fixed = TRUE) +}) + +test_that("report_assumptions - outliers detected", { + m <- lm(mpg ~ wt + hp, data = mtcars) + mock_outliers <- structure( + c(TRUE, TRUE, rep(FALSE, 30L)), + method = "cook", + threshold = list(cook = 0.125), + class = c("check_outliers", "logical") + ) + local_mocked_bindings( + check_outliers = function(...) mock_outliers, + .package = "performance" + ) + summ <- as.character(summary(report_assumptions(m))) + expect_match(summ, "The model's assumptions were checked", fixed = TRUE) + expect_false(grepl("no influential observations", summ, fixed = TRUE)) + expect_match(summ, "influential observation", ignore.case = TRUE) +}) + +test_that("report_assumptions - AI audience structure", { + m <- lm(mpg ~ wt + hp, data = mtcars) + result <- report_assumptions(m, audience = "ai") + + expect_s3_class(result, "report_ai") + expect_match(result, "## Assumptions", fixed = TRUE) + expect_match(result, "- Influential Observations:", fixed = TRUE) + expect_match(result, "- Homoskedasticity:", fixed = TRUE) +}) + +test_that("report_assumptions - AI no outliers", { + m <- lm(mpg ~ wt + hp, data = mtcars) + result <- report_assumptions(m, audience = "ai") + expect_match(result, "Influential Observations: OK (none)", fixed = TRUE) +}) + +test_that("report_assumptions - AI outliers detected", { + m <- lm(mpg ~ wt + hp, data = mtcars) + mock_outliers <- structure( + c(TRUE, TRUE, rep(FALSE, 30L)), + method = "cook", + threshold = list(cook = 0.125), + class = c("check_outliers", "logical") + ) + local_mocked_bindings( + check_outliers = function(...) mock_outliers, + .package = "performance" + ) + result <- report_assumptions(m, audience = "ai") + # Should show a count, not the no-outlier "OK (none)" text + expect_false(grepl( + "Influential Observations: OK (none)", + result, + fixed = TRUE + )) + expect_match(result, "Influential Observations:", fixed = TRUE) +}) + +# --------------------------------------------------------------------------- +# Graceful fallbacks when individual checks fail +# --------------------------------------------------------------------------- + +test_that("report_assumptions - partial fallback when check_outliers fails", { + m <- lm(mpg ~ wt + hp, data = mtcars) + local_mocked_bindings( + check_outliers = function(...) stop("not supported for this class"), + .package = "performance" + ) + result <- expect_no_error(report_assumptions(m)) + + # Heteroskedasticity check still ran → summary should mention it + summ <- as.character(summary(result)) + expect_match(summ, "The model's assumptions were checked", fixed = TRUE) + expect_match(summ, "homoskedastic", fixed = TRUE) + # Outlier check absent from summary + expect_false(grepl("influential", summ, ignore.case = TRUE)) +}) + +test_that("report_assumptions - partial fallback when check_heteroskedasticity fails", { + m <- lm(mpg ~ wt + hp, data = mtcars) + local_mocked_bindings( + check_heteroskedasticity = function(...) stop("not supported"), + .package = "performance" + ) + result <- expect_no_error(report_assumptions(m)) + + summ <- as.character(summary(result)) + expect_match(summ, "The model's assumptions were checked", fixed = TRUE) + expect_match(summ, "influential observations", fixed = TRUE) + expect_false(grepl("homoskedast", summ, ignore.case = TRUE)) +}) + +test_that("report_assumptions - full fallback when both checks fail", { + m <- lm(mpg ~ wt + hp, data = mtcars) + local_mocked_bindings( + check_outliers = function(...) stop("not supported"), + check_heteroskedasticity = function(...) stop("not supported"), + check_collinearity = function(...) stop("not supported"), + .package = "performance" + ) + result <- expect_no_error(report_assumptions(m)) + + # Should return an object (not NULL, not an error) + expect_false(is.null(result)) + summ <- as.character(summary(result)) + expect_match(summ, "could not be performed", fixed = TRUE) +}) + +test_that("report_assumptions - AI partial fallback shows N/A", { + m <- lm(mpg ~ wt + hp, data = mtcars) + local_mocked_bindings( + check_outliers = function(...) stop("not supported"), + .package = "performance" + ) + result <- expect_no_error(report_assumptions(m, audience = "ai")) + + expect_s3_class(result, "report_ai") + expect_match(result, "Influential Observations: N/A", fixed = TRUE) + # Homoskedasticity still worked + expect_false(grepl("Homoskedasticity: N/A", result, fixed = TRUE)) +}) + +test_that("report_assumptions - AI full fallback shows N/A for both", { + m <- lm(mpg ~ wt + hp, data = mtcars) + local_mocked_bindings( + check_outliers = function(...) stop("not supported"), + check_heteroskedasticity = function(...) stop("not supported"), + .package = "performance" + ) + result <- expect_no_error(report_assumptions(m, audience = "ai")) + + expect_s3_class(result, "report_ai") + expect_match(result, "Influential Observations: N/A", fixed = TRUE) + expect_match(result, "Homoskedasticity: N/A", fixed = TRUE) +}) + +# --------------------------------------------------------------------------- +# Collinearity +# --------------------------------------------------------------------------- + +test_that("report_assumptions - no collinearity (humans)", { + m <- lm(mpg ~ wt + hp, data = mtcars) + result <- report_assumptions(m) + expect_match(as.character(result), "Collinearity", fixed = TRUE) + expect_match(as.character(result), "No collinearity detected", fixed = TRUE) + summ <- as.character(summary(result)) + expect_match(summ, "no collinearity was detected", fixed = TRUE) +}) + +test_that("report_assumptions - high collinearity detected (humans)", { + set.seed(42) + dat <- mtcars + dat$wt_noise <- mtcars$wt + rnorm(32L, 0, 0.01) + m <- lm(mpg ~ wt + wt_noise + hp, data = dat) + result <- report_assumptions(m) + full <- as.character(result) + # Either high or moderate collinearity should be flagged + expect_true( + grepl("High collinearity", full, fixed = TRUE) || + grepl("Moderate collinearity", full, fixed = TRUE) + ) + summ <- as.character(summary(result)) + expect_true( + grepl("high collinearity", summ, fixed = TRUE) || + grepl("moderate collinearity", summ, fixed = TRUE) + ) +}) + +test_that("report_assumptions - no collinearity (AI)", { + m <- lm(mpg ~ wt + hp, data = mtcars) + result <- report_assumptions(m, audience = "ai") + expect_match(result, "- Collinearity:", fixed = TRUE) + expect_match(result, "OK (all VIF < 5)", fixed = TRUE) +}) + +test_that("report_assumptions - high collinearity (AI)", { + set.seed(42) + dat <- mtcars + dat$wt_noise <- mtcars$wt + rnorm(32L, 0, 0.01) + m <- lm(mpg ~ wt + wt_noise + hp, data = dat) + result <- report_assumptions(m, audience = "ai") + expect_match(result, "- Collinearity:", fixed = TRUE) + expect_false(grepl("OK (all VIF < 5)", result, fixed = TRUE)) +}) + +test_that("report_assumptions - collinearity fallback when check fails", { + m <- lm(mpg ~ wt + hp, data = mtcars) + local_mocked_bindings( + check_collinearity = function(...) stop("not supported"), + .package = "performance" + ) + result <- expect_no_error(report_assumptions(m)) + # Other checks still ran + summ <- as.character(summary(result)) + expect_match(summ, "The model's assumptions were checked", fixed = TRUE) + expect_false(grepl("collinearity", summ, fixed = TRUE)) +}) + +test_that("report_assumptions - collinearity AI fallback shows N/A", { + m <- lm(mpg ~ wt + hp, data = mtcars) + local_mocked_bindings( + check_collinearity = function(...) stop("not supported"), + .package = "performance" + ) + result <- expect_no_error(report_assumptions(m, audience = "ai")) + expect_match(result, "Collinearity: N/A", fixed = TRUE) +}) + +test_that("report_assumptions - collinearity check skipped for one predictor", { + m <- lm(mpg ~ wt, data = mtcars) + local_mocked_bindings( + check_collinearity = function(...) stop("should not be called"), + .package = "performance" + ) + + result <- expect_no_error(report_assumptions(m)) + full <- as.character(result) + summ <- as.character(summary(result)) + + expect_false(grepl("Collinearity", full, fixed = TRUE)) + expect_false(grepl("collinearity", summ, fixed = TRUE)) +}) + +test_that("report() - collinearity check skipped for one predictor", { + m <- lm(mpg ~ wt, data = mtcars) + local_mocked_bindings( + check_collinearity = function(...) stop("should not be called"), + .package = "performance" + ) + + result <- expect_no_error(report(m)) + expect_false(is.null(result)) +}) + +# --------------------------------------------------------------------------- +# Integration with report() +# --------------------------------------------------------------------------- + +test_that("report() - assumptions summary appears after model description", { + m <- lm(mpg ~ wt + hp, data = mtcars) + summ <- as.character(summary(report(m))) + + # Assumptions sentence present + expect_match(summ, "The model's assumptions were checked", fixed = TRUE) + # It should come before the R2 performance sentence + assum_pos <- regexpr("assumptions were checked", summ) + r2_pos <- regexpr("R2", summ) + expect_true(assum_pos < r2_pos) +}) + +test_that("report(audience='ai') - assumptions block between Variables and Parameters", { + m <- lm(mpg ~ wt + hp, data = mtcars) + result <- report(m, audience = "ai") + + expect_match(result, "## Assumptions", fixed = TRUE) + vars_pos <- regexpr("## Variables", result) + assum_pos <- regexpr("## Assumptions", result) + params_pos <- regexpr("## Parameters", result) + expect_true(vars_pos < assum_pos) + expect_true(assum_pos < params_pos) +}) + +test_that("report(audience='ai') - no blank line after ## Variables", { + m <- lm(mpg ~ wt + hp, data = mtcars) + result <- report(m, audience = "ai") + + # After "## Variables\n" the very next character should not be "\n" + expect_false(grepl("## Variables\n\n", result, fixed = TRUE)) +}) diff --git a/vignettes/report_ai.Rmd b/vignettes/report_ai.Rmd index 7d0bc108..54233dc0 100644 --- a/vignettes/report_ai.Rmd +++ b/vignettes/report_ai.Rmd @@ -33,7 +33,7 @@ The **report** package was originally designed in a pre-AI era with one overarch However, the analytical landscape has fundamentally changed. Today, researchers increasingly rely on AI agents and Large Language Models (LLMs) to help interpret results, summarize findings, and draft manuscripts, often by simply **pasting raw outputs directly into a chat window**. This shift prompted us to ask: should the scope of the *report* package be expanded to **support** this new workflow, rather than pretending it doesn't exist? And it wasn't even a matter of staying relevant, it was a matter of empowering our users to get the best possible results from their new AI assistants. -While our standard narrative outputs are excellent for humans, they are remarkably suboptimal for AI: verbose prose forces an LLM to waste valuable context tokens re-parsing implicit structures, wasting tokens and context memory. The answer to this challenge is `report_ai()`, a function that can be triggered via an argument from the main `report()` function. By stripping away the narrative bloat in favour of highly structured, token-efficient formats, `report_ai()` bridges the gap between R and the context window, giving your AI assistant exactly the information it needs in the most effective way possible. +While our standard narrative outputs are excellent for humans, they are remarkably suboptimal for AI: verbose prose forces an LLM to waste valuable context tokens re-parsing implicit structures, wasting tokens and context memory. The answer to this challenge is the `audience = "ai"` argument in `report()`. By stripping away the narrative bloat in favour of highly structured, token-efficient formats, `report(x, audience = "ai")` bridges the gap between R and the context window, giving your AI assistant exactly the information it needs in the most effective way possible. ## Basic Usage @@ -51,9 +51,8 @@ report(m) report(m, audience = "ai") ``` -The AI output is a single character vector of class `report_ai` that you can -pass directly to any LLM API, embed in a system prompt, or include in a -context window. +The AI output is a character string that you can pass directly to any LLM API, +embed in a system prompt, or include in a context window. ## Setting the Option Globally