From 81fd1a5279655c1fbc740e05db3fb3a2b351bd1e Mon Sep 17 00:00:00 2001 From: Jared Knowles Date: Mon, 27 Jul 2026 10:14:16 -0400 Subject: [PATCH] feat: refuse expenditure_concept = total in the cross-government verbs Owner ruling R1. Combining Census Total across governments counts intergovernmental transfers twice, and these results land in Tableau where a warning would be invisible -- so this is a hard error whose message names the fix and the reason. --- R/peers.R | 11 +++++- R/rollup.R | 13 ++++++- R/spending.R | 16 +++++++++ man/cog_geographic_rollup.Rd | 10 +++++- man/cog_peer_compare.Rd | 8 ++++- tests/testthat/test-expenditure-concept.R | 41 +++++++++++++++++++++++ 6 files changed, 95 insertions(+), 4 deletions(-) diff --git a/R/peers.R b/R/peers.R index 892828f..aa36b8f 100644 --- a/R/peers.R +++ b/R/peers.R @@ -143,6 +143,10 @@ cog_find_peers <- function(target_govid, #' @param per_capita Default `TRUE` — peer compare usually normalizes by #' population. #' @param adjust_to_year Integer base year for CPI-U conversion or `NULL`. +#' @param expenditure_concept `"direct"` (default) or `"total"`. Currently only +#' `"direct"` is accepted; the `"total"` option exists in [cog_spending()] for +#' single-government queries but cannot be used here because combining Total +#' across peer sets counts intergovernmental transfers twice. #' @return Tibble matching [cog_spending()]'s columns, plus a `role` #' column taking values `"target"`, `"peer"`, `"summary_p25"`, #' `"summary_p50"`, or `"summary_p75"`, `target_rank` (target's rank @@ -153,8 +157,13 @@ cog_find_peers <- function(target_govid, #' `cohort_year`, and `cohort_govids`. #' @export cog_peer_compare <- function(target_govid, peers, category, years, - per_capita = TRUE, adjust_to_year = NULL) { + per_capita = TRUE, adjust_to_year = NULL, + expenditure_concept = c("direct", "total")) { call <- match.call() + expenditure_concept <- match.arg(expenditure_concept) + if (identical(expenditure_concept, "total")) { + .abort_concept_not_aggregatable("cog_peer_compare") + } if (!is.character(target_govid) || length(target_govid) != 1L) { cli::cli_abort("`target_govid` must be a length-1 character string.") } diff --git a/R/rollup.R b/R/rollup.R index cc2adcc..9c90d53 100644 --- a/R/rollup.R +++ b/R/rollup.R @@ -25,6 +25,12 @@ #' population from `gov_population_yearly`. Govs with missing population #' are excluded from the result. #' @param adjust_to_year Integer base year for CPI-U conversion, or `NULL`. +#' @param expenditure_concept `"direct"` (default) or `"total"`. Currently only +#' `"direct"` is accepted; the `"total"` option exists in [cog_spending()] for +#' single-government queries but cannot be used here because combining Total +#' across multiple layers of government double-counts intergovernmental +#' transfers (a state's payment to a school district is the same dollar the +#' district reports as its own Direct spending). #' @return Tibble with columns `year`, `layer`, `canonical_govid`, `gov_name`, #' `spend_subtype`, `category`, `amt_nominal`, optional `amt_real` / #' `amt_per_capita_nominal` / `amt_per_capita_real`, optional `pop_source`, @@ -33,8 +39,13 @@ #' and `rollup$included_govids` / `rollup$excluded_govids`. #' @export cog_geographic_rollup <- function(govids, category, years, - per_capita = FALSE, adjust_to_year = NULL) { + per_capita = FALSE, adjust_to_year = NULL, + expenditure_concept = c("direct", "total")) { call <- match.call() + expenditure_concept <- match.arg(expenditure_concept) + if (identical(expenditure_concept, "total")) { + .abort_concept_not_aggregatable("cog_geographic_rollup") + } .validate_rollup_layers(govids) govids <- lapply(govids, .coerce_govid_input, arg = "govids[[layer]]") diff --git a/R/spending.R b/R/spending.R index cfefe90..d4cf7ff 100644 --- a/R/spending.R +++ b/R/spending.R @@ -82,6 +82,22 @@ cog_spending <- function(govid, years, category = NULL, ) } +#' @noRd +.abort_concept_not_aggregatable <- function(verb) { + cli::cli_abort(c( + "{.code expenditure_concept = \"total\"} cannot be used in {.fn {verb}}.", + "*" = "Use {.code expenditure_concept = \"direct\"} (the default) for any \\ + comparison or sum that spans more than one government.", + "i" = "Why: Census \"Total\" is a government's own Direct spending PLUS the \\ + money it hands to other governments. The receiving government reports \\ + that same dollar again as its own Direct when it actually spends it, \\ + so combining Total across governments double-counts intergovernmental \\ + transfers.", + "i" = "For one government's own Total, use \\ + {.code cog_spending(expenditure_concept = \"total\")}." + ), class = "uscogdata_concept_not_aggregatable") +} + #' @noRd .verb_spendrev <- function(verb, view_base, subtype_col, flow_prefixes, call, govid, years, category, diff --git a/man/cog_geographic_rollup.Rd b/man/cog_geographic_rollup.Rd index ffb2310..bafdc90 100644 --- a/man/cog_geographic_rollup.Rd +++ b/man/cog_geographic_rollup.Rd @@ -9,7 +9,8 @@ cog_geographic_rollup( category, years, per_capita = FALSE, - adjust_to_year = NULL + adjust_to_year = NULL, + expenditure_concept = c("direct", "total") ) } \arguments{ @@ -27,6 +28,13 @@ population from `gov_population_yearly`. Govs with missing population are excluded from the result.} \item{adjust_to_year}{Integer base year for CPI-U conversion, or `NULL`.} + +\item{expenditure_concept}{`"direct"` (default) or `"total"`. Currently only +`"direct"` is accepted; the `"total"` option exists in [cog_spending()] for +single-government queries but cannot be used here because combining Total +across multiple layers of government double-counts intergovernmental +transfers (a state's payment to a school district is the same dollar the +district reports as its own Direct spending).} } \value{ Tibble with columns `year`, `layer`, `canonical_govid`, `gov_name`, diff --git a/man/cog_peer_compare.Rd b/man/cog_peer_compare.Rd index a4d9f96..d1a42fc 100644 --- a/man/cog_peer_compare.Rd +++ b/man/cog_peer_compare.Rd @@ -10,7 +10,8 @@ cog_peer_compare( category, years, per_capita = TRUE, - adjust_to_year = NULL + adjust_to_year = NULL, + expenditure_concept = c("direct", "total") ) } \arguments{ @@ -27,6 +28,11 @@ cog_peer_compare( population.} \item{adjust_to_year}{Integer base year for CPI-U conversion or `NULL`.} + +\item{expenditure_concept}{`"direct"` (default) or `"total"`. Currently only +`"direct"` is accepted; the `"total"` option exists in [cog_spending()] for +single-government queries but cannot be used here because combining Total +across peer sets counts intergovernmental transfers twice.} } \value{ Tibble matching [cog_spending()]'s columns, plus a `role` diff --git a/tests/testthat/test-expenditure-concept.R b/tests/testthat/test-expenditure-concept.R index 5609ba8..1970ebb 100644 --- a/tests/testthat/test-expenditure-concept.R +++ b/tests/testthat/test-expenditure-concept.R @@ -186,3 +186,44 @@ test_that(".verb_spendrev rejects expenditure_concept = 'total' for a non-spendi class = "uscogdata_expenditure_concept_unsupported" ) }) + +test_that("cog_geographic_rollup refuses expenditure_concept = 'total'", { + expect_error( + cog_geographic_rollup( + govids = list(state = "010000226085"), + category = "Police", years = 2019, + expenditure_concept = "total" + ), + class = "uscogdata_concept_not_aggregatable" + ) +}) + +test_that("cog_peer_compare refuses expenditure_concept = 'total'", { + expect_error( + cog_peer_compare( + target_govid = "010000226085", peers = "010000226085", + category = "Police", years = 2019, + expenditure_concept = "total" + ), + class = "uscogdata_concept_not_aggregatable" + ) +}) + +test_that("the refusal message names the fix and the reason", { + err <- tryCatch( + cog_geographic_rollup(govids = list(state = "010000226085"), + category = "Police", years = 2019, + expenditure_concept = "total"), + condition = function(e) e + ) + msg <- paste(conditionMessage(err), collapse = " ") + expect_match(msg, "direct") + expect_match(msg, "double-count|double count") +}) + +test_that("both cross-government verbs still accept the direct default", { + expect_no_error( + cog_geographic_rollup(govids = list(state = "010000226085"), + category = "Police", years = 2019) + ) +})