cog_spending(), cog_revenue() and cog_balances() gain optional state/type
arguments. Both default to NULL, so every existing govid-based call is
unchanged.
The verbs took a cohort only as a govid vector, which .sql_lit_chr()
rendered into a quoted IN list and .verb_spendrev() embedded into 5-8
separate statements per call: the scope check, the main aggregate, the
per-capita join, the harmonization block, and the suggestion and
suppression queries. For type = "city" that list is 301,589 characters,
parsed and planned from scratch every time it appears.
Passing state/type instead expresses the cohort as a subquery against
canonical_fips_xwalk, so its size never enters the SQL string at all.
Measured on the production corpus, same FY2022 aggregate over the
20,106-government city cohort, DUCKDB_THREADS=2, median of 5:
IN (20,106 literals) -- 0.3.0 432 ms
join against a temp cohort table 132 ms
predicate on canonical_fips_xwalk 102 ms
no cohort filter at all (the floor) 105 ms
The predicate reaches the no-filter floor: the cohort restriction is
now free. End to end through cog_spending(category = "Police"),
1080 ms -> 271 ms, 3.99x -- larger than the single-query saving,
because the repetition across statements is what actually cost.
Design decisions, both made explicitly rather than left implicit:
- govid AND state/type INTERSECT. "These ids, narrowed to that
state/type" is a real query, and an error here could never be
relaxed later without breaking callers.
- A predicate cohort has no id list to report, so
provenance$scope$govids_found/govids_missing stay empty and a new
scope$cohort block carries state, type and n_governments. Resolving
the ids just to report them would put 20,000 govids in every
fleet-scale response body -- the cost this change removes. A
govid-named cohort's provenance is untouched.
state/type are coerced with .coerce_state_to_fips()/.coerce_type(), the
same helpers cog_gov_search() uses. That is load-bearing: the argument
is a postal abbreviation ("WI") while fips_state holds a FIPS code
("55"), and a predicate on the raw parameter matches nothing and returns
an empty result indistinguishable from "reported nothing". cog-api hit
exactly this trap optimizing the same path.
.attach_per_capita() now keys its population lookup on the govids present
in the result rather than the requested cohort. Those are the only ones
its LEFT JOIN can match, so the output is identical -- but it needs no id
list, and on a paginated call it looks up one page instead of the fleet.
Fixes uscogdata#58.
118 lines
5.8 KiB
R
118 lines
5.8 KiB
R
# R/suppression.R
|
|
# Split out of R/suggestions.R (2026-08-05) to keep files under the project's
|
|
# 400-line limit. Owns the second qualifying path for coverage signposting
|
|
# (uscogdata#9): measuring, per government, the component dollars the
|
|
# calling verb's own long view structurally excludes (aggregate-published,
|
|
# or absent from summary_categories). See R/suggestions.R for the
|
|
# orchestrator (`.build_suggestions()`) that calls this and the full
|
|
# uscogdata#9 background.
|
|
|
|
#' Measure, per (recipe, year), the component dollars this government holds
|
|
#' that the calling verb's own long view structurally excludes.
|
|
#'
|
|
#' This is the second qualifying path for a suggestion (uscogdata#9). The
|
|
#' first -- row absence -- only fires when a category returns NOTHING in a
|
|
#' requested year, which is how Corrections behaves in the wide era. Public
|
|
#' Welfare is the failure mode it misses: E74/E75/E77/E79 still return rows,
|
|
#' so there is no absence to detect, while E67/E68 (aggregate-flagged 1967-
|
|
#' 2011, and absent from `summary_categories` entirely) are dropped. The
|
|
#' caller gets a plausible number a third too low, silently.
|
|
#'
|
|
#' "Structurally excluded" is decided by anti-joining the verb's REAL long
|
|
#' view rather than restating its WHERE clause, so this stays correct if
|
|
#' `spending_long_harmonized` / `revenue_long_harmonized` ever change. That
|
|
#' anti-join is keyed on `item_code`, which is sound only because
|
|
#' harmonization never renames a recipe component -- asserted by the "no
|
|
#' recipe component is ever renamed by harmonization" test in
|
|
#' tests/testthat/test-recipes.R.
|
|
#'
|
|
#' Note what this deliberately does NOT count as suppressed: a component
|
|
#' excluded from the RESULT for scoping reasons -- because it belongs to a
|
|
#' different `category`, or because `expenditure_concept` narrowed the
|
|
#' subtypes -- is still present in the view, so it never fires. Suggesting a
|
|
#' recipe is a coverage fix, not a category redefinition.
|
|
#'
|
|
#' `flow_prefixes` (uscogdata#9 review, finding I1) restricts the measured
|
|
#' components to the CALLING VERB's own flow family (`c("E","F","G")` for
|
|
#' spending, `c("T","A","U","B","C","D")` for revenue). Without this, a
|
|
#' candidate recipe belonging to the OTHER flow family is always absent from
|
|
#' this verb's view (by construction -- `cog_revenue()`'s view never carries
|
|
#' an E-coded row) and so was always reported as "suppressed", fabricating a
|
|
#' dollar claim across flow families (`cog_revenue(category = "Corrections")`
|
|
#' claimed $3.63B excluded that `cog_spending()` reports and fully accounts
|
|
#' for). Filtering on `LEFT(r.component_code, 1)` also drops M/L-prefixed
|
|
#' components from measurement under `cog_spending()` (`flow_prefixes` never
|
|
#' includes "M"/"L") -- harmless today, because a recipe's own M/L components
|
|
#' (e.g. `corrections_ig_local_combined`'s M04/M05) are present in the view
|
|
#' in every year they exist and so never fired as suppressed anyway, but
|
|
#' worth recording since this filter is now the thing relied on to prevent
|
|
#' it.
|
|
#'
|
|
#' @param con Active DuckDB connection.
|
|
#' @param candidates Character vector of recipe ids to measure.
|
|
#' @param cohort The verb's cohort object (see `.make_cohort()`), rendered
|
|
#' into the govid predicate on both the outer scan and the restated
|
|
#' NOT EXISTS filter.
|
|
#' @param years Integer vector of requested years.
|
|
#' @param long_view Name of the verb's long view, from `.select_long_view()`.
|
|
#' @param flow_prefixes The calling verb's own flow-type prefixes (see
|
|
#' `.build_suggestions()`). Only recipe components whose first character is
|
|
#' in this set are measured.
|
|
#' @return Tibble of `recipe_id`, `year`, `suppressed_amount` (full US
|
|
#' dollars), `suppressed_codes` (comma-joined, sorted). Zero rows when
|
|
#' nothing is suppressed.
|
|
#' @noRd
|
|
.suppressed_components <- function(con, candidates, cohort, years, long_view,
|
|
flow_prefixes) {
|
|
empty <- tibble::tibble(
|
|
recipe_id = character(0), year = numeric(0),
|
|
suppressed_amount = numeric(0), suppressed_codes = character(0)
|
|
)
|
|
if (length(candidates) == 0L) return(empty)
|
|
|
|
# long_view is interpolated as a SQL IDENTIFIER, not a literal, so it can
|
|
# never be quoted safely. It is always internally derived from a fixed
|
|
# view_base, so an off-allowlist value is a programming error, not input.
|
|
if (!long_view %in% c("spending_long", "spending_long_harmonized",
|
|
"revenue_long", "revenue_long_harmonized")) {
|
|
cli::cli_abort(
|
|
"Internal error: unexpected `long_view` {.val {long_view}}.",
|
|
class = "uscogdata_internal_error"
|
|
)
|
|
}
|
|
|
|
sql <- sprintf(
|
|
"SELECT r.recipe_id,
|
|
l.year,
|
|
SUM(l.amt) * 1000.0 AS suppressed_amount,
|
|
string_agg(DISTINCT l.item_code, ',' ORDER BY l.item_code)
|
|
AS suppressed_codes
|
|
FROM long l
|
|
JOIN harmonization_recipes r
|
|
ON l.item_code = r.component_code
|
|
AND l.year BETWEEN r.year_min AND r.year_max
|
|
AND (r.gov_type_scope = 'all'
|
|
OR (r.gov_type_scope = 'state' AND l.type = 0)
|
|
OR (r.gov_type_scope = 'local' AND l.type BETWEEN 1 AND 3))
|
|
WHERE r.recipe_id IN (%1$s)
|
|
AND %2$s
|
|
AND l.year IN (%3$s)
|
|
AND l.amt <> 0
|
|
AND LEFT(r.component_code, 1) IN (%5$s)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM %4$s v
|
|
WHERE v.canonical_govid = l.canonical_govid
|
|
AND v.year = l.year
|
|
AND v.item_code = l.item_code
|
|
AND v.year IN (%3$s) -- restated: enables partition pruning (I3a)
|
|
AND %6$s -- restated: pushes the cohort filter (I3a)
|
|
)
|
|
GROUP BY 1, 2
|
|
ORDER BY 1, 2",
|
|
.sql_lit_chr(candidates), .cohort_sql(cohort, "l.canonical_govid"),
|
|
paste(as.integer(years), collapse = ","), long_view,
|
|
.sql_lit_chr(flow_prefixes), .cohort_sql(cohort, "v.canonical_govid")
|
|
)
|
|
tibble::as_tibble(DBI::dbGetQuery(con, sql))
|
|
}
|