Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b23dbd9f4
|
||
|
|
93300ae0c1
|
||
|
|
7d798b9937
|
||
|
|
915a4d0678 | ||
|
|
6f98d061a9 | ||
|
|
d95c9032c5
|
||
|
|
af85a23ea7
|
||
|
|
8db944e4a0 | ||
|
|
2e8383b098
|
||
|
|
d006dea6e4
|
||
|
|
ebac39e6de | ||
|
|
47dc08c4b0 | ||
|
|
1d553a788f
|
||
|
|
c375c55da7
|
||
|
|
82acda6f93 | ||
|
|
9233c3d18e
|
||
|
|
1f257812b6 | ||
|
|
d258cef8c5 | ||
|
|
e7d3a7a310 | ||
|
|
a4eb80d823 | ||
|
|
aba7ffbac2 | ||
|
|
c1c6b5a6ba | ||
|
|
c7260cb20c | ||
|
|
54ece11867 | ||
|
|
3bd9b1f011 | ||
|
|
24b2ff7d8c | ||
|
|
c28712f62f | ||
|
|
7913b0f664 | ||
|
|
887acf7e81 | ||
|
|
81fd1a5279 | ||
|
|
e2088458e1 | ||
|
|
fefd4fe969 | ||
|
|
7ed1da9b79 | ||
|
|
9240a18ea3 | ||
|
|
46fed3a241 | ||
|
|
c9d1a05d4f | ||
|
|
fcecd62a03 | ||
|
|
e581e7360c | ||
|
|
748ca4a56e | ||
|
|
fa40266d07 | ||
|
|
3583c05852
|
||
|
|
bd53230ae7 | ||
|
|
e813ffd3aa | ||
|
|
b0df1ec668 | ||
|
|
77f48047b1
|
||
|
|
4de915b557
|
||
|
|
7818cd2b1a
|
||
|
|
3b725770d2 |
@@ -16,3 +16,4 @@
|
|||||||
^Meta$
|
^Meta$
|
||||||
^\.gitea$
|
^\.gitea$
|
||||||
^CLAUDE\.md$
|
^CLAUDE\.md$
|
||||||
|
^\.superpowers$
|
||||||
|
|||||||
@@ -9,3 +9,6 @@ docs/
|
|||||||
/Meta/
|
/Meta/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/.quarto/
|
/.quarto/
|
||||||
|
|
||||||
|
# SDD working artifacts (ledger, briefs, review packages) — plans/ stays tracked
|
||||||
|
.superpowers/sdd/
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -32,4 +32,4 @@ Config/testthat/edition: 3
|
|||||||
VignetteBuilder: knitr
|
VignetteBuilder: knitr
|
||||||
RoxygenNote: 7.3.3
|
RoxygenNote: 7.3.3
|
||||||
MinCorpusSchema: 4
|
MinCorpusSchema: 4
|
||||||
MaxCorpusSchema: 4
|
MaxCorpusSchema: 5
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ export(cog_gov_search)
|
|||||||
export(cog_manifest)
|
export(cog_manifest)
|
||||||
export(cog_mirror)
|
export(cog_mirror)
|
||||||
export(cog_peer_compare)
|
export(cog_peer_compare)
|
||||||
|
export(cog_recipes)
|
||||||
export(cog_revenue)
|
export(cog_revenue)
|
||||||
export(cog_spending)
|
export(cog_spending)
|
||||||
|
|||||||
@@ -1,5 +1,117 @@
|
|||||||
# uscogdata 0.1.0 (development)
|
# uscogdata 0.1.0 (development)
|
||||||
|
|
||||||
|
## Multi-government aggregates now disclose their reporting coverage
|
||||||
|
|
||||||
|
* The Census of Governments is a **complete census only in years ending in 2
|
||||||
|
and 7**; every other year is a sample, and the sample varies enormously. On
|
||||||
|
the bundled fixture, Wisconsin's 608-city universe rolls up **597**
|
||||||
|
governments in FY2012 and **112** in FY2019 — an 18%-to-98% swing the
|
||||||
|
return value said nothing about, so a statewide total resting on a fifth of
|
||||||
|
the universe looked exactly like one resting on all of it.
|
||||||
|
* `cog_geographic_rollup()`, `cog_peer_compare()` and `cog_find_peers()` gain
|
||||||
|
`coverage`:
|
||||||
|
|
||||||
|
| value | effect |
|
||||||
|
|---|---|
|
||||||
|
| `"all"` (default) | every unit that reported that year — unchanged behaviour |
|
||||||
|
| `"census"` | census years only; aborts if the range holds none rather than returning nothing |
|
||||||
|
| `"consistent"` | only units reporting in *every* requested year — a balanced panel |
|
||||||
|
|
||||||
|
* **Regardless of mode**, every result now carries `provenance$coverage` with
|
||||||
|
per-year `n_units_reporting`, `n_units_expected` and `is_census_year`, plus
|
||||||
|
`provenance$coverage_mode`. `cog_explain()` prints a "Reporting coverage"
|
||||||
|
section. So the default mode can no longer mislead silently.
|
||||||
|
* `is_census_year` is a statement about the **survey calendar**, never a claim
|
||||||
|
of completeness: FY1967 is a census year in which only 97 of Wisconsin's 608
|
||||||
|
cities report. `n_units_reporting` is the number that tells the truth.
|
||||||
|
* On `cog_peer_compare()` the target is exempt from `"consistent"` balancing —
|
||||||
|
it is the subject of the comparison, not a member of the cohort — and the
|
||||||
|
`summary_*` quantiles are computed after the filter, so they describe the
|
||||||
|
cohort actually returned. `n_units_reporting` counts peers only, against the
|
||||||
|
cohort size.
|
||||||
|
* On `cog_find_peers()`, `coverage` governs the cohort **vintage** when `year`
|
||||||
|
is `NULL`: `"census"` snaps to the most recent census year with an observed
|
||||||
|
population, so a cohort is not built from a sample year in which most of the
|
||||||
|
candidate universe is absent.
|
||||||
|
|
||||||
|
## `complete = TRUE`: absent cells, labelled with why they are absent
|
||||||
|
|
||||||
|
* `cog_spending()` and `cog_revenue()` gain `complete`, defaulting to `FALSE`
|
||||||
|
(today's behaviour). With `complete = TRUE` the requested grid is filled
|
||||||
|
from the corpus's `code_set` table and every row carries a new
|
||||||
|
`value_source` column:
|
||||||
|
|
||||||
|
| `value_source` | meaning | `amt_nominal` |
|
||||||
|
|---|---|---|
|
||||||
|
| `reported` | the corpus carries this cell | as published |
|
||||||
|
| `census_zero` | dense-source year (≤ FY2011), cell absent — Census published `$0` | `0` |
|
||||||
|
| `not_reported` | sparse-source year (≥ FY2012), cell absent — unknown | `NA` |
|
||||||
|
|
||||||
|
The `NA` is deliberate and is the whole point: filling a modern absence
|
||||||
|
with `0` would invent data, which is precisely the error the corpus's
|
||||||
|
representation contract exists to prevent.
|
||||||
|
* This restores information the reader lost when the corpus was sparsified
|
||||||
|
(`SB194`, cog_pipeline#64) — a wide-era query whose cells were all `$0`
|
||||||
|
had begun returning nothing at all — and improves on what came before it,
|
||||||
|
since the pre-sparsification corpus could not distinguish a published zero
|
||||||
|
from an unreported cell either.
|
||||||
|
* The grid is scoped to each government's **own type**, so a county is never
|
||||||
|
filled with cells only a state can report.
|
||||||
|
* Needs a corpus published from 2026-07-29 onward (when `representation` and
|
||||||
|
`code_set` began shipping); aborts with class
|
||||||
|
`uscogdata_representation_unavailable` otherwise. Gated on the manifest
|
||||||
|
listing those tables rather than on `schema_version`, which was never
|
||||||
|
bumped for the change. Not available with `recipe` or
|
||||||
|
`expenditure_concept = "total"` — neither draws its cells from `code_set`.
|
||||||
|
* `provenance$completion` reports `applied`, `rows_filled`, and the per-year
|
||||||
|
`absence_means` rule; `cog_explain()` prints a "Completion" section.
|
||||||
|
|
||||||
|
## Corpus-wide series breaks now reach users (`corpus_break_refs`)
|
||||||
|
|
||||||
|
* Four catalogued series breaks carry `fin_code = "ALL"` — caveats about the
|
||||||
|
corpus as a whole rather than about one item code. `series_break_refs` is
|
||||||
|
built by matching `fin_code` against the item codes in the result, and no
|
||||||
|
row's `item_code` is ever the literal `"ALL"`, so **none of them could ever
|
||||||
|
be surfaced**: `SB085` (dollar precision across the 1976/1977 boundary),
|
||||||
|
`SB087` (imputation exclusion from FY2002), `SB194` (the dense → sparse
|
||||||
|
representation change at FY2012) and `SB086` (the government id scheme
|
||||||
|
change at FY2017).
|
||||||
|
* Provenance gains `corpus_break_refs`, selected on the break-year window
|
||||||
|
alone and disjoint from `series_break_refs` by construction, so a consumer
|
||||||
|
can tell a whole-result caveat from a break in one series. `cog_explain()`
|
||||||
|
prints them under their own "Corpus-wide caveats" heading. cog-api passes
|
||||||
|
provenance through verbatim, so the field appears there without an API
|
||||||
|
change.
|
||||||
|
* `SB194` is the one that made this urgent: a query spanning FY2011 → FY2012
|
||||||
|
crosses the boundary where an absent cell stops meaning "Census published
|
||||||
|
`$0`" and starts meaning "not reported", and until now nothing said so.
|
||||||
|
|
||||||
|
## Bundled fixture regenerated against the sparsified corpus
|
||||||
|
|
||||||
|
* `inst/extdata/fixture_corpus/` now tracks the corpus published on
|
||||||
|
2026-07-29 (`pipeline_commit 83f9715`, schema v6). The wide era no longer
|
||||||
|
stores explicit zeros: FY2011 fell from 2,864,212 rows to 496,004, of
|
||||||
|
which none are `$0`. **Absence now means two different things** — in a
|
||||||
|
`dense_source` year (≤ FY2011) an absent cell means Census published `$0`;
|
||||||
|
in a `sparse_source` year (≥ FY2012) it means not reported. The corpus
|
||||||
|
carries that rule in two new tables the fixture now ships,
|
||||||
|
`representation.parquet` and `code_set.parquet`, alongside
|
||||||
|
`census_collection_coverage.parquet` and `lineage_events.parquet`
|
||||||
|
(all ten publish-tree metadata tables, up from six). Catalogued upstream
|
||||||
|
as series break `SB194`.
|
||||||
|
* `cog_categories()` gains an `assistance` spending subtype: the J-prefix
|
||||||
|
aid/benefit codes (`J19`, `J67`, `J68`, `J85`) are categorised now that
|
||||||
|
the upstream crosswalk covers every flow code carrying dollars.
|
||||||
|
* Two consequences worth knowing about, both visible in provenance rather
|
||||||
|
than in returned dollars. The harmonization block's `na_rows_excluded`
|
||||||
|
counts only rows that exist, so wide-era codes that were zero-padded no
|
||||||
|
longer appear there. Coverage-gap `suggestions` are presence-based for the
|
||||||
|
same reason, so a recipe whose component codes were all `$0` for a given
|
||||||
|
government-year is no longer suggested for it.
|
||||||
|
* `tests/testthat/test-fixture-vintage.R` pins these structural facts, so a
|
||||||
|
fixture left behind by a future publish fails loudly instead of letting the
|
||||||
|
suite pass against a corpus that no longer exists.
|
||||||
|
|
||||||
## Breaking: corpus schema_version 4 (Phase P canonical ids)
|
## Breaking: corpus schema_version 4 (Phase P canonical ids)
|
||||||
|
|
||||||
* The package now requires corpus `schema_version = 4` (`MinCorpusSchema` /
|
* The package now requires corpus `schema_version = 4` (`MinCorpusSchema` /
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# R/basis.R
|
||||||
|
# basis= resolution (harmonized/raw, with v4/v5 dual-accept) and the
|
||||||
|
# harmonization exclusion-count block attached to provenance.
|
||||||
|
|
||||||
|
#' Resolve the requested `basis` against the active corpus's schema_version.
|
||||||
|
#'
|
||||||
|
#' On a `schema_version >= 5` corpus, the requested basis is used as-is. On
|
||||||
|
#' an older (`schema_version == 4`) corpus, which has no harmonization
|
||||||
|
#' tables: a caller who left `basis` at its default (`"harmonized"`, so
|
||||||
|
#' `explicit` is `FALSE`) silently gets `"raw"` back, with a note recorded
|
||||||
|
#' for provenance; a caller who explicitly asked for
|
||||||
|
#' `basis = "harmonized"` gets a hard abort instead of a silent downgrade.
|
||||||
|
#'
|
||||||
|
#' @param basis `"harmonized"` or `"raw"` (already resolved via `match.arg`).
|
||||||
|
#' @param explicit `TRUE` if the caller passed `basis` explicitly (as
|
||||||
|
#' opposed to relying on the default `c("harmonized", "raw")`).
|
||||||
|
#' @param manifest The active session's parsed manifest list.
|
||||||
|
#' @return List with `basis` (the resolved value) and `note` (character or
|
||||||
|
#' `NA_character_`).
|
||||||
|
#' @noRd
|
||||||
|
.resolve_basis <- function(basis, explicit, manifest) {
|
||||||
|
schema_version <- suppressWarnings(as.integer(manifest$schema_version %||% 0L))
|
||||||
|
|
||||||
|
if (schema_version >= 5L) {
|
||||||
|
return(list(basis = basis, note = NA_character_))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (identical(basis, "harmonized") && explicit) {
|
||||||
|
cli::cli_abort(c(
|
||||||
|
"basis = \"harmonized\" requires corpus schema_version >= 5.",
|
||||||
|
x = "Active corpus has schema_version {schema_version}.",
|
||||||
|
i = "Use basis = \"raw\" (the default on this corpus), or point USCOGDATA_URL at a schema_version >= 5 corpus."
|
||||||
|
), class = "uscogdata_basis_unsupported")
|
||||||
|
}
|
||||||
|
|
||||||
|
list(
|
||||||
|
basis = "raw",
|
||||||
|
note = sprintf(
|
||||||
|
"basis resolved to \"raw\": corpus schema_version %d < 5 (harmonization tables unavailable)",
|
||||||
|
schema_version
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#' Count + sum item-level rows that basis="harmonized" excludes because they
|
||||||
|
#' carry no harmonized_code (discontinued / not-yet-ruled codes) within the
|
||||||
|
#' calling verb's crosswalk scope (`subtype_col` values in `subtype_scope` --
|
||||||
|
#' the same subtype-membership classification the verb SQL uses, never
|
||||||
|
#' item-code prefixes), govids, and years. Only meaningful when the resolved
|
||||||
|
#' basis is "harmonized"; returns an applied = FALSE stub otherwise (raw
|
||||||
|
#' basis never excludes rows this way).
|
||||||
|
#'
|
||||||
|
#' The intergovernmental leg is deliberately outside this count even for
|
||||||
|
#' expenditure_concept = "total": ig_long_harmonized COALESCEs rather than
|
||||||
|
#' drops NULL-harmonized rows, so harmonization never excludes an IG row.
|
||||||
|
#' @noRd
|
||||||
|
.build_harmonization_block <- function(con, govid, years, resolved,
|
||||||
|
subtype_col, subtype_scope) {
|
||||||
|
if (!identical(resolved$basis, "harmonized")) {
|
||||||
|
return(list(
|
||||||
|
applied = FALSE,
|
||||||
|
na_rows_excluded = 0L,
|
||||||
|
na_amount_excluded = 0,
|
||||||
|
note = resolved$note
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
sql <- sprintf(
|
||||||
|
"SELECT COUNT(*) AS n, COALESCE(SUM(amt), 0) * 1000.0 AS amt
|
||||||
|
FROM long
|
||||||
|
WHERE canonical_govid IN (%s) AND year IN (%s)
|
||||||
|
AND NOT is_aggregate AND harmonized_code IS NULL
|
||||||
|
AND item_code IN (
|
||||||
|
SELECT item_code FROM summary_categories WHERE %s IN (%s)
|
||||||
|
)",
|
||||||
|
.sql_lit_chr(govid), paste(as.integer(years), collapse = ","),
|
||||||
|
subtype_col, .sql_lit_chr(subtype_scope)
|
||||||
|
)
|
||||||
|
na <- DBI::dbGetQuery(con, sql)
|
||||||
|
|
||||||
|
list(
|
||||||
|
applied = TRUE,
|
||||||
|
na_rows_excluded = as.integer(na$n),
|
||||||
|
na_amount_excluded = as.numeric(na$amt),
|
||||||
|
note = resolved$note
|
||||||
|
)
|
||||||
|
}
|
||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
# R/complete.R
|
||||||
|
#
|
||||||
|
# `complete = TRUE` on the money verbs. Fills the requested grid so that a
|
||||||
|
# cell the corpus does not carry still appears, labelled with WHY it is
|
||||||
|
# missing.
|
||||||
|
#
|
||||||
|
# The corpus stopped storing the wide era's explicit zeros
|
||||||
|
# (cog_pipeline#64, series break SB194), which made absence ambiguous:
|
||||||
|
#
|
||||||
|
# <= FY2011 dense_source absent => Census published $0 (census_zero)
|
||||||
|
# >= FY2012 sparse_source absent => not reported, unknown (not_reported)
|
||||||
|
#
|
||||||
|
# Before sparsification a wide-era query whose cells were all $0 came back as
|
||||||
|
# explicit $0 rows; afterwards it came back empty, with nothing to say which
|
||||||
|
# of the two meanings applied. This restores that -- and improves on it,
|
||||||
|
# because the pre-sparsification corpus could not distinguish the two either.
|
||||||
|
#
|
||||||
|
# `census_zero` fills carry `amt_nominal = 0`; `not_reported` fills carry NA.
|
||||||
|
# That difference is the entire point: writing 0 into a modern absence would
|
||||||
|
# invent data, which is the error the representation contract exists to stop.
|
||||||
|
|
||||||
|
#' @noRd
|
||||||
|
.abort_complete_unsupported <- function(reason, alternative) {
|
||||||
|
cli::cli_abort(c(
|
||||||
|
"{.code complete = TRUE} is not supported for this query.",
|
||||||
|
x = reason,
|
||||||
|
i = alternative
|
||||||
|
), class = "uscogdata_complete_unsupported")
|
||||||
|
}
|
||||||
|
|
||||||
|
#' @noRd
|
||||||
|
.require_representation <- function(con, manifest) {
|
||||||
|
needed <- c("representation.parquet", "code_set.parquet")
|
||||||
|
missing <- needed[!vapply(needed, function(f) .corpus_has_table(manifest, f),
|
||||||
|
logical(1))]
|
||||||
|
if (length(missing) == 0L) return(invisible(TRUE))
|
||||||
|
cli::cli_abort(c(
|
||||||
|
"This corpus does not publish the representation contract.",
|
||||||
|
x = "Missing: {.file {missing}}.",
|
||||||
|
i = "{.code complete = TRUE} needs those tables to know whether an absent cell means Census published $0 or means the government did not report.",
|
||||||
|
i = "They ship with corpora published from 2026-07-29 onward; re-point {.envvar USCOGDATA_URL} at a current corpus, or omit {.code complete}."
|
||||||
|
), class = "uscogdata_representation_unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
#' The cells a government-year COULD carry: every code in force for that
|
||||||
|
#' government's own type, mapped through `summary_categories`, restricted to
|
||||||
|
#' the calling verb's crosswalk subtype scope (the same subtype-membership
|
||||||
|
#' classification the verb SQL itself uses -- e.g. the `primary` concept's
|
||||||
|
#' operations/capital/assistance) and (when given) its category filter.
|
||||||
|
#'
|
||||||
|
#' Scoped by `govs_type` deliberately. Filling against the union of all types
|
||||||
|
#' would invent cells that the government can never report -- a county row for
|
||||||
|
#' "state IG transfer to school districts" -- and those inventions would then
|
||||||
|
#' be indistinguishable from real census zeros.
|
||||||
|
#'
|
||||||
|
#' `NOT cs.is_aggregate` mirrors `spending_long` / `revenue_long`, which drop
|
||||||
|
#' aggregate rows. Without it the grid would offer cells the verb structurally
|
||||||
|
#' never returns, so every one of them would fill as a phantom $0.
|
||||||
|
#' @noRd
|
||||||
|
.completion_grid_sql <- function(subtype_col, govid, years, category,
|
||||||
|
subtype_scope) {
|
||||||
|
category_pred <- if (is.null(category)) {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
sprintf("AND c.category IN (%s)", .sql_lit_chr(category))
|
||||||
|
}
|
||||||
|
sprintf(
|
||||||
|
"SELECT DISTINCT
|
||||||
|
cs.year,
|
||||||
|
x.canonical_govid,
|
||||||
|
x.gov_name,
|
||||||
|
c.%1$s AS subtype_value,
|
||||||
|
c.category,
|
||||||
|
r.absence_means
|
||||||
|
FROM code_set cs
|
||||||
|
JOIN canonical_fips_xwalk x ON x.govs_type = cs.type
|
||||||
|
JOIN summary_categories c ON c.item_code = cs.item_code
|
||||||
|
JOIN representation r ON r.year = cs.year
|
||||||
|
WHERE x.canonical_govid IN (%2$s)
|
||||||
|
AND cs.year IN (%3$s)
|
||||||
|
AND NOT cs.is_aggregate
|
||||||
|
AND c.category IS NOT NULL
|
||||||
|
AND c.%1$s IN (%4$s)
|
||||||
|
%5$s",
|
||||||
|
subtype_col, .sql_lit_chr(govid),
|
||||||
|
paste(as.integer(years), collapse = ","),
|
||||||
|
.sql_lit_chr(subtype_scope), category_pred
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#' Fill `result` out to the full grid, stamping `value_source` on every row.
|
||||||
|
#'
|
||||||
|
#' Returns the completed tibble with a `.completion` attribute carrying the
|
||||||
|
#' provenance block. Reported rows are passed through untouched -- filling
|
||||||
|
#' must never alter or drop what the corpus actually published.
|
||||||
|
#' @noRd
|
||||||
|
.complete_result <- function(result, con, subtype_col, govid, years, category,
|
||||||
|
subtype_scope) {
|
||||||
|
grid <- tibble::as_tibble(DBI::dbGetQuery(
|
||||||
|
con, .completion_grid_sql(subtype_col, govid, years, category, subtype_scope)
|
||||||
|
))
|
||||||
|
|
||||||
|
result$value_source <- rep("reported", nrow(result))
|
||||||
|
if (nrow(grid) == 0L) {
|
||||||
|
attr(result, ".completion") <- list(
|
||||||
|
applied = TRUE, rows_filled = 0L, absence_means = list()
|
||||||
|
)
|
||||||
|
return(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
names(grid)[names(grid) == "subtype_value"] <- subtype_col
|
||||||
|
key <- function(d) {
|
||||||
|
paste(d$year, d$canonical_govid, d[[subtype_col]], d$category, sep = "\r")
|
||||||
|
}
|
||||||
|
missing <- grid[!key(grid) %in% key(result), , drop = FALSE]
|
||||||
|
|
||||||
|
if (nrow(missing) > 0L) {
|
||||||
|
filled <- tibble::tibble(
|
||||||
|
year = as.integer(missing$year),
|
||||||
|
canonical_govid = as.character(missing$canonical_govid),
|
||||||
|
gov_name = as.character(missing$gov_name),
|
||||||
|
category = as.character(missing$category),
|
||||||
|
# census_zero is a value Census published; not_reported is unknown and
|
||||||
|
# must stay NA. Collapsing the two to 0 is the defect, not the fill.
|
||||||
|
amt_nominal = ifelse(missing$absence_means == "census_zero",
|
||||||
|
0, NA_real_),
|
||||||
|
codes_included = NA_character_,
|
||||||
|
aggregate_fallback = NA,
|
||||||
|
value_source = as.character(missing$absence_means)
|
||||||
|
)
|
||||||
|
filled[[subtype_col]] <- as.character(missing[[subtype_col]])
|
||||||
|
if ("notes" %in% names(result)) filled$notes <- NA_character_
|
||||||
|
|
||||||
|
result <- dplyr::bind_rows(result, filled)
|
||||||
|
result <- result[order(result$year, result$canonical_govid,
|
||||||
|
result[[subtype_col]], result$category), ,
|
||||||
|
drop = FALSE]
|
||||||
|
}
|
||||||
|
|
||||||
|
rules <- unique(grid[, c("year", "absence_means")])
|
||||||
|
attr(result, ".completion") <- list(
|
||||||
|
applied = TRUE,
|
||||||
|
rows_filled = nrow(missing),
|
||||||
|
absence_means = stats::setNames(
|
||||||
|
as.list(as.character(rules$absence_means)), as.character(rules$year)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result
|
||||||
|
}
|
||||||
+24
-1
@@ -21,7 +21,30 @@
|
|||||||
.uscogdata_defaults[[key]]
|
.uscogdata_defaults[[key]]
|
||||||
}
|
}
|
||||||
|
|
||||||
.resolve_url <- function() .cfg("url")
|
#' Resolve the corpus URL, guaranteeing the trailing slash the package assumes.
|
||||||
|
#'
|
||||||
|
#' Every consumer builds locations by CONCATENATION -- `paste0(url,
|
||||||
|
#' "manifest.json")` in manifest.R, `paste0(url, e$path)` in mirror.R, and the
|
||||||
|
#' parquet glob in views.R -- and mirror.R:104 documents the invariant outright
|
||||||
|
#' ('url ends in "/"'). Nothing enforced it, so a URL entered without the slash
|
||||||
|
#' failed silently and misleadingly:
|
||||||
|
#'
|
||||||
|
#' HTTPS -> ".../downloadmanifest.json"; the host answers with an HTML 404
|
||||||
|
#' page, which lands in the JSON parser as the lexical error
|
||||||
|
#' reported in issue #3 -- pointing the user at "login page / wrong
|
||||||
|
#' share" when the real cause was one missing character.
|
||||||
|
#' local -> ".../corpusdata/long/**/*.parquet" and a DuckDB "No files found".
|
||||||
|
#'
|
||||||
|
#' Normalizing here fixes every consumer at once, rather than each call site
|
||||||
|
#' re-deriving the same invariant. An empty setting is passed through
|
||||||
|
#' untouched so manifest.R's "not configured" guard still fires instead of the
|
||||||
|
#' value degrading into a bare "/" filesystem root.
|
||||||
|
#' @noRd
|
||||||
|
.resolve_url <- function() {
|
||||||
|
url <- .cfg("url")
|
||||||
|
if (is.null(url) || !nzchar(url) || grepl("/$", url)) return(url)
|
||||||
|
paste0(url, "/")
|
||||||
|
}
|
||||||
|
|
||||||
.resolve_cache_dir <- function() {
|
.resolve_cache_dir <- function() {
|
||||||
v <- .cfg("cache_dir")
|
v <- .cfg("cache_dir")
|
||||||
|
|||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
# R/coverage.R
|
||||||
|
#
|
||||||
|
# Reporting-coverage disclosure for the multi-government verbs (uscogdata#13,
|
||||||
|
# findings F-020 and F-023).
|
||||||
|
#
|
||||||
|
# The Census of Governments is a COMPLETE CENSUS only in years ending in 2 and
|
||||||
|
# 7. Every other year is a sample, and the sample varies enormously: on the
|
||||||
|
# bundled fixture, Wisconsin's 608-city universe reports 597 governments in
|
||||||
|
# FY2012 and 112 in FY2019. Summing "whatever reported" across those years is
|
||||||
|
# what the verbs have always done -- correctly -- but the return value said
|
||||||
|
# nothing about it, so a statewide total resting on 18% of the universe looked
|
||||||
|
# exactly like one resting on 98%.
|
||||||
|
#
|
||||||
|
# Owner's settled design: a `coverage` argument selecting WHICH units to
|
||||||
|
# include, plus always-on metadata saying how many there were either way. The
|
||||||
|
# principle behind it: using these verbs correctly must not require the caller
|
||||||
|
# to know the survey calendar.
|
||||||
|
|
||||||
|
# Years ending in 2 or 7 are full censuses of every government; all others are
|
||||||
|
# samples.
|
||||||
|
.CENSUS_YEAR_ENDINGS <- c(2L, 7L)
|
||||||
|
|
||||||
|
#' @noRd
|
||||||
|
.is_census_year <- function(years) {
|
||||||
|
as.integer(years) %% 10L %in% .CENSUS_YEAR_ENDINGS
|
||||||
|
}
|
||||||
|
|
||||||
|
#' @noRd
|
||||||
|
.validate_coverage <- function(coverage) {
|
||||||
|
tryCatch(
|
||||||
|
match.arg(coverage, c("all", "census", "consistent")),
|
||||||
|
error = function(e) {
|
||||||
|
cli::cli_abort(
|
||||||
|
"`coverage` must be one of {.val all}, {.val census} or {.val consistent}.",
|
||||||
|
class = "uscogdata_invalid_coverage", parent = e
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#' Restrict `years` to census years for `coverage = "census"`.
|
||||||
|
#'
|
||||||
|
#' Aborts rather than returning an empty result when the requested range holds
|
||||||
|
#' no census year: silently handing back zero rows for a query the caller
|
||||||
|
#' believes they made is the failure mode this whole issue is about.
|
||||||
|
#' @noRd
|
||||||
|
.apply_census_years <- function(years, coverage, verb) {
|
||||||
|
if (!identical(coverage, "census")) return(as.integer(years))
|
||||||
|
keep <- as.integer(years)[.is_census_year(years)]
|
||||||
|
if (length(keep) == 0L) {
|
||||||
|
cli::cli_abort(c(
|
||||||
|
"{.code coverage = \"census\"} leaves no years to query.",
|
||||||
|
x = "None of the requested years end in 2 or 7: {.val {sort(unique(as.integer(years)))}}.",
|
||||||
|
i = "Census of Governments years ending in 2 or 7 are complete censuses; all others are samples.",
|
||||||
|
i = "Use {.code coverage = \"all\"} (the default) to keep every requested year, or request a census year."
|
||||||
|
), class = "uscogdata_no_census_years")
|
||||||
|
}
|
||||||
|
sort(keep)
|
||||||
|
}
|
||||||
|
|
||||||
|
#' Keep only units that report in EVERY requested year (a balanced panel).
|
||||||
|
#'
|
||||||
|
#' `id_col` is the government identifier; `keep_ids` are rows exempt from the
|
||||||
|
#' filter (the peer-comparison target, which is the subject of the comparison
|
||||||
|
#' rather than a member of the cohort being balanced).
|
||||||
|
#' @noRd
|
||||||
|
.filter_consistent <- function(result, years, id_col = "canonical_govid",
|
||||||
|
keep_ids = character(0)) {
|
||||||
|
years <- unique(as.integer(years))
|
||||||
|
if (nrow(result) == 0L || length(years) <= 1L) return(result)
|
||||||
|
ids <- setdiff(unique(result[[id_col]]), c(NA, keep_ids))
|
||||||
|
present <- vapply(ids, function(g) {
|
||||||
|
all(years %in% unique(as.integer(result$year[result[[id_col]] == g])))
|
||||||
|
}, logical(1))
|
||||||
|
consistent <- c(ids[present], keep_ids)
|
||||||
|
result[result[[id_col]] %in% consistent | is.na(result[[id_col]]), ,
|
||||||
|
drop = FALSE]
|
||||||
|
}
|
||||||
|
|
||||||
|
#' Per-year coverage metadata, always attached regardless of mode.
|
||||||
|
#'
|
||||||
|
#' Built from the REQUESTED years rather than the years present in the result,
|
||||||
|
#' so a year in which nothing reported still appears -- with
|
||||||
|
#' `n_units_reporting = 0`, which is precisely the disclosure a silently
|
||||||
|
#' missing year fails to make.
|
||||||
|
#'
|
||||||
|
#' `n_units_reporting` describes the result the caller actually received, so
|
||||||
|
#' under `coverage = "consistent"` it reports the balanced count. `is_census_year`
|
||||||
|
#' is a statement about the SURVEY CALENDAR, never a claim of completeness:
|
||||||
|
#' FY1967 is a census year in which only 97 of Wisconsin's 608 cities report.
|
||||||
|
#' `n_units_reporting` is the number that tells the truth.
|
||||||
|
#' @noRd
|
||||||
|
.coverage_table <- function(result, years, n_expected,
|
||||||
|
id_col = "canonical_govid", rows = NULL) {
|
||||||
|
years <- sort(unique(as.integer(years)))
|
||||||
|
src <- if (is.null(rows)) result else rows
|
||||||
|
reporting <- vapply(years, function(y) {
|
||||||
|
ids <- src[[id_col]][as.integer(src$year) == y]
|
||||||
|
length(unique(ids[!is.na(ids)]))
|
||||||
|
}, integer(1))
|
||||||
|
tibble::tibble(
|
||||||
|
year = years,
|
||||||
|
n_units_reporting = as.integer(reporting),
|
||||||
|
n_units_expected = rep(as.integer(n_expected), length(years)),
|
||||||
|
is_census_year = .is_census_year(years)
|
||||||
|
)
|
||||||
|
}
|
||||||
+130
@@ -51,6 +51,38 @@ cog_explain <- function(result, format = c("print", "list")) {
|
|||||||
cli::cli_text("Category: (all)")
|
cli::cli_text("Category: (all)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!is.null(prov$basis)) {
|
||||||
|
note <- if (!is.null(prov$basis_note) && !is.na(prov$basis_note)) {
|
||||||
|
sprintf(" (%s)", prov$basis_note)
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
cli::cli_text("Basis: {prov$basis}{note}")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Each verb reports its OWN concept. Both fields are always present (each
|
||||||
|
# defaults to its concept's default), so printing `expenditure_concept`
|
||||||
|
# unconditionally would tell a cog_revenue() caller "Concept: primary",
|
||||||
|
# which names a spending concept their result has nothing to do with.
|
||||||
|
if (identical(prov$verb, "cog_revenue")) {
|
||||||
|
if (!is.null(prov$revenue_concept)) {
|
||||||
|
cli::cli_text("Concept: {prov$revenue_concept} revenue")
|
||||||
|
}
|
||||||
|
} else if (!is.null(prov$expenditure_concept)) {
|
||||||
|
concept_note <- if (!is.null(prov$expenditure_concept_note) &&
|
||||||
|
!is.na(prov$expenditure_concept_note)) {
|
||||||
|
sprintf(" (%s)", prov$expenditure_concept_note)
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
cli::cli_text("Concept: {prov$expenditure_concept}{concept_note}")
|
||||||
|
if (isTRUE(prov$expenditure_concept_direct_suppressed)) {
|
||||||
|
cli::cli_alert_warning(
|
||||||
|
"Direct leg unavailable for at least one requested (year, category) -- affected rows report intergovernmental dollars alone, not Direct + IG. See each row's notes."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cli::cli_h2("Codes observed")
|
cli::cli_h2("Codes observed")
|
||||||
codes <- prov$codes_summed$observed
|
codes <- prov$codes_summed$observed
|
||||||
if (length(codes) == 0L) {
|
if (length(codes) == 0L) {
|
||||||
@@ -66,6 +98,82 @@ cog_explain <- function(result, format = c("print", "list")) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h <- prov$harmonization
|
||||||
|
if (!is.null(h) && isTRUE(h$applied)) {
|
||||||
|
cli::cli_h2("Harmonization")
|
||||||
|
cli::cli_text(
|
||||||
|
"Excluded {h$na_rows_excluded} row(s) with no harmonized_code (${format(h$na_amount_excluded, big.mark = ',')})"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
rc <- prov$recipe
|
||||||
|
if (!is.null(rc)) {
|
||||||
|
cli::cli_h2("Recipe")
|
||||||
|
cli::cli_text("{rc$recipe_id}: {rc$label}")
|
||||||
|
comp_lines <- vapply(rc$components, function(x) {
|
||||||
|
sprintf("%s (%s, %s-%s, weight=%s)", x$component_code, x$gov_type_scope,
|
||||||
|
x$year_min, x$year_max, x$weight)
|
||||||
|
}, character(1))
|
||||||
|
cli::cli_ul(comp_lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (length(prov$suggestions) > 0L) {
|
||||||
|
cli::cli_h2("Suggestions")
|
||||||
|
sugg_lines <- vapply(prov$suggestions, function(s) {
|
||||||
|
sprintf("%s -- %s (years %s-%s): %s", s$recipe_id, s$label,
|
||||||
|
s$available_years[1], s$available_years[2], s$hint)
|
||||||
|
}, character(1))
|
||||||
|
cli::cli_ul(sugg_lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is.null(prov$coverage) && nrow(prov$coverage) > 0L) {
|
||||||
|
cli::cli_h2("Reporting coverage")
|
||||||
|
cli::cli_text("Mode: {prov$coverage_mode %||% 'all'}")
|
||||||
|
cov <- prov$coverage
|
||||||
|
cli::cli_ul(sprintf(
|
||||||
|
"%d: %d of %d units reporting (%.0f%%) -- %s year",
|
||||||
|
cov$year, cov$n_units_reporting, cov$n_units_expected,
|
||||||
|
100 * cov$n_units_reporting / pmax(cov$n_units_expected, 1L),
|
||||||
|
ifelse(cov$is_census_year, "census", "sample")
|
||||||
|
))
|
||||||
|
if (any(!cov$is_census_year)) {
|
||||||
|
cli::cli_text(
|
||||||
|
"Note: the Census of Governments is a complete census only in years ending in 2 or 7; every other year is a sample."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTRUE(prov$completion$applied)) {
|
||||||
|
cli::cli_h2("Completion")
|
||||||
|
cli::cli_text(
|
||||||
|
"Filled {prov$completion$rows_filled} absent cell(s) from the corpus code set."
|
||||||
|
)
|
||||||
|
rules <- prov$completion$absence_means
|
||||||
|
if (length(rules) > 0L) {
|
||||||
|
cli::cli_ul(vapply(names(rules), function(y) {
|
||||||
|
sprintf("%s: an absent cell means %s", y,
|
||||||
|
if (identical(rules[[y]], "census_zero")) {
|
||||||
|
"Census published $0 (filled as 0)"
|
||||||
|
} else {
|
||||||
|
"the government did not report (filled as NA, not 0)"
|
||||||
|
})
|
||||||
|
}, character(1)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (length(prov$series_break_refs) > 0L) {
|
||||||
|
cli::cli_h2("Series breaks")
|
||||||
|
cli::cli_ul(.series_break_story_lines(prov$series_break_refs))
|
||||||
|
}
|
||||||
|
|
||||||
|
# Kept in a section of its own: these qualify the whole result, so folding
|
||||||
|
# them in with the per-code breaks above would invite reading them as a
|
||||||
|
# caveat about one series.
|
||||||
|
if (length(prov$corpus_break_refs) > 0L) {
|
||||||
|
cli::cli_h2("Corpus-wide caveats")
|
||||||
|
cli::cli_ul(.series_break_story_lines(prov$corpus_break_refs))
|
||||||
|
}
|
||||||
|
|
||||||
cli::cli_h2("Transformations")
|
cli::cli_h2("Transformations")
|
||||||
uc <- prov$transformations$units_conversion
|
uc <- prov$transformations$units_conversion
|
||||||
if (isTRUE(uc$applied)) {
|
if (isTRUE(uc$applied)) {
|
||||||
@@ -109,6 +217,28 @@ cog_explain <- function(result, format = c("print", "list")) {
|
|||||||
invisible(NULL)
|
invisible(NULL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# One "break-story" line per referenced break_id: "SB109 (2005): <join_advice>".
|
||||||
|
# Re-queries series_breaks_pq for the detail (break_year, join_advice) that
|
||||||
|
# provenance$series_break_refs deliberately doesn't carry (the schema keeps
|
||||||
|
# that field to a plain id array). Falls back to bare ids if no session is
|
||||||
|
# available (e.g. explaining a result after cog_close()) rather than
|
||||||
|
# erroring cog_explain() over a cosmetic detail.
|
||||||
|
#' @noRd
|
||||||
|
.series_break_story_lines <- function(break_ids) {
|
||||||
|
con <- tryCatch(.ensure_session(), error = function(e) NULL)
|
||||||
|
if (is.null(con) || !DBI::dbIsValid(con)) return(break_ids)
|
||||||
|
detail <- tryCatch(
|
||||||
|
DBI::dbGetQuery(con, sprintf(
|
||||||
|
"SELECT break_id, break_year, join_advice FROM series_breaks_pq
|
||||||
|
WHERE break_id IN (%s) ORDER BY break_id",
|
||||||
|
.sql_lit_chr(break_ids)
|
||||||
|
)),
|
||||||
|
error = function(e) NULL
|
||||||
|
)
|
||||||
|
if (is.null(detail) || nrow(detail) == 0L) return(break_ids)
|
||||||
|
sprintf("%s (%s): %s", detail$break_id, detail$break_year, detail$join_advice)
|
||||||
|
}
|
||||||
|
|
||||||
# Expand a 2-digit Census popyear (e.g. 19) to a 4-digit calendar year (2019).
|
# Expand a 2-digit Census popyear (e.g. 19) to a 4-digit calendar year (2019).
|
||||||
# F-33 metadata stores popyear as 2 digits; pivot at 70 to handle a future
|
# F-33 metadata stores popyear as 2 digits; pivot at 70 to handle a future
|
||||||
# corpus that ever spans pre-1970 vintages, though current scope is 2000+.
|
# corpus that ever spans pre-1970 vintages, though current scope is 2000+.
|
||||||
|
|||||||
+13
-3
@@ -128,11 +128,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#' @noRd
|
#' @noRd
|
||||||
.validate_schema <- function(manifest, expected_version) {
|
#' Schema v6 (FIPS geography harmonization, 2026-07-22) is accepted alongside
|
||||||
if (manifest$schema_version != expected_version) {
|
#' 4/5. v6 renamed the long table's fips_state_code/fips_county_code to
|
||||||
|
#' fips_state_asof/fips_county_asof and added cog_legacy_state/
|
||||||
|
#' cog_legacy_county (26 -> 28 cols); this package references NONE of those
|
||||||
|
#' columns, so no code change was needed. NOTE the SILENT semantic change for
|
||||||
|
#' any consumer of the raw long table: long fips_state/fips_county are now
|
||||||
|
#' PRESENT/harmonized geography (current county identity carried back to every
|
||||||
|
#' year, matching canonical_fips_xwalk) rather than as-of-year; as-of-year
|
||||||
|
#' moved to the *_asof columns. This package's own geography always came from
|
||||||
|
#' the xwalk (already present-based), so behaviour is unchanged.
|
||||||
|
.validate_schema <- function(manifest, supported = c(4L, 5L, 6L)) {
|
||||||
|
if (!manifest$schema_version %in% supported) {
|
||||||
cli::cli_abort(c(
|
cli::cli_abort(c(
|
||||||
"Corpus schema version mismatch.",
|
"Corpus schema version mismatch.",
|
||||||
x = "Package expects schema_version = {expected_version}; corpus has {manifest$schema_version}.",
|
x = "Package supports schema_version in {paste(supported, collapse = ', ')}; corpus has {manifest$schema_version}.",
|
||||||
i = "Update uscogdata (install.packages or pak::pkg_install) or re-publish corpus."
|
i = "Update uscogdata (install.packages or pak::pkg_install) or re-publish corpus."
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,13 @@
|
|||||||
#' target's population at `year` to produce absolute bounds. If `FALSE`,
|
#' target's population at `year` to produce absolute bounds. If `FALSE`,
|
||||||
#' `pop_range` is interpreted as absolute population counts.
|
#' `pop_range` is interpreted as absolute population counts.
|
||||||
#' @param max_peers Integer cap on the number of peers returned.
|
#' @param max_peers Integer cap on the number of peers returned.
|
||||||
|
#' @param coverage Survey-cycle handling; see [cog_peer_compare()]. Here it
|
||||||
|
#' governs the cohort VINTAGE when `year` is `NULL`: `"census"` snaps to the
|
||||||
|
#' most recent census year with an observed population, so a cohort is not
|
||||||
|
#' built from a sample year in which most of the candidate universe is
|
||||||
|
#' absent. `"consistent"` needs a year range, which cohort selection does not
|
||||||
|
#' have, so it selects like `"all"` and is carried on the result as
|
||||||
|
#' `attr(x, "coverage")` for [cog_peer_compare()].
|
||||||
#' @return Tibble with columns `canonical_govid`, `gov_name`, `fips_state`,
|
#' @return Tibble with columns `canonical_govid`, `gov_name`, `fips_state`,
|
||||||
#' `population`, `pop_ratio`, `rank`. The cohort year is attached as
|
#' `population`, `pop_ratio`, `rank`. The cohort year is attached as
|
||||||
#' `attr(x, "cohort_year")`.
|
#' `attr(x, "cohort_year")`.
|
||||||
@@ -29,7 +36,9 @@ cog_find_peers <- function(target_govid,
|
|||||||
same_state = FALSE,
|
same_state = FALSE,
|
||||||
pop_range = c(0.7, 1.3),
|
pop_range = c(0.7, 1.3),
|
||||||
is_ratio = TRUE,
|
is_ratio = TRUE,
|
||||||
max_peers = 10L) {
|
max_peers = 10L,
|
||||||
|
coverage = c("all", "census", "consistent")) {
|
||||||
|
coverage <- .validate_coverage(coverage)
|
||||||
if (!is.character(target_govid) || length(target_govid) != 1L) {
|
if (!is.character(target_govid) || length(target_govid) != 1L) {
|
||||||
cli::cli_abort("`target_govid` must be a length-1 character string.")
|
cli::cli_abort("`target_govid` must be a length-1 character string.")
|
||||||
}
|
}
|
||||||
@@ -59,7 +68,7 @@ cog_find_peers <- function(target_govid,
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
cohort_year <- .resolve_cohort_year(con, target_govid, year)
|
cohort_year <- .resolve_cohort_year(con, target_govid, year, coverage)
|
||||||
|
|
||||||
pop_sql <- sprintf(
|
pop_sql <- sprintf(
|
||||||
"SELECT population FROM gov_population_yearly
|
"SELECT population FROM gov_population_yearly
|
||||||
@@ -107,12 +116,34 @@ cog_find_peers <- function(target_govid,
|
|||||||
attr(peers, "cohort_year") <- as.integer(cohort_year)
|
attr(peers, "cohort_year") <- as.integer(cohort_year)
|
||||||
attr(peers, "pop_range") <- as.numeric(pop_range)
|
attr(peers, "pop_range") <- as.numeric(pop_range)
|
||||||
attr(peers, "is_ratio") <- isTRUE(is_ratio)
|
attr(peers, "is_ratio") <- isTRUE(is_ratio)
|
||||||
|
attr(peers, "coverage") <- coverage
|
||||||
|
attr(peers, "is_census_year") <- .is_census_year(cohort_year)
|
||||||
peers
|
peers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# `coverage` picks the cohort vintage when the caller did not name one.
|
||||||
|
# "census" snaps to the most recent CENSUS year with an observed population,
|
||||||
|
# so a cohort is not silently built from a sample year in which most of the
|
||||||
|
# candidate universe is absent. "consistent" is a comparison-time concept --
|
||||||
|
# it needs a year RANGE, which cohort selection does not have -- so it selects
|
||||||
|
# like "all" here and is carried on the result for cog_peer_compare().
|
||||||
#' @noRd
|
#' @noRd
|
||||||
.resolve_cohort_year <- function(con, target_govid, year) {
|
.resolve_cohort_year <- function(con, target_govid, year,
|
||||||
|
coverage = "all") {
|
||||||
if (!is.null(year)) return(as.integer(year))
|
if (!is.null(year)) return(as.integer(year))
|
||||||
|
if (identical(coverage, "census")) {
|
||||||
|
sql <- sprintf(
|
||||||
|
"SELECT MAX(year) AS y FROM gov_population_yearly
|
||||||
|
WHERE canonical_govid = %s AND year %% 10 IN (2, 7)",
|
||||||
|
.sql_lit_chr(target_govid)
|
||||||
|
)
|
||||||
|
y <- DBI::dbGetQuery(con, sql)$y
|
||||||
|
if (length(y) > 0L && !is.na(y)) return(as.integer(y))
|
||||||
|
cli::cli_abort(c(
|
||||||
|
"{.code coverage = \"census\"} found no census year with an observed population for {target_govid}.",
|
||||||
|
i = "Pass an explicit {.arg year}, or use {.code coverage = \"all\"}."
|
||||||
|
), class = "uscogdata_no_census_years")
|
||||||
|
}
|
||||||
sql <- sprintf(
|
sql <- sprintf(
|
||||||
"SELECT MAX(year) AS y FROM gov_population_yearly
|
"SELECT MAX(year) AS y FROM gov_population_yearly
|
||||||
WHERE canonical_govid = %s",
|
WHERE canonical_govid = %s",
|
||||||
@@ -133,7 +164,9 @@ cog_find_peers <- function(target_govid,
|
|||||||
#' [cog_find_peers()] result or a character vector of `canonical_govid`) and
|
#' [cog_find_peers()] result or a character vector of `canonical_govid`) and
|
||||||
#' appends peer-distribution summary rows (`summary_p25`, `summary_p50`,
|
#' appends peer-distribution summary rows (`summary_p25`, `summary_p50`,
|
||||||
#' `summary_p75`) so the result can be faceted by `role` in a single ggplot
|
#' `summary_p75`) so the result can be faceted by `role` in a single ggplot
|
||||||
#' call.
|
#' call. Those summary rows are quantiles **within each category**, not
|
||||||
|
#' quantiles of each peer's total — see the `@return` section before summing
|
||||||
|
#' them.
|
||||||
#'
|
#'
|
||||||
#' @param target_govid Character scalar.
|
#' @param target_govid Character scalar.
|
||||||
#' @param peers A tibble from [cog_find_peers()] or a character vector of
|
#' @param peers A tibble from [cog_find_peers()] or a character vector of
|
||||||
@@ -143,6 +176,36 @@ cog_find_peers <- function(target_govid,
|
|||||||
#' @param per_capita Default `TRUE` — peer compare usually normalizes by
|
#' @param per_capita Default `TRUE` — peer compare usually normalizes by
|
||||||
#' population.
|
#' population.
|
||||||
#' @param adjust_to_year Integer base year for CPI-U conversion or `NULL`.
|
#' @param adjust_to_year Integer base year for CPI-U conversion or `NULL`.
|
||||||
|
#' @param expenditure_concept `"primary"` (default), `"direct"`, or
|
||||||
|
#' `"total"` -- see [cog_spending()] for the three concepts. `"total"` is
|
||||||
|
#' refused here because combining Total across peer sets counts
|
||||||
|
#' intergovernmental transfers twice; `"primary"` and `"direct"` combine
|
||||||
|
#' safely.
|
||||||
|
#' @param coverage How to handle the Census of Governments survey cycle,
|
||||||
|
#' which is a **complete census only in years ending in 2 and 7** -- every
|
||||||
|
#' other year is a sample, and the sample varies enormously (on the bundled
|
||||||
|
#' fixture, Wisconsin's 608-city universe reports 597 governments in FY2012
|
||||||
|
#' and 112 in FY2019).
|
||||||
|
#'
|
||||||
|
#' * `"all"` (default) -- every unit that reported that year. Unchanged
|
||||||
|
#' behaviour, so existing code keeps working.
|
||||||
|
#' * `"census"` -- census years only. Aborts if the requested range holds
|
||||||
|
#' none, rather than silently returning nothing.
|
||||||
|
#' * `"consistent"` -- only units reporting in *every* requested year, giving
|
||||||
|
#' a balanced panel.
|
||||||
|
#'
|
||||||
|
#' Regardless of mode, `provenance$coverage` always carries per-year
|
||||||
|
#' `n_units_reporting`, `n_units_expected` and `is_census_year`, and
|
||||||
|
#' `provenance$coverage_mode` records the mode. `is_census_year` is a
|
||||||
|
#' statement about the **survey calendar**, never a claim of completeness:
|
||||||
|
#' FY1967 is a census year in which only 97 of Wisconsin's 608 cities
|
||||||
|
#' report. `n_units_reporting` is the number that tells the truth.
|
||||||
|
#'
|
||||||
|
#' The comparison target is exempt from `"consistent"` balancing -- it is the
|
||||||
|
#' subject of the comparison, not a member of the cohort -- and the
|
||||||
|
#' `summary_*` quantiles are computed AFTER the filter, so they describe the
|
||||||
|
#' cohort actually returned. `n_units_reporting` counts peers only, against
|
||||||
|
#' the cohort size: "3 of your 15 peers reported in FY2019".
|
||||||
#' @return Tibble matching [cog_spending()]'s columns, plus a `role`
|
#' @return Tibble matching [cog_spending()]'s columns, plus a `role`
|
||||||
#' column taking values `"target"`, `"peer"`, `"summary_p25"`,
|
#' column taking values `"target"`, `"peer"`, `"summary_p25"`,
|
||||||
#' `"summary_p50"`, or `"summary_p75"`, `target_rank` (target's rank
|
#' `"summary_p50"`, or `"summary_p75"`, `target_rank` (target's rank
|
||||||
@@ -151,10 +214,43 @@ cog_find_peers <- function(target_govid,
|
|||||||
#' `attr(peers, "cohort_year")`; `NA` when `peers` was a bare character
|
#' `attr(peers, "cohort_year")`; `NA` when `peers` was a bare character
|
||||||
#' vector). Provenance reports `verb = "cog_peer_compare"`, `peer_count`,
|
#' vector). Provenance reports `verb = "cog_peer_compare"`, `peer_count`,
|
||||||
#' `cohort_year`, and `cohort_govids`.
|
#' `cohort_year`, and `cohort_govids`.
|
||||||
|
#'
|
||||||
|
#' **The `summary_*` rows are per-category quantiles: they are not additive.**
|
||||||
|
#' Each one is computed **within each `(year, spend_subtype,
|
||||||
|
#' category)` cell** across the peer set, so a `summary_p50` row is *the
|
||||||
|
#' median peer's value in that one category*, not *the value of the median
|
||||||
|
#' peer's total*. The median peer for Police and the median peer for Fire
|
||||||
|
#' are usually different governments, so summing `summary_*` rows across
|
||||||
|
#' categories does not give any peer's total and misstates the band it
|
||||||
|
#' appears to describe — measured at −32.7% to +251.0% across 24 years on
|
||||||
|
#' one cohort, with a sign flip at FY2012.
|
||||||
|
#'
|
||||||
|
#' Facet by `role` **and** `category` (the documented use, and what the
|
||||||
|
#' rows are built for). For a genuine "median peer's total spending" line,
|
||||||
|
#' sum each peer's own categories first and take the quantile of those
|
||||||
|
#' per-government totals:
|
||||||
|
#'
|
||||||
|
#' ```r
|
||||||
|
#' library(dplyr)
|
||||||
|
#' cmp |>
|
||||||
|
#' filter(role %in% c("target", "peer")) |>
|
||||||
|
#' group_by(year, role, canonical_govid) |>
|
||||||
|
#' summarise(total = sum(amt_per_capita_real, na.rm = TRUE), .groups = "drop") |>
|
||||||
|
#' filter(role == "peer") |>
|
||||||
|
#' group_by(year) |>
|
||||||
|
#' summarise(p50 = quantile(total, 0.5, na.rm = TRUE))
|
||||||
|
#' ```
|
||||||
#' @export
|
#' @export
|
||||||
cog_peer_compare <- function(target_govid, peers, category, years,
|
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("primary", "direct", "total"),
|
||||||
|
coverage = c("all", "census", "consistent")) {
|
||||||
call <- match.call()
|
call <- match.call()
|
||||||
|
expenditure_concept <- match.arg(expenditure_concept)
|
||||||
|
coverage <- .validate_coverage(coverage)
|
||||||
|
if (identical(expenditure_concept, "total")) {
|
||||||
|
.abort_concept_not_aggregatable("cog_peer_compare")
|
||||||
|
}
|
||||||
if (!is.character(target_govid) || length(target_govid) != 1L) {
|
if (!is.character(target_govid) || length(target_govid) != 1L) {
|
||||||
cli::cli_abort("`target_govid` must be a length-1 character string.")
|
cli::cli_abort("`target_govid` must be a length-1 character string.")
|
||||||
}
|
}
|
||||||
@@ -174,9 +270,21 @@ cog_peer_compare <- function(target_govid, peers, category, years,
|
|||||||
peer_govids <- peer_govids[!is.na(peer_govids) & nzchar(peer_govids)]
|
peer_govids <- peer_govids[!is.na(peer_govids) & nzchar(peer_govids)]
|
||||||
all_govids <- unique(c(target_govid, peer_govids))
|
all_govids <- unique(c(target_govid, peer_govids))
|
||||||
|
|
||||||
r <- cog_spending(all_govids, years, category, per_capita, adjust_to_year)
|
years <- .apply_census_years(years, coverage, "cog_peer_compare")
|
||||||
|
|
||||||
|
r <- cog_spending(all_govids, years, category, per_capita, adjust_to_year,
|
||||||
|
expenditure_concept = expenditure_concept)
|
||||||
r$role <- ifelse(r$canonical_govid == target_govid, "target", "peer")
|
r$role <- ifelse(r$canonical_govid == target_govid, "target", "peer")
|
||||||
|
|
||||||
|
# The target is exempt from balancing: it is the subject of the comparison,
|
||||||
|
# not a member of the cohort being balanced, and dropping it would leave a
|
||||||
|
# peer comparison with nothing to compare. Filtering happens BEFORE the
|
||||||
|
# quantiles below, so a "consistent" cohort's summary rows describe that
|
||||||
|
# cohort rather than the unbalanced one.
|
||||||
|
if (identical(coverage, "consistent")) {
|
||||||
|
r <- .filter_consistent(r, years, keep_ids = target_govid)
|
||||||
|
}
|
||||||
|
|
||||||
value_col <- .peer_value_col(per_capita, adjust_to_year)
|
value_col <- .peer_value_col(per_capita, adjust_to_year)
|
||||||
|
|
||||||
summary_rows <- .peer_summary_rows(r, value_col)
|
summary_rows <- .peer_summary_rows(r, value_col)
|
||||||
@@ -197,6 +305,14 @@ cog_peer_compare <- function(target_govid, peers, category, years,
|
|||||||
canonical_govid = target_govid,
|
canonical_govid = target_govid,
|
||||||
gov_name = unique(r$gov_name[r$role == "target"])
|
gov_name = unique(r$gov_name[r$role == "target"])
|
||||||
)
|
)
|
||||||
|
# Counted over PEER rows only, against the cohort size: "3 of your 15 peers
|
||||||
|
# reported in FY2019". Including the target would inflate every count by one
|
||||||
|
# and make a cohort that has entirely stopped reporting look non-empty.
|
||||||
|
prov$coverage_mode <- coverage
|
||||||
|
prov$coverage <- .coverage_table(
|
||||||
|
out, years, length(peer_govids),
|
||||||
|
rows = r[r$role == "peer", , drop = FALSE]
|
||||||
|
)
|
||||||
attr(out, "provenance") <- prov
|
attr(out, "provenance") <- prov
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-2
@@ -4,7 +4,15 @@
|
|||||||
#' @noRd
|
#' @noRd
|
||||||
.build_provenance <- function(verb, call, govid, years, category,
|
.build_provenance <- function(verb, call, govid, years, category,
|
||||||
per_capita, adjust_to_year, result, sql,
|
per_capita, adjust_to_year, result, sql,
|
||||||
subtype_col) {
|
subtype_col, basis = NA_character_,
|
||||||
|
basis_note = NA_character_,
|
||||||
|
expenditure_concept = "primary",
|
||||||
|
expenditure_concept_note = NA_character_,
|
||||||
|
expenditure_concept_direct_suppressed = FALSE,
|
||||||
|
revenue_concept = "general",
|
||||||
|
harmonization = NULL, recipe = NULL,
|
||||||
|
suggestions = list(),
|
||||||
|
completion = NULL) {
|
||||||
manifest <- .uscogdata_env$manifest
|
manifest <- .uscogdata_env$manifest
|
||||||
|
|
||||||
codes <- result[["codes_included"]]
|
codes <- result[["codes_included"]]
|
||||||
@@ -29,6 +37,23 @@
|
|||||||
unique(result$gov_name)
|
unique(result$gov_name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
schema_version <- suppressWarnings(as.integer(manifest$schema_version %||% 0L))
|
||||||
|
con <- .uscogdata_env$con
|
||||||
|
have_con <- !is.null(con) && DBI::dbIsValid(con)
|
||||||
|
break_refs <- if (have_con) {
|
||||||
|
.build_series_break_refs(con, codes_observed, years, schema_version)
|
||||||
|
} else {
|
||||||
|
character(0)
|
||||||
|
}
|
||||||
|
# Corpus-wide caveats travel separately: they qualify the whole result
|
||||||
|
# rather than one series, and they do not depend on codes_observed (see
|
||||||
|
# .build_corpus_break_refs()).
|
||||||
|
corpus_refs <- if (have_con) {
|
||||||
|
.build_corpus_break_refs(con, years, schema_version)
|
||||||
|
} else {
|
||||||
|
character(0)
|
||||||
|
}
|
||||||
|
|
||||||
list(
|
list(
|
||||||
verb = verb,
|
verb = verb,
|
||||||
call = paste(deparse(call), collapse = " "),
|
call = paste(deparse(call), collapse = " "),
|
||||||
@@ -38,6 +63,18 @@
|
|||||||
),
|
),
|
||||||
years = as.integer(years),
|
years = as.integer(years),
|
||||||
category = category,
|
category = category,
|
||||||
|
basis = basis,
|
||||||
|
basis_note = basis_note,
|
||||||
|
expenditure_concept = expenditure_concept,
|
||||||
|
expenditure_concept_note = expenditure_concept_note,
|
||||||
|
expenditure_concept_direct_suppressed = isTRUE(expenditure_concept_direct_suppressed),
|
||||||
|
revenue_concept = revenue_concept,
|
||||||
|
harmonization = harmonization %||% list(
|
||||||
|
applied = FALSE, na_rows_excluded = 0L, na_amount_excluded = 0,
|
||||||
|
note = NA_character_
|
||||||
|
),
|
||||||
|
recipe = recipe,
|
||||||
|
suggestions = suggestions,
|
||||||
scope = list(
|
scope = list(
|
||||||
gov_types_included = as.integer(unlist(manifest$scope$gov_types_included)),
|
gov_types_included = as.integer(unlist(manifest$scope$gov_types_included)),
|
||||||
gov_types_excluded = as.integer(unlist(manifest$scope$gov_types_excluded)),
|
gov_types_excluded = as.integer(unlist(manifest$scope$gov_types_excluded)),
|
||||||
@@ -90,7 +127,15 @@
|
|||||||
index = if (is.null(adjust_to_year)) NA_character_ else "CPI-U (BLS CPIAUCSL annual average, bundled)"
|
index = if (is.null(adjust_to_year)) NA_character_ else "CPI-U (BLS CPIAUCSL annual average, bundled)"
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
series_break_refs = character(0),
|
series_break_refs = break_refs,
|
||||||
|
corpus_break_refs = corpus_refs,
|
||||||
|
# What `complete = TRUE` filled, and the rule it filled by. Always
|
||||||
|
# present so a consumer can read `completion$applied` without testing
|
||||||
|
# for the key -- an absent block and applied = FALSE would otherwise be
|
||||||
|
# indistinguishable from an older reader version.
|
||||||
|
completion = completion %||% list(
|
||||||
|
applied = FALSE, rows_filled = 0L, absence_means = list()
|
||||||
|
),
|
||||||
manifest = list(
|
manifest = list(
|
||||||
schema_version = as.integer(manifest$schema_version),
|
schema_version = as.integer(manifest$schema_version),
|
||||||
pipeline_commit = manifest$pipeline_commit %||% NA_character_,
|
pipeline_commit = manifest$pipeline_commit %||% NA_character_,
|
||||||
|
|||||||
+167
@@ -0,0 +1,167 @@
|
|||||||
|
# R/recipes.R
|
||||||
|
# Harmonization recipes: multi-code, cross-vintage series built by summing a
|
||||||
|
# fixed set of component item codes with per-component weights and
|
||||||
|
# year/gov-type scoping (see the `harmonization_recipes` view, registered
|
||||||
|
# from data/harmonization_recipes.parquet, schema_version >= 5 only).
|
||||||
|
#
|
||||||
|
# Recipes exist because some cross-vintage series can't be expressed as a
|
||||||
|
# 1:1 harmonized_code mapping (basis = "harmonized"): the wide era (pre-2012)
|
||||||
|
# publishes only a combined aggregate row for these families (e.g.
|
||||||
|
# corrections functions 04+05), while the modern era splits them into leaf
|
||||||
|
# codes. A recipe's generic join sums whichever of its component codes are
|
||||||
|
# present for a given year, so the resulting series is continuous across
|
||||||
|
# that format boundary.
|
||||||
|
|
||||||
|
#' List available harmonization recipes
|
||||||
|
#'
|
||||||
|
#' Recipes are multi-code cross-vintage series (see [cog_spending()]'s
|
||||||
|
#' `recipe` argument) catalogued in the corpus's `harmonization_recipes`
|
||||||
|
#' table. Use this to discover valid `recipe` ids.
|
||||||
|
#'
|
||||||
|
#' @param pattern Optional regex matched case-insensitively against
|
||||||
|
#' `recipe_id` or `label`.
|
||||||
|
#' @return Tibble with columns `recipe_id`, `label`, `n_components`,
|
||||||
|
#' `year_min`, `year_max` (the min/max component year coverage), sorted by
|
||||||
|
#' `recipe_id`.
|
||||||
|
#' @export
|
||||||
|
cog_recipes <- function(pattern = NULL) {
|
||||||
|
if (!is.null(pattern) &&
|
||||||
|
(!is.character(pattern) || length(pattern) != 1L)) {
|
||||||
|
cli::cli_abort("`pattern` must be a length-1 character string or NULL.")
|
||||||
|
}
|
||||||
|
con <- .ensure_session()
|
||||||
|
.require_schema_v5(con, .uscogdata_env$manifest, "cog_recipes()")
|
||||||
|
|
||||||
|
where <- if (is.null(pattern)) {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
sprintf(
|
||||||
|
"WHERE regexp_matches(recipe_id, %1$s, 'i') OR regexp_matches(label, %1$s, 'i')",
|
||||||
|
.sql_lit_chr(pattern)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sql <- paste(
|
||||||
|
"SELECT recipe_id, any_value(label) AS label,
|
||||||
|
COUNT(*) AS n_components,
|
||||||
|
MIN(year_min) AS year_min, MAX(year_max) AS year_max
|
||||||
|
FROM harmonization_recipes",
|
||||||
|
where,
|
||||||
|
"GROUP BY recipe_id
|
||||||
|
ORDER BY recipe_id"
|
||||||
|
)
|
||||||
|
out <- tibble::as_tibble(DBI::dbGetQuery(con, sql))
|
||||||
|
out$year_min <- as.integer(out$year_min)
|
||||||
|
out$year_max <- as.integer(out$year_max)
|
||||||
|
out$n_components <- as.integer(out$n_components)
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#' Abort unless the active corpus has schema_version >= 5.
|
||||||
|
#' @noRd
|
||||||
|
.require_schema_v5 <- function(con, manifest, what) {
|
||||||
|
sv <- suppressWarnings(as.integer(manifest$schema_version %||% 0L))
|
||||||
|
if (sv < 5L) {
|
||||||
|
cli::cli_abort(c(
|
||||||
|
sprintf("%s requires corpus schema_version >= 5.", what),
|
||||||
|
x = "Active corpus has schema_version {sv}.",
|
||||||
|
i = "Point USCOGDATA_URL at a schema_version >= 5 corpus to use harmonization recipes."
|
||||||
|
), class = "uscogdata_schema_unsupported")
|
||||||
|
}
|
||||||
|
invisible(sv)
|
||||||
|
}
|
||||||
|
|
||||||
|
#' Abort with the valid id list unless `recipe_id` exists in the catalog.
|
||||||
|
#' @noRd
|
||||||
|
.validate_recipe_id <- function(con, recipe_id) {
|
||||||
|
ids <- DBI::dbGetQuery(
|
||||||
|
con, "SELECT DISTINCT recipe_id FROM harmonization_recipes"
|
||||||
|
)$recipe_id
|
||||||
|
if (!recipe_id %in% ids) {
|
||||||
|
cli::cli_abort(c(
|
||||||
|
"Unknown recipe = {.val {recipe_id}}.",
|
||||||
|
i = "Valid ids: {paste(sort(ids), collapse = ', ')}",
|
||||||
|
i = "See cog_recipes() for labels and year coverage."
|
||||||
|
), class = "uscogdata_unknown_recipe")
|
||||||
|
}
|
||||||
|
invisible(TRUE)
|
||||||
|
}
|
||||||
|
|
||||||
|
#' Fetch the component rows for one recipe (label, component codes, scope,
|
||||||
|
#' year ranges, weights) -- both for running the recipe and for the
|
||||||
|
#' `recipe` provenance block.
|
||||||
|
#' @noRd
|
||||||
|
.recipe_components <- function(con, recipe_id) {
|
||||||
|
sql <- sprintf(
|
||||||
|
"SELECT recipe_id, label, component_code, gov_type_scope,
|
||||||
|
year_min, year_max, weight, source_break_ids, notes
|
||||||
|
FROM harmonization_recipes
|
||||||
|
WHERE recipe_id = %s
|
||||||
|
ORDER BY component_code",
|
||||||
|
.sql_lit_chr(recipe_id)
|
||||||
|
)
|
||||||
|
tibble::as_tibble(DBI::dbGetQuery(con, sql))
|
||||||
|
}
|
||||||
|
|
||||||
|
#' Run a recipe's generic join: sum `amt * weight` across whichever
|
||||||
|
#' component codes are present for each (year, canonical_govid), scoped by
|
||||||
|
#' gov_type_scope. Deliberately does NOT filter `NOT is_aggregate`: in the
|
||||||
|
#' wide era (<= 2011) these families' component codes exist ONLY as
|
||||||
|
#' aggregate rows (leaves first appear 2012), so excluding aggregates would
|
||||||
|
#' zero out the wide-era half of every recipe. This is safe by corpus
|
||||||
|
#' construction -- wide-era rows for these codes are aggregate-only, modern
|
||||||
|
#' rows are leaf-only, and every component row is year-scoped via
|
||||||
|
#' `year_min`/`year_max` -- so there is no double-counting. (Checkpoint
|
||||||
|
#' review docs/phase_r_harmonization_review.md § 0.2.)
|
||||||
|
#' @noRd
|
||||||
|
.run_recipe <- function(con, recipe_id, govid, years) {
|
||||||
|
sql <- sprintf(
|
||||||
|
"SELECT l.year, l.canonical_govid,
|
||||||
|
COALESCE(x.gov_name, l.gov_name) AS gov_name,
|
||||||
|
SUM(l.amt * r.weight) * 1000.0 AS amt_nominal,
|
||||||
|
string_agg(DISTINCT l.item_code, ',' ORDER BY l.item_code) AS codes_included
|
||||||
|
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))
|
||||||
|
LEFT JOIN canonical_fips_xwalk x USING (canonical_govid)
|
||||||
|
WHERE r.recipe_id = %1$s
|
||||||
|
AND l.canonical_govid IN (%2$s)
|
||||||
|
AND l.year IN (%3$s)
|
||||||
|
GROUP BY 1, 2, 3
|
||||||
|
ORDER BY 1, 2",
|
||||||
|
.sql_lit_chr(recipe_id), .sql_lit_chr(govid),
|
||||||
|
paste(as.integer(years), collapse = ",")
|
||||||
|
)
|
||||||
|
result <- tibble::as_tibble(DBI::dbGetQuery(con, sql))
|
||||||
|
attr(result, "sql_query") <- sql
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#' Shape a raw .run_recipe() result into the standard cog_spending()/
|
||||||
|
#' cog_revenue() column layout: subtype = "recipe", category = the recipe's
|
||||||
|
#' label, aggregate_fallback = FALSE (recipes resolve coverage gaps by
|
||||||
|
#' construction, not by falling back to an aggregate row).
|
||||||
|
#' @noRd
|
||||||
|
.shape_recipe_result <- function(result, subtype_col, label) {
|
||||||
|
sql_query <- attr(result, "sql_query")
|
||||||
|
n <- nrow(result)
|
||||||
|
result[[subtype_col]] <- rep("recipe", n)
|
||||||
|
result$category <- rep(label, n)
|
||||||
|
result$aggregate_fallback <- rep(FALSE, n)
|
||||||
|
result <- result[, c(
|
||||||
|
"year", "canonical_govid", "gov_name", subtype_col, "category",
|
||||||
|
"amt_nominal", "codes_included", "aggregate_fallback"
|
||||||
|
), drop = FALSE]
|
||||||
|
attr(result, "sql_query") <- sql_query
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#' Turn a small data.frame into a list-of-lists (one list per row), the
|
||||||
|
#' shape used for the `recipe$components` provenance block.
|
||||||
|
#' @noRd
|
||||||
|
.df_to_row_list <- function(df) {
|
||||||
|
lapply(seq_len(nrow(df)), function(i) as.list(df[i, , drop = FALSE]))
|
||||||
|
}
|
||||||
+42
-4
@@ -8,22 +8,60 @@
|
|||||||
#' multiplies by 1000 and records the conversion in `provenance`).
|
#' multiplies by 1000 and records the conversion in `provenance`).
|
||||||
#'
|
#'
|
||||||
#' @inheritParams cog_spending
|
#' @inheritParams cog_spending
|
||||||
|
#' @param revenue_concept Which of Census's two published revenue concepts to
|
||||||
|
#' return. Concepts are defined as sets of the crosswalk's `revenue_subtype`
|
||||||
|
#' values -- never as item-code first letters, which cannot classify
|
||||||
|
#' correctly (prefix `Y` spans revenue, expenditure and balance codes, and
|
||||||
|
#' prefix `X` does the same):
|
||||||
|
#'
|
||||||
|
#' * `"general"` (default) -- Census General Revenue: `own_source` +
|
||||||
|
#' `federal` + `state` + `local_aid`. The manual defines this concept by
|
||||||
|
#' subtraction (section 4.3: *"General revenue comprises all revenue
|
||||||
|
#' except that classified as liquor store, utility, or insurance trust
|
||||||
|
#' revenue"*), so utility (`A91`-`A94`), liquor store (`A90`) and
|
||||||
|
#' insurance trust revenue are all excluded.
|
||||||
|
#' * `"total"` -- Census Total Revenue: every revenue subtype, i.e.
|
||||||
|
#' `general` plus utility, liquor store, and insurance trust revenue
|
||||||
|
#' (`Y01`/`Y02`/`Y04`/`Y11`/`Y12`/`Y51`/`Y52` and the employee-retirement
|
||||||
|
#' `X01`/`X02`/`X05`/`X08`).
|
||||||
|
#'
|
||||||
|
#' The two are related by Census's own identity, `Total Revenue = General +
|
||||||
|
#' Utility + Liquor Store + Insurance Trust`.
|
||||||
|
#'
|
||||||
|
#' Note that the employee-retirement (`X`) codes stop at FY2016, when those
|
||||||
|
#' systems moved out of the annual finance file into the separate Annual
|
||||||
|
#' Survey of Public Pensions, so a `"total"` series steps down at the
|
||||||
|
#' FY2016/FY2017 seam for reasons that are about collection scope rather
|
||||||
|
#' than revenue (series breaks `SB197`-`SB202`).
|
||||||
#' @return Tibble with columns `year`, `canonical_govid`, `gov_name`,
|
#' @return Tibble with columns `year`, `canonical_govid`, `gov_name`,
|
||||||
#' `revenue_subtype`, `category`, `amt_nominal`, optional `amt_real`,
|
#' `revenue_subtype`, `category`, `amt_nominal`, optional `amt_real`,
|
||||||
#' optional `amt_per_capita_nominal`, optional `amt_per_capita_real`,
|
#' optional `amt_per_capita_nominal`, optional `amt_per_capita_real`,
|
||||||
#' optional `pop_source`, `codes_included`, `aggregate_fallback`, `notes`.
|
#' optional `pop_source`, `codes_included`, `aggregate_fallback`, `notes`,
|
||||||
|
#' and `value_source` when `complete = TRUE`.
|
||||||
#' @export
|
#' @export
|
||||||
cog_revenue <- function(govid, years, category = NULL,
|
cog_revenue <- function(govid, years, category = NULL,
|
||||||
per_capita = FALSE, adjust_to_year = NULL) {
|
per_capita = FALSE, adjust_to_year = NULL,
|
||||||
|
basis = c("harmonized", "raw"), recipe = NULL,
|
||||||
|
revenue_concept = c("general", "total"),
|
||||||
|
complete = FALSE) {
|
||||||
|
# flow_prefixes no longer classifies rows (crosswalk revenue_subtype
|
||||||
|
# membership does -- General Revenue, i.e. everything except
|
||||||
|
# insurance_trust) -- it only scopes the recipe-suggestion machinery to
|
||||||
|
# this verb's recipe families (see R/suggestions.R).
|
||||||
.verb_spendrev(
|
.verb_spendrev(
|
||||||
verb = "cog_revenue",
|
verb = "cog_revenue",
|
||||||
view = "revenue_annotated",
|
view_base = "revenue_annotated",
|
||||||
subtype_col = "revenue_subtype",
|
subtype_col = "revenue_subtype",
|
||||||
|
flow_prefixes = c("T", "A", "U", "B", "C", "D"),
|
||||||
call = match.call(),
|
call = match.call(),
|
||||||
govid = govid,
|
govid = govid,
|
||||||
years = years,
|
years = years,
|
||||||
category = category,
|
category = category,
|
||||||
per_capita = per_capita,
|
per_capita = per_capita,
|
||||||
adjust_to_year = adjust_to_year
|
adjust_to_year = adjust_to_year,
|
||||||
|
basis = basis,
|
||||||
|
recipe = recipe,
|
||||||
|
revenue_concept = revenue_concept,
|
||||||
|
complete = complete
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-2
@@ -25,6 +25,31 @@
|
|||||||
#' population from `gov_population_yearly`. Govs with missing population
|
#' population from `gov_population_yearly`. Govs with missing population
|
||||||
#' are excluded from the result.
|
#' are excluded from the result.
|
||||||
#' @param adjust_to_year Integer base year for CPI-U conversion, or `NULL`.
|
#' @param adjust_to_year Integer base year for CPI-U conversion, or `NULL`.
|
||||||
|
#' @param expenditure_concept `"primary"` (default), `"direct"`, or
|
||||||
|
#' `"total"` -- see [cog_spending()] for the three concepts. `"total"` is
|
||||||
|
#' refused 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); `"primary"` and `"direct"` combine safely.
|
||||||
|
#' @param coverage How to handle the Census of Governments survey cycle,
|
||||||
|
#' which is a **complete census only in years ending in 2 and 7** -- every
|
||||||
|
#' other year is a sample, and the sample varies enormously (on the bundled
|
||||||
|
#' fixture, Wisconsin's 608-city universe reports 597 governments in FY2012
|
||||||
|
#' and 112 in FY2019).
|
||||||
|
#'
|
||||||
|
#' * `"all"` (default) -- every unit that reported that year. Unchanged
|
||||||
|
#' behaviour, so existing code keeps working.
|
||||||
|
#' * `"census"` -- census years only. Aborts if the requested range holds
|
||||||
|
#' none, rather than silently returning nothing.
|
||||||
|
#' * `"consistent"` -- only units reporting in *every* requested year, giving
|
||||||
|
#' a balanced panel.
|
||||||
|
#'
|
||||||
|
#' Regardless of mode, `provenance$coverage` always carries per-year
|
||||||
|
#' `n_units_reporting`, `n_units_expected` and `is_census_year`, and
|
||||||
|
#' `provenance$coverage_mode` records the mode. `is_census_year` is a
|
||||||
|
#' statement about the **survey calendar**, never a claim of completeness:
|
||||||
|
#' FY1967 is a census year in which only 97 of Wisconsin's 608 cities
|
||||||
|
#' report. `n_units_reporting` is the number that tells the truth.
|
||||||
#' @return Tibble with columns `year`, `layer`, `canonical_govid`, `gov_name`,
|
#' @return Tibble with columns `year`, `layer`, `canonical_govid`, `gov_name`,
|
||||||
#' `spend_subtype`, `category`, `amt_nominal`, optional `amt_real` /
|
#' `spend_subtype`, `category`, `amt_nominal`, optional `amt_real` /
|
||||||
#' `amt_per_capita_nominal` / `amt_per_capita_real`, optional `pop_source`,
|
#' `amt_per_capita_nominal` / `amt_per_capita_real`, optional `pop_source`,
|
||||||
@@ -33,8 +58,15 @@
|
|||||||
#' and `rollup$included_govids` / `rollup$excluded_govids`.
|
#' and `rollup$included_govids` / `rollup$excluded_govids`.
|
||||||
#' @export
|
#' @export
|
||||||
cog_geographic_rollup <- function(govids, category, years,
|
cog_geographic_rollup <- function(govids, category, years,
|
||||||
per_capita = FALSE, adjust_to_year = NULL) {
|
per_capita = FALSE, adjust_to_year = NULL,
|
||||||
|
expenditure_concept = c("primary", "direct", "total"),
|
||||||
|
coverage = c("all", "census", "consistent")) {
|
||||||
call <- match.call()
|
call <- match.call()
|
||||||
|
expenditure_concept <- match.arg(expenditure_concept)
|
||||||
|
coverage <- .validate_coverage(coverage)
|
||||||
|
if (identical(expenditure_concept, "total")) {
|
||||||
|
.abort_concept_not_aggregatable("cog_geographic_rollup")
|
||||||
|
}
|
||||||
.validate_rollup_layers(govids)
|
.validate_rollup_layers(govids)
|
||||||
|
|
||||||
govids <- lapply(govids, .coerce_govid_input, arg = "govids[[layer]]")
|
govids <- lapply(govids, .coerce_govid_input, arg = "govids[[layer]]")
|
||||||
@@ -48,11 +80,21 @@ cog_geographic_rollup <- function(govids, category, years,
|
|||||||
layer = rep(layer_names, lengths(govids))
|
layer = rep(layer_names, lengths(govids))
|
||||||
)
|
)
|
||||||
|
|
||||||
r <- cog_spending(all_govids, years, category, per_capita, adjust_to_year)
|
# coverage = "census" drops non-census years BEFORE the query rather than
|
||||||
|
# after: a sample year's rows are not wanted at all, and fetching them only
|
||||||
|
# to discard them would also let them into the coverage table.
|
||||||
|
years <- .apply_census_years(years, coverage, "cog_geographic_rollup")
|
||||||
|
|
||||||
|
r <- cog_spending(all_govids, years, category, per_capita, adjust_to_year,
|
||||||
|
expenditure_concept = expenditure_concept)
|
||||||
r <- dplyr::left_join(r, layer_map, by = "canonical_govid",
|
r <- dplyr::left_join(r, layer_map, by = "canonical_govid",
|
||||||
relationship = "many-to-many")
|
relationship = "many-to-many")
|
||||||
r$scope_note <- .rollup_scope_note(r$layer)
|
r$scope_note <- .rollup_scope_note(r$layer)
|
||||||
|
|
||||||
|
if (identical(coverage, "consistent")) {
|
||||||
|
r <- .filter_consistent(r, years)
|
||||||
|
}
|
||||||
|
|
||||||
excluded <- character(0)
|
excluded <- character(0)
|
||||||
if (isTRUE(per_capita) && "pop_source" %in% names(r)) {
|
if (isTRUE(per_capita) && "pop_source" %in% names(r)) {
|
||||||
drop <- r$pop_source == "unavailable"
|
drop <- r$pop_source == "unavailable"
|
||||||
@@ -71,6 +113,11 @@ cog_geographic_rollup <- function(govids, category, years,
|
|||||||
included_govids = included,
|
included_govids = included,
|
||||||
excluded_govids = excluded
|
excluded_govids = excluded
|
||||||
)
|
)
|
||||||
|
# n_units_expected is the universe the CALLER named -- the govids passed in
|
||||||
|
# -- not the national universe. That is what makes the ratio meaningful:
|
||||||
|
# "597 of the 608 Wisconsin cities you asked about reported in FY2012".
|
||||||
|
prov$coverage_mode <- coverage
|
||||||
|
prov$coverage <- .coverage_table(r, years, length(unique(all_govids)))
|
||||||
attr(r, "provenance") <- prov
|
attr(r, "provenance") <- prov
|
||||||
|
|
||||||
r
|
r
|
||||||
|
|||||||
+19
-7
@@ -6,8 +6,11 @@
|
|||||||
#' the cross-vintage canonical-government registry. Operates in two modes:
|
#' the cross-vintage canonical-government registry. Operates in two modes:
|
||||||
#'
|
#'
|
||||||
#' * **Utility mode** (single `name`, the original behavior): returns all
|
#' * **Utility mode** (single `name`, the original behavior): returns all
|
||||||
#' rows whose `gov_name` matches the regex case-insensitively, sorted by
|
#' rows whose `gov_name` contains `name` as a **literal, case-insensitive
|
||||||
#' `population_acs` descending. Useful for exploratory lookups.
|
#' substring**, sorted by `population_acs` descending. Useful for
|
||||||
|
#' exploratory lookups. Regex metacharacters in `name` are escaped, so a
|
||||||
|
#' government is findable by its own complete name even when that name
|
||||||
|
#' contains parentheses or a period.
|
||||||
#' * **Basket mode** (`length(name) > 1`): resolves each input row to a
|
#' * **Basket mode** (`length(name) > 1`): resolves each input row to a
|
||||||
#' single canonical govid and returns a tibble in input order, suitable
|
#' single canonical govid and returns a tibble in input order, suitable
|
||||||
#' for piping straight into [cog_spending()] / [cog_revenue()] /
|
#' for piping straight into [cog_spending()] / [cog_revenue()] /
|
||||||
@@ -19,7 +22,8 @@
|
|||||||
#' 1. Filter `canonical_fips_xwalk` by `state` and (if non-NA) `type`.
|
#' 1. Filter `canonical_fips_xwalk` by `state` and (if non-NA) `type`.
|
||||||
#' 2. **Exact pass:** case-insensitive equality against `gov_name`.
|
#' 2. **Exact pass:** case-insensitive equality against `gov_name`.
|
||||||
#' Single hit -> resolved. Multiple -> step 4.
|
#' Single hit -> resolved. Multiple -> step 4.
|
||||||
#' 3. **Substring fallback:** case-insensitive regex against `gov_name`.
|
#' 3. **Substring fallback:** case-insensitive literal substring against
|
||||||
|
#' `gov_name` (metacharacters escaped).
|
||||||
#' Single hit -> resolved (`match_method = "substring"`). Zero hits ->
|
#' Single hit -> resolved (`match_method = "substring"`). Zero hits ->
|
||||||
#' `status = "no_match"`. Multiple hits -> step 4.
|
#' `status = "no_match"`. Multiple hits -> step 4.
|
||||||
#' 4. **Disambiguation:** if matches share one `govs_type`, pick the
|
#' 4. **Disambiguation:** if matches share one `govs_type`, pick the
|
||||||
@@ -48,7 +52,7 @@
|
|||||||
#' [cog_spending()], [cog_revenue()].
|
#' [cog_spending()], [cog_revenue()].
|
||||||
#' @examples
|
#' @examples
|
||||||
#' \dontrun{
|
#' \dontrun{
|
||||||
#' # Utility mode — exploratory regex lookup
|
#' # Utility mode — exploratory substring lookup
|
||||||
#' cog_gov_search("broward", state = "FL")
|
#' cog_gov_search("broward", state = "FL")
|
||||||
#'
|
#'
|
||||||
#' # Basket mode — resolve a known cohort
|
#' # Basket mode — resolve a known cohort
|
||||||
@@ -98,9 +102,16 @@ cog_gov_search <- function(name = NULL, state = NULL, type = NULL) {
|
|||||||
if (!is.character(name) || length(name) != 1L) {
|
if (!is.character(name) || length(name) != 1L) {
|
||||||
cli::cli_abort("`name` must be a length-1 character string.")
|
cli::cli_abort("`name` must be a length-1 character string.")
|
||||||
}
|
}
|
||||||
|
# Escaped, so `name` is a literal case-insensitive substring -- the same
|
||||||
|
# treatment basket mode has always given it. Interpolating it raw made a
|
||||||
|
# government unfindable by its own name whenever that name contains a
|
||||||
|
# metacharacter (FREDONIA (BRISCOE) CITY), turned a bare "." into a
|
||||||
|
# match-everything wildcard, and let malformed pattern text reach the
|
||||||
|
# engine as an error -- which cog-api surfaced as a 500, reachable by
|
||||||
|
# typing a real name one character at a time (uscogdata#16, F-025).
|
||||||
preds <- c(preds,
|
preds <- c(preds,
|
||||||
sprintf("regexp_matches(gov_name, %s, 'i')",
|
sprintf("regexp_matches(gov_name, %s, 'i')",
|
||||||
.sql_lit_chr(name)))
|
.sql_lit_chr(.escape_regex(name))))
|
||||||
}
|
}
|
||||||
if (!is.null(state)) {
|
if (!is.null(state)) {
|
||||||
st_fips <- .coerce_state_to_fips(state)
|
st_fips <- .coerce_state_to_fips(state)
|
||||||
@@ -136,8 +147,9 @@ cog_gov_search <- function(name = NULL, state = NULL, type = NULL) {
|
|||||||
#' @noRd
|
#' @noRd
|
||||||
.escape_regex <- function(x) {
|
.escape_regex <- function(x) {
|
||||||
# Backslash-escape POSIX regex metacharacters so `name` is treated as a
|
# Backslash-escape POSIX regex metacharacters so `name` is treated as a
|
||||||
# literal substring in the DuckDB regexp_matches call (substring fallback
|
# literal substring in the DuckDB regexp_matches call. Used by BOTH modes:
|
||||||
# only; utility-mode intentionally preserves regex behavior).
|
# utility mode used to interpolate raw, which was a defect rather than a
|
||||||
|
# feature -- see the call site and uscogdata#16.
|
||||||
gsub("([\\^$.|?*+(){}\\[\\]])", "\\\\\\1", x, perl = TRUE)
|
gsub("([\\^$.|?*+(){}\\[\\]])", "\\\\\\1", x, perl = TRUE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# R/series_breaks.R
|
||||||
|
# Populates prov$series_break_refs (schema in inst/schemas/provenance-v1.json
|
||||||
|
# defines the field; it was always present but always empty pre-Phase-R2)
|
||||||
|
# with the ids of any catalogued series break whose fin_code appears among
|
||||||
|
# the result's observed item codes and whose break_year falls inside the
|
||||||
|
# requested year span -- the "break warnings in the provenance envelope"
|
||||||
|
# spec § 5 promises downstream consumers (cog-api passes provenance through
|
||||||
|
# verbatim). schema_version >= 5 only: series_breaks_pq isn't registered on
|
||||||
|
# an older corpus.
|
||||||
|
|
||||||
|
#' @noRd
|
||||||
|
.build_series_break_refs <- function(con, codes_observed, years, schema_version) {
|
||||||
|
if (schema_version < 5L || length(codes_observed) == 0L) return(character(0))
|
||||||
|
sql <- sprintf(
|
||||||
|
"SELECT DISTINCT break_id
|
||||||
|
FROM series_breaks_pq
|
||||||
|
WHERE fin_code IN (%s) AND fin_code <> 'ALL'
|
||||||
|
AND break_year BETWEEN %d AND %d
|
||||||
|
ORDER BY break_id",
|
||||||
|
.sql_lit_chr(codes_observed), min(as.integer(years)), max(as.integer(years))
|
||||||
|
)
|
||||||
|
DBI::dbGetQuery(con, sql)$break_id
|
||||||
|
}
|
||||||
|
|
||||||
|
#' Corpus-wide caveats: catalogued breaks whose `fin_code` is the literal
|
||||||
|
#' `"ALL"` rather than an item code. They qualify the whole result, so they
|
||||||
|
#' cannot be matched the way `.build_series_break_refs()` matches -- no row's
|
||||||
|
#' `item_code` is ever `"ALL"`, which is exactly why they reached no user
|
||||||
|
#' before uscogdata#19. Selection is on the break_year window alone: which
|
||||||
|
#' codes a result happens to contain is irrelevant to a caveat about the
|
||||||
|
#' corpus.
|
||||||
|
#'
|
||||||
|
#' All four catalogued entries are *boundary* caveats (dollar precision
|
||||||
|
#' across 1976/1977, imputation exclusion from 2002, the dense -> sparse
|
||||||
|
#' representation change at 2012, the id scheme change at 2017), so the same
|
||||||
|
#' `break_year BETWEEN min(years) AND max(years)` rule the code-specific
|
||||||
|
#' path uses is the right one -- a request that never crosses the boundary
|
||||||
|
#' is not affected by it.
|
||||||
|
#'
|
||||||
|
#' Returned separately from `series_break_refs` so a consumer can tell a
|
||||||
|
#' whole-result caveat from a break in one series; the two are disjoint by
|
||||||
|
#' construction.
|
||||||
|
#' @noRd
|
||||||
|
.build_corpus_break_refs <- function(con, years, schema_version) {
|
||||||
|
if (schema_version < 5L || length(years) == 0L) return(character(0))
|
||||||
|
sql <- sprintf(
|
||||||
|
"SELECT DISTINCT break_id
|
||||||
|
FROM series_breaks_pq
|
||||||
|
WHERE fin_code = 'ALL' AND break_year BETWEEN %d AND %d
|
||||||
|
ORDER BY break_id",
|
||||||
|
min(as.integer(years)), max(as.integer(years))
|
||||||
|
)
|
||||||
|
DBI::dbGetQuery(con, sql)$break_id
|
||||||
|
}
|
||||||
+1
-1
@@ -12,7 +12,7 @@ cog_open <- function(url = .resolve_url(),
|
|||||||
DBI::dbExecute(con, "INSTALL httpfs; LOAD httpfs;")
|
DBI::dbExecute(con, "INSTALL httpfs; LOAD httpfs;")
|
||||||
|
|
||||||
manifest <- .fetch_or_cache_manifest(url, cache_dir)
|
manifest <- .fetch_or_cache_manifest(url, cache_dir)
|
||||||
.validate_schema(manifest, expected_version = 4L)
|
.validate_schema(manifest, supported = c(4L, 5L, 6L))
|
||||||
.validate_scope(manifest)
|
.validate_scope(manifest)
|
||||||
|
|
||||||
.register_views(con, url, manifest)
|
.register_views(con, url, manifest)
|
||||||
|
|||||||
+621
-19
@@ -1,5 +1,57 @@
|
|||||||
# R/spending.R
|
# R/spending.R
|
||||||
|
|
||||||
|
# The three expenditure concepts (uscogdata#11), as sets of the crosswalk's
|
||||||
|
# `spend_subtype` values. Classification is crosswalk membership, never
|
||||||
|
# item-code first letters: prefix Y alone spans revenue (Y01/Y02),
|
||||||
|
# expenditure (Y05/Y06) and balance codes, so no first-letter allowlist can
|
||||||
|
# route it (finding F-018).
|
||||||
|
#
|
||||||
|
# primary = operations + capital + assistance (the default)
|
||||||
|
# direct = primary + interest + insurance_benefits (Census Direct Expenditure)
|
||||||
|
# total = direct + intergovernmental (via the ig_* views)
|
||||||
|
#
|
||||||
|
# Census manual section 5.2.2.1: Direct Expenditure is ALL expenditure other
|
||||||
|
# than intergovernmental -- including payments to retirees, i.e. insurance
|
||||||
|
# trust benefits. Verified against Census's own published FY2020 state
|
||||||
|
# aggregates (20statetypepu.txt): `total` reproduces the published
|
||||||
|
# expenditure sum to the dollar; omitting insurance benefits understates
|
||||||
|
# California's Direct by 10.9%.
|
||||||
|
.spend_subtypes_primary <- c("operations", "capital", "assistance")
|
||||||
|
.spend_subtypes_direct <- c(.spend_subtypes_primary, "interest", "insurance_benefits")
|
||||||
|
|
||||||
|
#' @noRd
|
||||||
|
.expenditure_concept_subtypes <- function(concept) {
|
||||||
|
switch(concept,
|
||||||
|
primary = .spend_subtypes_primary,
|
||||||
|
# "total" = the direct subtypes here PLUS the intergovernmental leg,
|
||||||
|
# which travels through the ig_* views rather than this scope (see
|
||||||
|
# .build_verb_sql()).
|
||||||
|
direct = ,
|
||||||
|
total = .spend_subtypes_direct
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
# The two revenue concepts (uscogdata#12), again as crosswalk subtype sets.
|
||||||
|
# Census's manual section 4.3 defines the first by SUBTRACTING from the second
|
||||||
|
# -- "General revenue comprises all revenue except that classified as liquor
|
||||||
|
# store, utility, or insurance trust revenue" -- giving the identity
|
||||||
|
#
|
||||||
|
# Total Revenue = General + Utility + Liquor Store + Insurance Trust
|
||||||
|
#
|
||||||
|
# Verified against Census's own computed concept fields (IndFin FY2012,
|
||||||
|
# Wisconsin state): 31,410,686 + 0 + 0 + 4,469,906 = 35,880,592, exact.
|
||||||
|
.revenue_subtypes_general <- c("own_source", "federal", "state", "local_aid")
|
||||||
|
.revenue_subtypes_total <- c(.revenue_subtypes_general, "utility",
|
||||||
|
"liquor_store", "insurance_trust")
|
||||||
|
|
||||||
|
#' @noRd
|
||||||
|
.revenue_concept_subtypes <- function(concept) {
|
||||||
|
switch(concept,
|
||||||
|
general = .revenue_subtypes_general,
|
||||||
|
total = .revenue_subtypes_total
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#' Summarized spending by category
|
#' Summarized spending by category
|
||||||
#'
|
#'
|
||||||
#' One row per `(year, canonical_govid, spend_subtype, category)`. Amounts are
|
#' One row per `(year, canonical_govid, spend_subtype, category)`. Amounts are
|
||||||
@@ -20,72 +72,409 @@
|
|||||||
#' in that year).
|
#' in that year).
|
||||||
#' @param adjust_to_year Integer base year for CPI-U real-dollar conversion,
|
#' @param adjust_to_year Integer base year for CPI-U real-dollar conversion,
|
||||||
#' or `NULL` for nominal only.
|
#' or `NULL` for nominal only.
|
||||||
|
#' @param basis `"harmonized"` (default) sums item codes through the
|
||||||
|
#' cross-vintage harmonization mapping (folding series-break-affected
|
||||||
|
#' codes onto a comparable target and excluding aggregate / discontinued
|
||||||
|
#' rows -- see the `harmonization` block in `cog_explain()`); `"raw"`
|
||||||
|
#' reproduces the pre-Phase-R2 behavior (published item codes, no
|
||||||
|
#' folding). On a corpus with `schema_version < 5` (no harmonization
|
||||||
|
#' tables), `basis` silently resolves to `"raw"` when left at its default
|
||||||
|
#' and the resolution is recorded in the provenance; explicitly passing
|
||||||
|
#' `basis = "harmonized"` on such a corpus aborts. Ignored when `recipe`
|
||||||
|
#' is set (see below).
|
||||||
|
#' @param recipe Optional harmonization recipe id (see [cog_recipes()]) for
|
||||||
|
#' multi-code cross-vintage series that a 1:1 harmonized_code mapping
|
||||||
|
#' can't express (e.g. a wide-era aggregate that only splits into leaf
|
||||||
|
#' codes in the modern era). Mutually exclusive with `category`. The
|
||||||
|
#' result's subtype column reads `"recipe"` and `category` reads the
|
||||||
|
#' recipe's label. Requires `schema_version >= 5`. A recipe query bypasses
|
||||||
|
#' `basis` entirely (it joins `long` directly rather than going through
|
||||||
|
#' the `*_annotated`/`*_annotated_harmonized` views), so the `basis`
|
||||||
|
#' argument is ignored and the result's provenance reports
|
||||||
|
#' `basis = "recipe"` with an inert `harmonization` block (`applied =
|
||||||
|
#' FALSE`, pointing at the `recipe` block instead) rather than a
|
||||||
|
#' possibly-misleading `"harmonized"`/`"raw"` value.
|
||||||
|
#' @param expenditure_concept Which spending concept to return. Concepts are
|
||||||
|
#' defined as sets of the crosswalk's `spend_subtype` values -- never as
|
||||||
|
#' item-code first letters, which cannot classify correctly (prefix `Y`
|
||||||
|
#' alone spans revenue, expenditure, and balance codes):
|
||||||
|
#'
|
||||||
|
#' * `"primary"` (default) -- the government's own service provision:
|
||||||
|
#' `operations` + `capital` + `assistance` subtypes.
|
||||||
|
#' * `"direct"` -- Census's published Direct Expenditure: `primary` plus
|
||||||
|
#' `interest` (interest on debt) and `insurance_benefits` (insurance
|
||||||
|
#' trust benefit payments, e.g. pensions -- Census manual section
|
||||||
|
#' 5.2.2.1 includes payments to retirees in Direct).
|
||||||
|
#' * `"total"` -- `direct` plus the intergovernmental leg: payments to
|
||||||
|
#' local governments (`M` codes), to the state government (`L` codes,
|
||||||
|
#' excluding the `L--` family-total rollup), and state payments to
|
||||||
|
#' school systems (`Q11`/`Q12`/`Q18`), so results gain rows with
|
||||||
|
#' `spend_subtype == "intergovernmental"`. Requires the active corpus's
|
||||||
|
#' `summary_categories` to carry M/L rows (added by cog_pipeline PR
|
||||||
|
#' #59); aborts with class `uscogdata_ig_categories_unsupported` on an
|
||||||
|
#' older corpus rather than silently under-reporting. Mutually
|
||||||
|
#' exclusive with `recipe` (a recipe already defines its own component
|
||||||
|
#' codes).
|
||||||
|
#'
|
||||||
|
#' **Do not sum `"total"` results across levels of government** (e.g.
|
||||||
|
#' state + county + city): a state's `M12` payment to a school district is
|
||||||
|
#' the same dollar the district reports as its own direct `E12`, so
|
||||||
|
#' summing both double-counts it. This matters in particular with
|
||||||
|
#' [cog_geographic_rollup()], which sums across exactly that kind of
|
||||||
|
#' multi-layer government set.
|
||||||
|
#'
|
||||||
|
#' In the legacy wide era (<= FY2011), some functions are published ONLY
|
||||||
|
#' as an aggregate-flagged family total (e.g. Corrections' `E04`/`E05`
|
||||||
|
#' split), which the Direct leg excludes by construction but the IG leg
|
||||||
|
#' deliberately keeps (see `inst/sql/24-ig_long.sql`). For a `"total"`
|
||||||
|
#' query, any (year, category) where this leaves intergovernmental rows
|
||||||
|
#' with NO Direct counterpart is flagged: the affected rows' `notes`
|
||||||
|
#' name the harmonization recipe that recovers the missing Direct
|
||||||
|
#' component (when one exists), and
|
||||||
|
#' `provenance$expenditure_concept_direct_suppressed` is `TRUE` -- the
|
||||||
|
#' figure in those rows is the intergovernmental leg alone, not Direct +
|
||||||
|
#' IG.
|
||||||
|
#' @param complete If `TRUE`, fill the requested grid so that a cell the
|
||||||
|
#' corpus does not carry still appears, labelled with **why** it is
|
||||||
|
#' missing, and add a `value_source` column to every row:
|
||||||
|
#'
|
||||||
|
#' * `"reported"` — the corpus carries this cell.
|
||||||
|
#' * `"census_zero"` — dense-source year (`<= FY2011`), cell absent:
|
||||||
|
#' Census published `$0`. `amt_nominal` is `0`.
|
||||||
|
#' * `"not_reported"` — sparse-source year (`>= FY2012`), cell absent: the
|
||||||
|
#' government did not report, and the value is unknown. `amt_nominal` is
|
||||||
|
#' `NA`, **not** `0` — writing a zero there would invent data.
|
||||||
|
#'
|
||||||
|
#' The grid comes from the corpus's `code_set` table, scoped to each
|
||||||
|
#' government's own type, so a county is never filled with cells only a
|
||||||
|
#' state can report. Reported rows are passed through untouched.
|
||||||
|
#'
|
||||||
|
#' Defaults to `FALSE` (the historical behaviour: absent cells simply do
|
||||||
|
#' not appear). Needs a corpus published from 2026-07-29 onward, which is
|
||||||
|
#' when `representation`/`code_set` began shipping; aborts with class
|
||||||
|
#' `uscogdata_representation_unavailable` otherwise. Not available with
|
||||||
|
#' `recipe` or with `expenditure_concept = "total"` (class
|
||||||
|
#' `uscogdata_complete_unsupported`) — neither draws its cells from
|
||||||
|
#' `code_set`.
|
||||||
#' @return Tibble with columns `year`, `canonical_govid`, `gov_name`,
|
#' @return Tibble with columns `year`, `canonical_govid`, `gov_name`,
|
||||||
#' `spend_subtype`, `category`, `amt_nominal`, optional `amt_real`,
|
#' `spend_subtype`, `category`, `amt_nominal`, optional `amt_real`,
|
||||||
#' optional `amt_per_capita_nominal`, optional `amt_per_capita_real`,
|
#' optional `amt_per_capita_nominal`, optional `amt_per_capita_real`,
|
||||||
#' optional `pop_source`, `codes_included`, `aggregate_fallback`, `notes`.
|
#' optional `pop_source`, `codes_included`, `aggregate_fallback`, `notes`,
|
||||||
#' Carries a `provenance` attribute matching `inst/schemas/provenance-v1.json`.
|
#' and `value_source` when `complete = TRUE`.
|
||||||
|
#' Carries a `provenance` attribute matching `inst/schemas/provenance-v1.json`,
|
||||||
|
#' whose `completion` block reports `applied`, `rows_filled`, and the
|
||||||
|
#' per-year `absence_means` rule that was applied.
|
||||||
#' @export
|
#' @export
|
||||||
cog_spending <- function(govid, years, category = NULL,
|
cog_spending <- function(govid, years, category = NULL,
|
||||||
per_capita = FALSE, adjust_to_year = NULL) {
|
per_capita = FALSE, adjust_to_year = NULL,
|
||||||
|
basis = c("harmonized", "raw"), recipe = NULL,
|
||||||
|
expenditure_concept = c("primary", "direct", "total"),
|
||||||
|
complete = FALSE) {
|
||||||
|
# flow_prefixes no longer classifies rows (crosswalk subtype membership
|
||||||
|
# does, per expenditure_concept) -- it only scopes the recipe-suggestion
|
||||||
|
# machinery to this verb's recipe families (see R/suggestions.R; the
|
||||||
|
# catalog only has E/F/G-component direct-expenditure recipes).
|
||||||
.verb_spendrev(
|
.verb_spendrev(
|
||||||
verb = "cog_spending",
|
verb = "cog_spending",
|
||||||
view = "spending_annotated",
|
view_base = "spending_annotated",
|
||||||
subtype_col = "spend_subtype",
|
subtype_col = "spend_subtype",
|
||||||
|
flow_prefixes = c("E", "F", "G"),
|
||||||
call = match.call(),
|
call = match.call(),
|
||||||
govid = govid,
|
govid = govid,
|
||||||
years = years,
|
years = years,
|
||||||
category = category,
|
category = category,
|
||||||
per_capita = per_capita,
|
per_capita = per_capita,
|
||||||
adjust_to_year = adjust_to_year
|
adjust_to_year = adjust_to_year,
|
||||||
|
basis = basis,
|
||||||
|
recipe = recipe,
|
||||||
|
expenditure_concept = expenditure_concept,
|
||||||
|
complete = complete
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#' @noRd
|
#' @noRd
|
||||||
.verb_spendrev <- function(verb, view, subtype_col, call,
|
.abort_concept_not_aggregatable <- function(verb) {
|
||||||
|
cli::cli_abort(c(
|
||||||
|
"{.code expenditure_concept = \"total\"} cannot be used in {.fn {verb}}.",
|
||||||
|
"*" = "Use {.code expenditure_concept = \"primary\"} (the default) or \\
|
||||||
|
{.code \"direct\"} 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,
|
govid, years, category,
|
||||||
per_capita, adjust_to_year) {
|
per_capita, adjust_to_year,
|
||||||
|
basis = c("harmonized", "raw"), recipe = NULL,
|
||||||
|
expenditure_concept = c("primary", "direct", "total"),
|
||||||
|
revenue_concept = c("general", "total"),
|
||||||
|
complete = FALSE) {
|
||||||
|
basis_explicit <- length(basis) == 1L
|
||||||
|
basis <- match.arg(basis, c("harmonized", "raw"))
|
||||||
|
# match.arg() itself throws a base `simpleError`, not an rlang-classed
|
||||||
|
# condition; wrap it so an invalid expenditure_concept aborts consistently
|
||||||
|
# with the rest of this package's validation (cli::cli_abort -> rlang_error).
|
||||||
|
expenditure_concept <- tryCatch(
|
||||||
|
match.arg(expenditure_concept, c("primary", "direct", "total")),
|
||||||
|
error = function(e) {
|
||||||
|
cli::cli_abort(
|
||||||
|
"`expenditure_concept` must be one of {.val primary}, {.val direct}, or {.val total}.",
|
||||||
|
class = "uscogdata_invalid_expenditure_concept",
|
||||||
|
parent = e
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
revenue_concept <- tryCatch(
|
||||||
|
match.arg(revenue_concept, c("general", "total")),
|
||||||
|
error = function(e) {
|
||||||
|
cli::cli_abort(
|
||||||
|
"`revenue_concept` must be one of {.val general} or {.val total}.",
|
||||||
|
class = "uscogdata_invalid_revenue_concept",
|
||||||
|
parent = e
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# The concept's subtype scope. Every code path below -- the verb SQL, the
|
||||||
|
# harmonization exclusion count, and the complete = TRUE grid -- is scoped
|
||||||
|
# by crosswalk subtype membership, never by item-code prefix. The
|
||||||
|
# expenditure "total" concept's extra intergovernmental leg is the one
|
||||||
|
# exception: it travels through the ig_* views rather than this scope,
|
||||||
|
# because its legacy rows are aggregate-flagged.
|
||||||
|
subtype_scope <- if (identical(subtype_col, "spend_subtype")) {
|
||||||
|
.expenditure_concept_subtypes(expenditure_concept)
|
||||||
|
} else {
|
||||||
|
.revenue_concept_subtypes(revenue_concept)
|
||||||
|
}
|
||||||
|
|
||||||
govid <- .coerce_govid_input(govid, arg = "govid")
|
govid <- .coerce_govid_input(govid, arg = "govid")
|
||||||
.validate_verb_inputs(govid, years, category, per_capita, adjust_to_year)
|
.validate_verb_inputs(govid, years, category, per_capita, adjust_to_year,
|
||||||
|
recipe)
|
||||||
|
|
||||||
|
if (!is.null(recipe) && identical(expenditure_concept, "total")) {
|
||||||
|
cli::cli_abort(c(
|
||||||
|
"`recipe` and `expenditure_concept = \"total\"` are mutually exclusive.",
|
||||||
|
i = "A recipe defines its own component codes; pass one or the other.",
|
||||||
|
i = "For a recipe's intergovernmental counterpart, use the matching IG recipe (e.g. `corrections_ig_local_combined`)."
|
||||||
|
), class = "uscogdata_recipe_concept_conflict")
|
||||||
|
}
|
||||||
|
|
||||||
|
# .verb_spendrev() is shared with cog_revenue(), which never exposes
|
||||||
|
# expenditure_concept and always resolves it to the default -- so nothing
|
||||||
|
# on the public API can reach this today. But it's a cheap guard against a
|
||||||
|
# future call (direct or via a modified cog_revenue()) that would UNION
|
||||||
|
# the IG leg's expenditure M/L/Q rows into a revenue result, which has no
|
||||||
|
# matching IG view and no sensible meaning.
|
||||||
|
if (identical(expenditure_concept, "total") &&
|
||||||
|
!identical(view_base, "spending_annotated")) {
|
||||||
|
cli::cli_abort(
|
||||||
|
paste0(
|
||||||
|
"`expenditure_concept = \"total\"` is only supported for spending ",
|
||||||
|
"(view_base = \"spending_annotated\"); got view_base = ",
|
||||||
|
"{.val {view_base}}."
|
||||||
|
),
|
||||||
|
class = "uscogdata_expenditure_concept_unsupported"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
complete <- isTRUE(complete)
|
||||||
|
if (complete && !is.null(recipe)) {
|
||||||
|
.abort_complete_unsupported(
|
||||||
|
"A recipe defines its own component codes and never goes through `summary_categories`, so there is no grid to fill from.",
|
||||||
|
"Query the recipe without `complete`, or use a category query with `complete = TRUE`."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (complete && identical(expenditure_concept, "total")) {
|
||||||
|
.abort_complete_unsupported(
|
||||||
|
"The intergovernmental leg deliberately keeps aggregate-flagged rows (see `inst/sql/24-ig_long.sql`), so its cells are not the ones `code_set` describes.",
|
||||||
|
"Use `expenditure_concept = \"direct\"` with `complete = TRUE`, or drop `complete`."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
years <- as.integer(years)
|
years <- as.integer(years)
|
||||||
if (!is.null(adjust_to_year)) adjust_to_year <- as.integer(adjust_to_year)
|
if (!is.null(adjust_to_year)) adjust_to_year <- as.integer(adjust_to_year)
|
||||||
|
|
||||||
con <- .ensure_session()
|
con <- .ensure_session()
|
||||||
|
manifest <- .uscogdata_env$manifest
|
||||||
scope <- .check_govids_in_scope(govid)
|
scope <- .check_govids_in_scope(govid)
|
||||||
|
if (complete) .require_representation(con, manifest)
|
||||||
|
|
||||||
sql <- .build_verb_sql(view, subtype_col, govid, years, category)
|
resolved <- .resolve_basis(basis, basis_explicit, manifest)
|
||||||
result <- tibble::as_tibble(DBI::dbGetQuery(con, sql))
|
|
||||||
|
recipe_block <- NULL
|
||||||
|
category_for_prov <- category
|
||||||
|
if (!is.null(recipe)) {
|
||||||
|
.require_schema_v5(con, manifest, "recipe =")
|
||||||
|
.validate_recipe_id(con, recipe)
|
||||||
|
comps <- .recipe_components(con, recipe)
|
||||||
|
recipe_label <- comps$label[[1]]
|
||||||
|
result <- .run_recipe(con, recipe, govid, years)
|
||||||
|
sql <- attr(result, "sql_query")
|
||||||
|
result <- .shape_recipe_result(result, subtype_col, recipe_label)
|
||||||
|
recipe_block <- list(
|
||||||
|
recipe_id = recipe, label = recipe_label,
|
||||||
|
components = .df_to_row_list(comps)
|
||||||
|
)
|
||||||
|
category_for_prov <- recipe_label
|
||||||
|
} else {
|
||||||
|
view <- .select_view(view_base, resolved$basis)
|
||||||
|
ig_view <- if (identical(expenditure_concept, "total")) {
|
||||||
|
.require_ig_categories(con)
|
||||||
|
.select_ig_view(resolved$basis)
|
||||||
|
} else {
|
||||||
|
NULL
|
||||||
|
}
|
||||||
|
sql <- .build_verb_sql(view, subtype_col, govid, years, category, ig_view,
|
||||||
|
subtype_scope)
|
||||||
|
result <- tibble::as_tibble(DBI::dbGetQuery(con, sql))
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fill BEFORE per_capita / inflation so the added cells get the same
|
||||||
|
# treatment as reported ones: a census_zero stays $0 per capita and in real
|
||||||
|
# dollars, and a not_reported stays NA through both rather than becoming a
|
||||||
|
# spurious 0.
|
||||||
|
completion <- list(applied = FALSE, rows_filled = 0L, absence_means = list())
|
||||||
|
if (complete) {
|
||||||
|
result <- .complete_result(result, con, subtype_col, govid, years,
|
||||||
|
category, subtype_scope)
|
||||||
|
completion <- attr(result, ".completion")
|
||||||
|
attr(result, ".completion") <- NULL
|
||||||
|
}
|
||||||
|
|
||||||
if (per_capita) result <- .attach_per_capita(result, con, govid)
|
if (per_capita) result <- .attach_per_capita(result, con, govid)
|
||||||
if (!is.null(adjust_to_year)) {
|
if (!is.null(adjust_to_year)) {
|
||||||
result <- .attach_real_dollars(result, adjust_to_year, per_capita)
|
result <- .attach_real_dollars(result, adjust_to_year, per_capita)
|
||||||
}
|
}
|
||||||
|
|
||||||
result$notes <- .notes_column(result)
|
# A recipe result doesn't go through spending_annotated(_harmonized) /
|
||||||
|
# revenue_annotated(_harmonized) at all -- .run_recipe()'s generic join
|
||||||
|
# reads `long` directly -- so `basis` and the `harmonization` exclusion
|
||||||
|
# count (which is itself computed from `long`, independent of which view
|
||||||
|
# a non-recipe query used) would describe a code path this result never
|
||||||
|
# took. Rather than report a technically-still-computed but misleading
|
||||||
|
# basis = "harmonized"/"raw" + harmonization$applied combo, recipe
|
||||||
|
# results report basis = "recipe" and an explicit, inert harmonization
|
||||||
|
# block pointing at the `recipe` block instead. Task 12 (cog-api) passes
|
||||||
|
# provenance through verbatim, so this needs to be unambiguous rather
|
||||||
|
# than technically-defensible-but-confusing.
|
||||||
|
if (!is.null(recipe)) {
|
||||||
|
basis_for_prov <- "recipe"
|
||||||
|
basis_note_for_prov <- NA_character_
|
||||||
|
harmonization <- list(
|
||||||
|
applied = FALSE, na_rows_excluded = 0L, na_amount_excluded = 0,
|
||||||
|
note = "basis/harmonization not applicable to recipe results; see the recipe block instead"
|
||||||
|
)
|
||||||
|
suggestions <- list()
|
||||||
|
} else {
|
||||||
|
basis_for_prov <- resolved$basis
|
||||||
|
basis_note_for_prov <- resolved$note
|
||||||
|
harmonization <- .build_harmonization_block(
|
||||||
|
con, govid, years, resolved, subtype_col, subtype_scope
|
||||||
|
)
|
||||||
|
# C1(a): gap detection must run against the Direct leg alone. `result`
|
||||||
|
# can also carry UNION'd intergovernmental rows (expenditure_concept =
|
||||||
|
# "total"), and the wide era (<= FY2011) routinely has legacy IG dollars
|
||||||
|
# surviving (ig_long deliberately keeps aggregate rows) for a
|
||||||
|
# (year, category) whose legacy Direct dollars were suppressed (spending_
|
||||||
|
# long/spending_long_harmonized both filter NOT is_aggregate). Passing
|
||||||
|
# the UNION'd result here would let a surviving IG row count as coverage
|
||||||
|
# and silently cancel the recipe-hint suggestion that should fire.
|
||||||
|
direct_leg_result <- if (identical(expenditure_concept, "total")) {
|
||||||
|
result[!(result[[subtype_col]] %in% "intergovernmental"), , drop = FALSE]
|
||||||
|
} else {
|
||||||
|
result
|
||||||
|
}
|
||||||
|
suggestions <- .build_suggestions(con, govid, years, category,
|
||||||
|
direct_leg_result,
|
||||||
|
resolved$basis, flow_prefixes)
|
||||||
|
}
|
||||||
|
|
||||||
|
# C1(b): when expenditure_concept = "total", flag any row where the IG
|
||||||
|
# leg has dollars but the Direct leg has none for that same (year,
|
||||||
|
# canonical_govid, category) AND a harmonization recipe actually recovers
|
||||||
|
# the missing Direct dollars for that exact triple -- see
|
||||||
|
# .detect_direct_suppressed() for why bare Direct-row absence alone is NOT
|
||||||
|
# sufficient (the dominant real cause is a government that simply has no
|
||||||
|
# direct spending in that category, which is correct, ordinary data). When
|
||||||
|
# a covering recipe is found, both the row-level notes and the provenance
|
||||||
|
# say so rather than pass silently as a plausible Total.
|
||||||
|
direct_suppressed_info <- if (identical(expenditure_concept, "total")) {
|
||||||
|
.detect_direct_suppressed(con, result, subtype_col)
|
||||||
|
} else {
|
||||||
|
list(flag = rep(FALSE, nrow(result)), notes = rep(NA_character_, nrow(result)))
|
||||||
|
}
|
||||||
|
direct_suppressed <- direct_suppressed_info$flag
|
||||||
|
direct_suppressed_flag <- isTRUE(any(direct_suppressed))
|
||||||
|
|
||||||
|
result$notes <- .notes_column(result, direct_suppressed_info$notes)
|
||||||
|
|
||||||
|
# Determine expenditure_concept_note: only non-empty for "total", explains
|
||||||
|
# how the IG leg was assembled from legacy-era aggregates. When the Direct
|
||||||
|
# leg is suppressed for at least one requested (year, category), append an
|
||||||
|
# explicit warning rather than let the base note's "Total = Direct + IG"
|
||||||
|
# framing stand unqualified for rows where that arithmetic didn't happen.
|
||||||
|
expenditure_concept_note_for_prov <- if (identical(expenditure_concept, "total")) {
|
||||||
|
base_note <- "Total = Direct + intergovernmental (M to local govts + L to state govts). Legacy-era IG is assembled from aggregate-flagged rows, which are year-disjoint from their modern leaf components; the L-- family total is excluded."
|
||||||
|
if (direct_suppressed_flag) {
|
||||||
|
paste0(
|
||||||
|
base_note,
|
||||||
|
" NOTE: for at least one requested (year, category) the Direct leg ",
|
||||||
|
"has NO rows in this corpus (a legacy aggregate-only family) -- the ",
|
||||||
|
"affected result rows report the intergovernmental leg alone, not ",
|
||||||
|
"Direct + IG. See `expenditure_concept_direct_suppressed` and each ",
|
||||||
|
"affected row's `notes`."
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
base_note
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
NA_character_
|
||||||
|
}
|
||||||
|
|
||||||
prov <- .build_provenance(
|
prov <- .build_provenance(
|
||||||
verb = verb,
|
verb = verb,
|
||||||
call = call,
|
call = call,
|
||||||
govid = govid,
|
govid = govid,
|
||||||
years = years,
|
years = years,
|
||||||
category = category,
|
category = category_for_prov,
|
||||||
per_capita = per_capita,
|
per_capita = per_capita,
|
||||||
adjust_to_year = adjust_to_year,
|
adjust_to_year = adjust_to_year,
|
||||||
result = result,
|
result = result,
|
||||||
sql = sql,
|
sql = sql,
|
||||||
subtype_col = subtype_col
|
subtype_col = subtype_col,
|
||||||
|
basis = basis_for_prov,
|
||||||
|
basis_note = basis_note_for_prov,
|
||||||
|
expenditure_concept = expenditure_concept,
|
||||||
|
expenditure_concept_note = expenditure_concept_note_for_prov,
|
||||||
|
expenditure_concept_direct_suppressed = direct_suppressed_flag,
|
||||||
|
revenue_concept = revenue_concept,
|
||||||
|
harmonization = harmonization,
|
||||||
|
recipe = recipe_block,
|
||||||
|
suggestions = suggestions,
|
||||||
|
completion = completion
|
||||||
)
|
)
|
||||||
prov$scope$govids_found <- scope$found
|
prov$scope$govids_found <- scope$found
|
||||||
prov$scope$govids_missing <- scope$missing
|
prov$scope$govids_missing <- scope$missing
|
||||||
attr(result, "provenance") <- prov
|
attr(result, "provenance") <- prov
|
||||||
attr(result, ".popyear_range") <- NULL
|
attr(result, ".popyear_range") <- NULL
|
||||||
|
|
||||||
|
if (length(suggestions) > 0L) .inform_suggestions(suggestions)
|
||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
#' @noRd
|
#' @noRd
|
||||||
.validate_verb_inputs <- function(govid, years, category,
|
.validate_verb_inputs <- function(govid, years, category,
|
||||||
per_capita, adjust_to_year) {
|
per_capita, adjust_to_year, recipe = NULL) {
|
||||||
if (!is.character(govid) || length(govid) == 0L) {
|
if (!is.character(govid) || length(govid) == 0L) {
|
||||||
cli::cli_abort("`govid` must be a non-empty character vector.")
|
cli::cli_abort("`govid` must be a non-empty character vector.")
|
||||||
}
|
}
|
||||||
@@ -104,6 +493,59 @@ cog_spending <- function(govid, years, category = NULL,
|
|||||||
cli::cli_abort("`adjust_to_year` must be NULL or a length-1 integer.")
|
cli::cli_abort("`adjust_to_year` must be NULL or a length-1 integer.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!is.null(recipe)) {
|
||||||
|
if (!is.character(recipe) || length(recipe) != 1L) {
|
||||||
|
cli::cli_abort("`recipe` must be NULL or a length-1 character string.")
|
||||||
|
}
|
||||||
|
if (!is.null(category)) {
|
||||||
|
cli::cli_abort(c(
|
||||||
|
"`recipe` and `category` are mutually exclusive.",
|
||||||
|
i = "Pass one or the other, not both."
|
||||||
|
), class = "uscogdata_recipe_category_conflict")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
invisible(TRUE)
|
||||||
|
}
|
||||||
|
|
||||||
|
#' @noRd
|
||||||
|
.select_view <- function(view_base, basis) {
|
||||||
|
if (identical(basis, "harmonized")) paste0(view_base, "_harmonized") else view_base
|
||||||
|
}
|
||||||
|
|
||||||
|
#' @noRd
|
||||||
|
.select_ig_view <- function(basis) {
|
||||||
|
if (identical(basis, "harmonized")) "ig_annotated_harmonized" else "ig_annotated"
|
||||||
|
}
|
||||||
|
|
||||||
|
#' Abort unless the active corpus's `summary_categories` actually carries
|
||||||
|
#' intergovernmental (M/L) rows.
|
||||||
|
#'
|
||||||
|
#' The 66 M/L category rows arrived via cog_pipeline PR #59 with NO
|
||||||
|
#' `schema_version` bump (`DESCRIPTION` still declares `MinCorpusSchema: 4`),
|
||||||
|
#' so `schema_version` alone cannot gate `expenditure_concept = "total"` --
|
||||||
|
#' a pre-#59 corpus can validly report schema_version 4, 5, or 6 and still
|
||||||
|
#' have zero M/L rows in `summary_categories`. Against such a corpus,
|
||||||
|
#' `ig_annotated`'s LEFT JOIN to `summary_categories` silently produces NA
|
||||||
|
#' `category`/`spend_subtype` for every IG row: with a `category` filter
|
||||||
|
#' this returns 0 rows (reads as "no intergovernmental spending" rather than
|
||||||
|
#' "can't tell"), and with `category = NULL` every IG dollar collapses into
|
||||||
|
#' one NA-subtype group that is invisible to the `spend_subtype ==
|
||||||
|
#' "intergovernmental"` filter this package's own tests, roxygen, and
|
||||||
|
#' vignette all rely on. Checking the data directly (rather than
|
||||||
|
#' schema_version) is the only reliable gate.
|
||||||
|
#' @noRd
|
||||||
|
.require_ig_categories <- function(con, what = "expenditure_concept = \"total\"") {
|
||||||
|
n <- DBI::dbGetQuery(con,
|
||||||
|
"SELECT COUNT(*) AS n FROM summary_categories WHERE LEFT(item_code, 1) IN ('M', 'L')"
|
||||||
|
)$n
|
||||||
|
if (identical(as.integer(n), 0L)) {
|
||||||
|
cli::cli_abort(c(
|
||||||
|
sprintf("%s requires a corpus with intergovernmental category rows.", what),
|
||||||
|
x = "The active corpus's `summary_categories` has no M/L (intergovernmental) rows.",
|
||||||
|
i = "This corpus predates the intergovernmental category rows added by cog_pipeline PR #59.",
|
||||||
|
i = "Point USCOGDATA_URL at a newer corpus that includes the M/L summary_categories rows."
|
||||||
|
), class = "uscogdata_ig_categories_unsupported")
|
||||||
|
}
|
||||||
invisible(TRUE)
|
invisible(TRUE)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +556,8 @@ cog_spending <- function(govid, years, category = NULL,
|
|||||||
}
|
}
|
||||||
|
|
||||||
#' @noRd
|
#' @noRd
|
||||||
.build_verb_sql <- function(view, subtype_col, govid, years, category) {
|
.build_verb_sql <- function(view, subtype_col, govid, years, category,
|
||||||
|
ig_view = NULL, subtype_scope = NULL) {
|
||||||
govid_lit <- .sql_lit_chr(govid)
|
govid_lit <- .sql_lit_chr(govid)
|
||||||
years_lit <- paste(as.integer(years), collapse = ",")
|
years_lit <- paste(as.integer(years), collapse = ",")
|
||||||
category_pred <- if (is.null(category)) {
|
category_pred <- if (is.null(category)) {
|
||||||
@@ -123,6 +566,39 @@ cog_spending <- function(govid, years, category = NULL,
|
|||||||
sprintf("AND category IN (%s)", .sql_lit_chr(category))
|
sprintf("AND category IN (%s)", .sql_lit_chr(category))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The concept's subtype allowlist (see .expenditure_concept_subtypes()).
|
||||||
|
# The base views carry every subtype of their flow (spending_annotated has
|
||||||
|
# all five non-IG expenditure subtypes); the concept narrows here. For
|
||||||
|
# "total", the IG leg's rows are 'intergovernmental', so that value joins
|
||||||
|
# the allowlist exactly when ig_view is present.
|
||||||
|
subtype_pred <- if (is.null(subtype_scope)) {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
scope <- if (is.null(ig_view)) subtype_scope else c(subtype_scope, "intergovernmental")
|
||||||
|
sprintf("AND %s IN (%s)", subtype_col, .sql_lit_chr(scope))
|
||||||
|
}
|
||||||
|
|
||||||
|
# expenditure_concept = "total" adds the intergovernmental leg. UNION ALL,
|
||||||
|
# never UNION: the two legs are disjoint by crosswalk subtype (the direct
|
||||||
|
# view excludes 'intergovernmental'; the IG view is only that), so
|
||||||
|
# de-duplication would be pure cost, and a silent row-drop if two
|
||||||
|
# governments ever reported identical values.
|
||||||
|
source_expr <- if (is.null(ig_view)) {
|
||||||
|
view
|
||||||
|
} else {
|
||||||
|
sprintf("(SELECT * FROM %s UNION ALL SELECT * FROM %s)", view, ig_view)
|
||||||
|
}
|
||||||
|
|
||||||
|
# bool_or(), not bool_and(): a no-op for the Direct/revenue legs (those
|
||||||
|
# views filter NOT is_aggregate, so no row in any group is ever aggregate),
|
||||||
|
# but load-bearing for the IG leg, which deliberately keeps aggregate rows
|
||||||
|
# (see inst/sql/24-ig_long.sql). The wide era is dense -- every government
|
||||||
|
# has a row for every code in a family, most of them $0 -- so a $0 leaf
|
||||||
|
# commonly lands in the same (year, gov, subtype, category) group as the
|
||||||
|
# real aggregate row. bool_and() would then read FALSE for that group even
|
||||||
|
# though its dollars came entirely from an aggregate row, silently
|
||||||
|
# suppressing the "Aggregate fallback applied" note on exactly the rows
|
||||||
|
# this feature exists to surface.
|
||||||
sprintf(
|
sprintf(
|
||||||
"SELECT
|
"SELECT
|
||||||
year,
|
year,
|
||||||
@@ -132,14 +608,15 @@ cog_spending <- function(govid, years, category = NULL,
|
|||||||
category,
|
category,
|
||||||
SUM(amt) * 1000.0 AS amt_nominal,
|
SUM(amt) * 1000.0 AS amt_nominal,
|
||||||
string_agg(DISTINCT item_code, ',' ORDER BY item_code) AS codes_included,
|
string_agg(DISTINCT item_code, ',' ORDER BY item_code) AS codes_included,
|
||||||
bool_and(is_aggregate) AS aggregate_fallback
|
bool_or(is_aggregate) AS aggregate_fallback
|
||||||
FROM %2$s
|
FROM %2$s
|
||||||
WHERE canonical_govid IN (%3$s)
|
WHERE canonical_govid IN (%3$s)
|
||||||
AND year IN (%4$s)
|
AND year IN (%4$s)
|
||||||
%5$s
|
%5$s
|
||||||
|
%6$s
|
||||||
GROUP BY year, canonical_govid, gov_name, xwalk_gov_name, %1$s, category
|
GROUP BY year, canonical_govid, gov_name, xwalk_gov_name, %1$s, category
|
||||||
ORDER BY year, canonical_govid, %1$s, category",
|
ORDER BY year, canonical_govid, %1$s, category",
|
||||||
subtype_col, view, govid_lit, years_lit, category_pred
|
subtype_col, source_expr, govid_lit, years_lit, category_pred, subtype_pred
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,11 +669,131 @@ cog_spending <- function(govid, years, category = NULL,
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#' Detect rows where expenditure_concept = "total" is reporting the
|
||||||
|
#' intergovernmental leg with NO Direct counterpart in the same (year,
|
||||||
|
#' canonical_govid, category) group AND a harmonization recipe actually
|
||||||
|
#' recovers the missing Direct dollars for that exact (year, canonical_govid,
|
||||||
|
#' category) triple.
|
||||||
|
#'
|
||||||
|
#' Bare Direct-row absence is deliberately NOT sufficient on its own: the
|
||||||
|
#' dominant real cause of "no Direct sibling row" is a government that simply
|
||||||
|
#' has no direct spending in that category (e.g. a state that funds K-12
|
||||||
|
#' entirely through school districts), which is correct, ordinary data, not
|
||||||
|
#' suppression. Genuine suppression -- a legacy aggregate-only family whose
|
||||||
|
#' Direct-leg basis query excludes it by construction (spending_long/
|
||||||
|
#' spending_long_harmonized both filter NOT is_aggregate) -- always has a
|
||||||
|
#' covering harmonization recipe, because that is exactly what the recipe
|
||||||
|
#' catalog exists to recover (see R/suggestions.R and `cog_recipes()`). So
|
||||||
|
#' checking "does a recipe actually cover this triple" cleanly separates the
|
||||||
|
#' two cases instead of conflating them.
|
||||||
|
#'
|
||||||
|
#' Returns `list(flag, notes)`, both the same length as `result`: `flag` is
|
||||||
|
#' `TRUE` only for the `spend_subtype == "intergovernmental"` row(s) in a
|
||||||
|
#' suppressed group, and `notes` names the recovering recipe(s) for those
|
||||||
|
#' rows (`NA` everywhere else).
|
||||||
#' @noRd
|
#' @noRd
|
||||||
.notes_column <- function(result) {
|
.detect_direct_suppressed <- function(con, result, subtype_col) {
|
||||||
|
n <- nrow(result)
|
||||||
|
empty_notes <- rep(NA_character_, n)
|
||||||
|
if (n == 0L) return(list(flag = logical(0), notes = character(0)))
|
||||||
|
is_ig <- result[[subtype_col]] %in% "intergovernmental"
|
||||||
|
if (!any(is_ig)) return(list(flag = rep(FALSE, n), notes = empty_notes))
|
||||||
|
|
||||||
|
key <- paste(result$year, result$canonical_govid, result$category, sep = "\r")
|
||||||
|
has_direct <- key %in% unique(key[!is_ig])
|
||||||
|
candidate <- is_ig & !has_direct
|
||||||
|
|
||||||
|
flag <- rep(FALSE, n)
|
||||||
|
notes <- empty_notes
|
||||||
|
if (!any(candidate)) return(list(flag = flag, notes = notes))
|
||||||
|
|
||||||
|
idx <- which(candidate)
|
||||||
|
rows <- unique(result[idx, c("year", "canonical_govid", "category")])
|
||||||
|
covering <- .covering_recipes(con, rows)
|
||||||
|
cov_key <- paste(covering$year, covering$canonical_govid, covering$category,
|
||||||
|
sep = "\r")
|
||||||
|
|
||||||
|
for (i in idx) {
|
||||||
|
k <- paste(result$year[i], result$canonical_govid[i], result$category[i],
|
||||||
|
sep = "\r")
|
||||||
|
m <- match(k, cov_key)
|
||||||
|
if (is.na(m)) next
|
||||||
|
ids <- covering$recipe_ids[[m]]
|
||||||
|
if (length(ids) == 0L) next
|
||||||
|
flag[i] <- TRUE
|
||||||
|
notes[i] <- sprintf(
|
||||||
|
"Direct component is unavailable through this basis for this year; recover it via recipe = '%s' (see cog_recipes()).",
|
||||||
|
paste(sort(unique(ids)), collapse = "', '")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
list(flag = flag, notes = notes)
|
||||||
|
}
|
||||||
|
|
||||||
|
#' For each (year, canonical_govid, category) triple potentially affected by
|
||||||
|
#' a suppressed Direct leg, find the harmonization recipe(s) that (a) cover
|
||||||
|
#' this `category` (share a component item_code via `summary_categories`,
|
||||||
|
#' excluding any recipe that is itself entirely intergovernmental M/L -- the
|
||||||
|
#' same exclusion `.build_suggestions()` applies, see I2) and (b) actually
|
||||||
|
#' produce a `long` row for this exact (canonical_govid, year) via the same
|
||||||
|
#' generic join `.run_recipe()` uses (component year_min/year_max +
|
||||||
|
#' gov_type_scope, no is_aggregate filter -- a recipe's whole point is to
|
||||||
|
#' recover data that's aggregate-only). Adds a list-column `recipe_ids`
|
||||||
|
#' (possibly length-0) to `rows`.
|
||||||
|
#' @noRd
|
||||||
|
.covering_recipes <- function(con, rows) {
|
||||||
|
rows$recipe_ids <- vector("list", nrow(rows))
|
||||||
|
cats <- unique(rows$category[!is.na(rows$category)])
|
||||||
|
if (length(cats) == 0L) return(rows)
|
||||||
|
|
||||||
|
cand <- DBI::dbGetQuery(con, sprintf(
|
||||||
|
"SELECT DISTINCT sc.category, r.recipe_id
|
||||||
|
FROM harmonization_recipes r
|
||||||
|
JOIN summary_categories sc ON sc.item_code = r.component_code
|
||||||
|
WHERE sc.category IN (%s)
|
||||||
|
AND r.recipe_id NOT IN (
|
||||||
|
SELECT DISTINCT recipe_id FROM harmonization_recipes
|
||||||
|
WHERE LEFT(component_code, 1) IN ('M', 'L')
|
||||||
|
)",
|
||||||
|
.sql_lit_chr(cats)
|
||||||
|
))
|
||||||
|
if (nrow(cand) == 0L) return(rows)
|
||||||
|
|
||||||
|
recipe_ids_all <- unique(cand$recipe_id)
|
||||||
|
govids <- unique(rows$canonical_govid)
|
||||||
|
years <- unique(rows$year)
|
||||||
|
covered <- DBI::dbGetQuery(con, sprintf(
|
||||||
|
"SELECT DISTINCT r.recipe_id, l.canonical_govid, l.year
|
||||||
|
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 (%s)
|
||||||
|
AND l.canonical_govid IN (%s)
|
||||||
|
AND l.year IN (%s)",
|
||||||
|
.sql_lit_chr(recipe_ids_all), .sql_lit_chr(govids), paste(years, collapse = ",")
|
||||||
|
))
|
||||||
|
|
||||||
|
for (i in seq_len(nrow(rows))) {
|
||||||
|
cat_i <- rows$category[i]
|
||||||
|
if (is.na(cat_i)) next
|
||||||
|
cat_recipe_ids <- cand$recipe_id[cand$category == cat_i]
|
||||||
|
if (length(cat_recipe_ids) == 0L) next
|
||||||
|
sub <- covered[covered$canonical_govid == rows$canonical_govid[i] &
|
||||||
|
covered$year == rows$year[i] &
|
||||||
|
covered$recipe_id %in% cat_recipe_ids, ]
|
||||||
|
rows$recipe_ids[[i]] <- sort(unique(sub$recipe_id))
|
||||||
|
}
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
#' @noRd
|
||||||
|
.notes_column <- function(result, direct_suppressed_notes = NULL) {
|
||||||
n <- nrow(result)
|
n <- nrow(result)
|
||||||
if (n == 0L) return(character(0))
|
if (n == 0L) return(character(0))
|
||||||
parts <- vector("list", 2L)
|
parts <- vector("list", 3L)
|
||||||
agg <- result[["aggregate_fallback"]]
|
agg <- result[["aggregate_fallback"]]
|
||||||
parts[[1]] <- if (!is.null(agg)) {
|
parts[[1]] <- if (!is.null(agg)) {
|
||||||
ifelse(agg %in% TRUE,
|
ifelse(agg %in% TRUE,
|
||||||
@@ -213,6 +810,11 @@ cog_spending <- function(govid, years, category = NULL,
|
|||||||
} else {
|
} else {
|
||||||
rep(NA_character_, n)
|
rep(NA_character_, n)
|
||||||
}
|
}
|
||||||
|
parts[[3]] <- if (!is.null(direct_suppressed_notes)) {
|
||||||
|
direct_suppressed_notes
|
||||||
|
} else {
|
||||||
|
rep(NA_character_, n)
|
||||||
|
}
|
||||||
out <- character(n)
|
out <- character(n)
|
||||||
for (i in seq_len(n)) {
|
for (i in seq_len(n)) {
|
||||||
pieces <- vapply(parts, `[[`, character(1), i)
|
pieces <- vapply(parts, `[[`, character(1), i)
|
||||||
|
|||||||
+251
@@ -0,0 +1,251 @@
|
|||||||
|
# R/suggestions.R
|
||||||
|
# Recipe-component-driven signposting: when a basis = "harmonized" query for
|
||||||
|
# a category comes back with a coverage gap in some requested years (the
|
||||||
|
# result has no rows at all in that year) that a harmonization recipe would
|
||||||
|
# actually fill for this government, surface that recipe as a suggestion.
|
||||||
|
#
|
||||||
|
# This is deliberately keyed off the recipe catalog's component codes, not
|
||||||
|
# off harmonization_map rows: no live map row carries a non-blank
|
||||||
|
# suggested_recipe_id (the corpus's wide era exposes split families like
|
||||||
|
# corrections functions 04+05 ONLY as aggregate rows, which basis =
|
||||||
|
# "harmonized" excludes by construction -- there's no NA ruling to hang a
|
||||||
|
# suggestion off of, just a leaf-code absence a recipe happens to fill).
|
||||||
|
# See docs/phase_r_harmonization_review.md § 0.3.
|
||||||
|
#
|
||||||
|
# Scope is deliberately narrow: signposting only runs when the caller
|
||||||
|
# supplied a `category` (an un-scoped, all-categories query has no single
|
||||||
|
# coverage question to answer) and only flags a recipe when the ACTUAL
|
||||||
|
# result has zero rows in a requested year AND the candidate recipe's own
|
||||||
|
# generic join (same join .run_recipe() uses, including its wide-era
|
||||||
|
# aggregate rows) produces at least one row for this government in that
|
||||||
|
# year. Checking presence per-government (not corpus-wide) avoids false
|
||||||
|
# positives from ordinary reporting variance -- most governments don't use
|
||||||
|
# every sibling code in a multi-code category every year, and that is not
|
||||||
|
# a format-boundary gap worth signposting.
|
||||||
|
#
|
||||||
|
# C1(a): for expenditure_concept = "total" callers, `result` here must
|
||||||
|
# already be the Direct-leg subset (the caller filters out
|
||||||
|
# spend_subtype == "intergovernmental" rows before calling in). A gap year
|
||||||
|
# is "the requested year has no Direct rows", never "no rows at all" --
|
||||||
|
# an IG row surviving on a legacy aggregate that Direct excludes must not
|
||||||
|
# read as coverage and cancel the very suggestion that would recover it.
|
||||||
|
|
||||||
|
#' Build the `prov$suggestions` list for a (non-recipe) basis = "harmonized"
|
||||||
|
#' verb call: recipes whose generic join would fill a real gap in `result`.
|
||||||
|
#'
|
||||||
|
#' @param con Active DuckDB connection.
|
||||||
|
#' @param govid Character vector of canonical_govid values (the verb's raw
|
||||||
|
#' `govid`).
|
||||||
|
#' @param years Integer vector of requested years.
|
||||||
|
#' @param category `category` argument as passed to the verb (character
|
||||||
|
#' vector or `NULL`; suggestions are only computed when non-NULL).
|
||||||
|
#' @param result The verb's already-computed result tibble (post basis
|
||||||
|
#' query, pre per_capita/adjust_to_year), pre-filtered to the Direct leg
|
||||||
|
#' only when the caller's `expenditure_concept = "total"` (see C1(a)).
|
||||||
|
#' @param basis The *resolved* basis (`"harmonized"` or `"raw"`).
|
||||||
|
#' @param flow_prefixes The calling verb's own flow-type prefixes (e.g.
|
||||||
|
#' `c("E", "F", "G")` for `cog_spending()`, `c("T", "A", "U", "B", "C",
|
||||||
|
#' "D")` for `cog_revenue()` -- see `.verb_spendrev()`). Passed through to
|
||||||
|
#' `.attach_ig_counterparts()` to keep the intergovernmental-counterpart
|
||||||
|
#' lookup scoped to the calling verb's own flow family.
|
||||||
|
#' @return List of `list(recipe_id, label, available_years, hint,
|
||||||
|
#' ig_recipe_id)`, possibly empty.
|
||||||
|
#' @noRd
|
||||||
|
.build_suggestions <- function(con, govid, years, category, result, basis,
|
||||||
|
flow_prefixes) {
|
||||||
|
if (!identical(basis, "harmonized") || is.null(category)) return(list())
|
||||||
|
|
||||||
|
# Exclude any recipe that is ITSELF an intergovernmental (M/L) recipe --
|
||||||
|
# i.e. every one of its own component codes is M/L-prefixed. Without this,
|
||||||
|
# a category whose summary_categories rows span both a Direct family
|
||||||
|
# (e.g. E04/E05, "Corrections") and its M/L counterpart (M04/M05, same
|
||||||
|
# category since Task 1) makes the M/L recipe itself (e.g.
|
||||||
|
# `corrections_ig_local_combined`) a raw top-level candidate for a plain
|
||||||
|
# (Direct) cog_spending() call -- following that hint would silently
|
||||||
|
# return intergovernmental dollars under `expenditure_concept = "direct"`
|
||||||
|
# provenance. This is a stronger, unconditional exclusion than the
|
||||||
|
# flow-prefix gate below/in `.attach_ig_counterparts()`: an M/L recipe
|
||||||
|
# should never be suggested as a coverage-gap filler for EITHER verb, not
|
||||||
|
# just kept from being named as the *counterpart* of another suggestion.
|
||||||
|
candidates <- DBI::dbGetQuery(con, sprintf(
|
||||||
|
"SELECT DISTINCT recipe_id FROM harmonization_recipes
|
||||||
|
WHERE component_code IN (
|
||||||
|
SELECT DISTINCT item_code FROM summary_categories WHERE category IN (%s)
|
||||||
|
)
|
||||||
|
AND recipe_id NOT IN (
|
||||||
|
SELECT DISTINCT recipe_id FROM harmonization_recipes
|
||||||
|
WHERE LEFT(component_code, 1) IN ('M', 'L')
|
||||||
|
)",
|
||||||
|
.sql_lit_chr(category)
|
||||||
|
))$recipe_id
|
||||||
|
if (length(candidates) == 0L) return(list())
|
||||||
|
|
||||||
|
result_years <- if (is.null(result) || nrow(result) == 0L) {
|
||||||
|
integer(0)
|
||||||
|
} else {
|
||||||
|
unique(as.integer(result$year))
|
||||||
|
}
|
||||||
|
gap_years <- setdiff(as.integer(years), result_years)
|
||||||
|
if (length(gap_years) == 0L) return(list())
|
||||||
|
|
||||||
|
meta <- tibble::as_tibble(DBI::dbGetQuery(con, sprintf(
|
||||||
|
"SELECT recipe_id, any_value(label) AS label,
|
||||||
|
MIN(year_min) AS year_min, MAX(year_max) AS year_max
|
||||||
|
FROM harmonization_recipes
|
||||||
|
WHERE recipe_id IN (%s)
|
||||||
|
GROUP BY recipe_id",
|
||||||
|
.sql_lit_chr(candidates)
|
||||||
|
)))
|
||||||
|
|
||||||
|
# Which (recipe_id, year) pairs the recipe's own generic join actually
|
||||||
|
# covers for this government, restricted to the gap years -- the same
|
||||||
|
# join .run_recipe() uses (component year_min/year_max + gov_type_scope,
|
||||||
|
# no is_aggregate filter), just checking existence instead of summing.
|
||||||
|
covered <- DBI::dbGetQuery(con, sprintf(
|
||||||
|
"SELECT DISTINCT r.recipe_id, l.year
|
||||||
|
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 (%s)
|
||||||
|
AND l.canonical_govid IN (%s)
|
||||||
|
AND l.year IN (%s)",
|
||||||
|
.sql_lit_chr(candidates), .sql_lit_chr(govid),
|
||||||
|
paste(gap_years, collapse = ",")
|
||||||
|
))
|
||||||
|
|
||||||
|
suggestions <- list()
|
||||||
|
for (rid in candidates) {
|
||||||
|
if (!rid %in% covered$recipe_id) next
|
||||||
|
m <- meta[meta$recipe_id == rid, ]
|
||||||
|
suggestions[[length(suggestions) + 1L]] <- list(
|
||||||
|
recipe_id = rid,
|
||||||
|
label = m$label[[1]],
|
||||||
|
available_years = c(as.integer(m$year_min), as.integer(m$year_max)),
|
||||||
|
hint = sprintf("re-run with recipe = '%s'", rid)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.attach_ig_counterparts(con, suggestions, flow_prefixes)
|
||||||
|
}
|
||||||
|
|
||||||
|
#' Attach `ig_recipe_id` to each suggestion: the intergovernmental-expenditure
|
||||||
|
#' recipe (an M-to-local or L-to-state recipe) whose component codes cover
|
||||||
|
#' exactly the same set of function suffixes as the firing recipe's own
|
||||||
|
#' components, e.g. `corrections_combined`'s {E04, E05} -> suffixes {"04",
|
||||||
|
#' "05"} matches `corrections_ig_local_combined`'s {M04, M05} -> the same
|
||||||
|
#' {"04", "05"}. `NULL` when no such recipe exists, which also covers the
|
||||||
|
#' case where the firing recipe already IS the IG recipe (self-matches are
|
||||||
|
#' excluded, so an IG recipe never names itself as its own counterpart).
|
||||||
|
#'
|
||||||
|
#' Matching is deliberately an exact set match, not "any suffix in common":
|
||||||
|
#' the two-digit suffix only means the same "function" across recipes that
|
||||||
|
#' share the underlying Census functional-classification scheme (E/F/G/L/M
|
||||||
|
#' all use "04"/"05" for corrections). M/L "combined other" codes (47/89/
|
||||||
|
#' 91-94) reuse digits for an unrelated catch-all construct, so e.g.
|
||||||
|
#' `general_gov_e89_wide`'s {E85, E89} -> {"85", "89"} must NOT match
|
||||||
|
#' `ige_local_m89_wide`'s {"89", "91", "92", "93"} on the shared "89" alone.
|
||||||
|
#' Checked by hand against the full harmonization_recipes catalog: only the
|
||||||
|
#' corrections family (E/F/G/M, suffixes 04/05) has an exact-set match in
|
||||||
|
#' this corpus.
|
||||||
|
#'
|
||||||
|
#' Exact-set suffix matching is NOT enough on its own, though: the same
|
||||||
|
#' reused-digit problem exists ACROSS the revenue-side IG families too.
|
||||||
|
#' `ig_local_d47_wide` (D47/D94, suffixes {"47","94"}) is an exact-set match
|
||||||
|
#' for `ige_local_m47_wide` (M47/M94, same suffixes) even though one is
|
||||||
|
#' intergovernmental REVENUE received from local governments and the other is
|
||||||
|
#' intergovernmental EXPENDITURE paid to local governments -- unrelated flows
|
||||||
|
#' that happen to reuse "47"/"94" for their own "transit/utilities" and
|
||||||
|
#' "other/combined" catch-alls. `ig_federal_b47_wide`, `ig_state_c47_wide`,
|
||||||
|
#' and their `*_89` siblings all collide the same way. None of this is
|
||||||
|
#' reachable via `cog_revenue()` in the bundled fixture today (its B/C/D
|
||||||
|
#' recipes never happen to have a covered gap year for any fixture govid),
|
||||||
|
#' but it IS reachable via a mis-scoped `cog_spending()` call on a
|
||||||
|
#' revenue-only category, e.g. `cog_spending(gov, category = "IG Federal")`
|
||||||
|
#' fires `ig_federal_b47_wide`/`ig_federal_b89_wide` for real in the fixture
|
||||||
|
#' -- so this is a live, not merely theoretical, gap.
|
||||||
|
#'
|
||||||
|
#' Two flow-family checks close this, both required (see
|
||||||
|
#' `tests/testthat/test-expenditure-concept.R`, "revenue-flavored ... never
|
||||||
|
#' receives an M/L counterpart" tests, for the pairwise verification):
|
||||||
|
#' 1. `own_prefix %in% flow_prefixes`: the firing recipe's own component
|
||||||
|
#' codes must belong to the calling verb's own flow family (the same
|
||||||
|
#' `flow_prefixes` `.build_harmonization_block()` uses, see
|
||||||
|
#' `R/basis.R`). This blocks a recipe surfaced through a mis-scoped
|
||||||
|
#' category from ever reaching the M/L search, e.g. `cog_spending()`'s
|
||||||
|
#' flow_prefixes are `c("E","F","G")`, which `ig_federal_b47_wide`'s own
|
||||||
|
#' `"B"` is not part of.
|
||||||
|
#' 2. `own_prefix %in% c("E","F","G")`: M/L only ever pairs with the
|
||||||
|
#' DIRECT-expenditure family, never with revenue (`cog_revenue()`'s
|
||||||
|
#' flow_prefixes already fold B/C/D in as ordinary revenue -- there is
|
||||||
|
#' no separate "Total" bolt-on for revenue the way `expenditure_concept`
|
||||||
|
#' adds one for spending) and never with ANOTHER M/L recipe (without
|
||||||
|
#' this check, `ige_local_m47_wide` would wrongly match sibling
|
||||||
|
#' `ige_state_l47_wide` on their shared {"47","94"} suffix set).
|
||||||
|
#' Condition 1 alone does not catch this: under `cog_revenue()`,
|
||||||
|
#' `ig_federal_b47_wide`'s own `"B"` IS inside revenue's own
|
||||||
|
#' `flow_prefixes`, so only this second, family-specific check blocks
|
||||||
|
#' the search.
|
||||||
|
#' @noRd
|
||||||
|
.attach_ig_counterparts <- function(con, suggestions, flow_prefixes) {
|
||||||
|
if (length(suggestions) == 0L) return(suggestions)
|
||||||
|
|
||||||
|
comp <- DBI::dbGetQuery(con,
|
||||||
|
"SELECT recipe_id, component_code FROM harmonization_recipes")
|
||||||
|
comp$prefix <- substr(comp$component_code, 1L, 1L)
|
||||||
|
comp$suffix <- substr(comp$component_code, 2L, nchar(comp$component_code))
|
||||||
|
suffix_sets <- lapply(split(comp$suffix, comp$recipe_id), function(x) sort(unique(x)))
|
||||||
|
prefix_sets <- lapply(split(comp$prefix, comp$recipe_id), function(x) sort(unique(x)))
|
||||||
|
|
||||||
|
ig_recipe_ids <- unique(comp$recipe_id[comp$prefix %in% c("M", "L")])
|
||||||
|
|
||||||
|
find_counterpart <- function(rid) {
|
||||||
|
own_prefix <- prefix_sets[[rid]]
|
||||||
|
own_suffix <- suffix_sets[[rid]]
|
||||||
|
if (is.null(own_prefix) || is.null(own_suffix)) return(NULL)
|
||||||
|
if (!all(own_prefix %in% flow_prefixes)) return(NULL)
|
||||||
|
if (!all(own_prefix %in% c("E", "F", "G"))) return(NULL)
|
||||||
|
for (cand in ig_recipe_ids) {
|
||||||
|
if (identical(cand, rid)) next
|
||||||
|
if (setequal(suffix_sets[[cand]], own_suffix)) return(cand)
|
||||||
|
}
|
||||||
|
NULL
|
||||||
|
}
|
||||||
|
|
||||||
|
lapply(suggestions, function(s) {
|
||||||
|
# `s$ig_recipe_id <- NULL` would DELETE the element rather than set it
|
||||||
|
# (standard R list-assignment gotcha), leaving no-match entries missing
|
||||||
|
# the key entirely instead of carrying it as NULL. Single-bracket
|
||||||
|
# assignment with a wrapped list preserves a NULL-valued element so the
|
||||||
|
# field is always present, per the brief's "NULL when there is none".
|
||||||
|
s["ig_recipe_id"] <- list(find_counterpart(s$recipe_id))
|
||||||
|
s
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#' Emit the single cli::cli_inform() message summarizing all suggestions
|
||||||
|
#' for a verb call (the brief's "one message", not one per suggestion).
|
||||||
|
#' Bullet text is pre-formatted plain text (no cli/glue `{}` markup) since
|
||||||
|
#' recipe ids/labels are untrusted-ish data values, not literal call-site
|
||||||
|
#' expressions. When a suggestion has an `ig_recipe_id`, one indented
|
||||||
|
#' continuation line is appended naming the intergovernmental counterpart
|
||||||
|
#' recipe (embedded `\n` renders as a hanging-indent continuation of the
|
||||||
|
#' same bullet under cli, not a new bullet).
|
||||||
|
#' @noRd
|
||||||
|
.inform_suggestions <- function(suggestions) {
|
||||||
|
bullets <- vapply(suggestions, function(s) {
|
||||||
|
bullet <- sprintf("%s (%d-%d): %s", s$recipe_id,
|
||||||
|
s$available_years[1], s$available_years[2], s$hint)
|
||||||
|
if (!is.null(s$ig_recipe_id)) {
|
||||||
|
bullet <- paste0(bullet, sprintf(
|
||||||
|
"\n intergovernmental counterpart: recipe = '%s'", s$ig_recipe_id))
|
||||||
|
}
|
||||||
|
bullet
|
||||||
|
}, character(1))
|
||||||
|
cli::cli_inform(c(
|
||||||
|
i = "Coverage gap detected for the requested years; a harmonization recipe may fill it:",
|
||||||
|
stats::setNames(bullets, rep("*", length(bullets)))
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -1,11 +1,71 @@
|
|||||||
# R/views.R
|
# R/views.R
|
||||||
|
|
||||||
|
# SQL files that cannot be registered unconditionally against a v4 corpus,
|
||||||
|
# for one of two distinct reasons -- both fail at CREATE VIEW time (DuckDB
|
||||||
|
# resolves a view's source schema eagerly, even though it defers execution),
|
||||||
|
# so a v4 corpus can't tolerate either unconditionally:
|
||||||
|
#
|
||||||
|
# (a) Missing FILE. 33-/34-/35- read_parquet() a v5-only parquet table
|
||||||
|
# (harmonization_map.parquet, harmonization_recipes.parquet,
|
||||||
|
# series_breaks.parquet) that doesn't exist at all on a v4 corpus --
|
||||||
|
# "IO Error: No files found".
|
||||||
|
#
|
||||||
|
# (b) Missing COLUMN. 22-/23-/25- reference `long.harmonized_code`, a
|
||||||
|
# column that does not exist on a v4 corpus's `long` table (harmonized
|
||||||
|
# space was introduced in schema v5) -- "Binder Error: Referenced
|
||||||
|
# column harmonized_code not found". 42-/43-/45- are on this list only
|
||||||
|
# because they SELECT s.* FROM the (a)/(b) views above, so they'd fail
|
||||||
|
# to resolve their own source view if it weren't already skipped.
|
||||||
|
#
|
||||||
|
# Registration is therefore gated on manifest$schema_version >= 5 for all of
|
||||||
|
# them; verb-level *usage* of the resulting views is separately gated by
|
||||||
|
# .resolve_basis() / .require_schema_v5().
|
||||||
|
.harmonization_view_files <- c(
|
||||||
|
"22-spending_long_harmonized.sql",
|
||||||
|
"23-revenue_long_harmonized.sql",
|
||||||
|
"25-ig_long_harmonized.sql",
|
||||||
|
"33-harmonization_map.sql",
|
||||||
|
"34-harmonization_recipes.sql",
|
||||||
|
"35-series_breaks_pq.sql",
|
||||||
|
"42-spending_annotated_harmonized.sql",
|
||||||
|
"43-revenue_annotated_harmonized.sql",
|
||||||
|
"45-ig_annotated_harmonized.sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
# The representation contract (cog_pipeline#64): two parquet tables that say
|
||||||
|
# what an ABSENT cell means in a given year. Gated on manifest PRESENCE, not
|
||||||
|
# on schema_version, because the sparsification that introduced them did not
|
||||||
|
# bump the version -- the pre-sparsification corpus this package shipped
|
||||||
|
# against until 2026-07-30 was already schema v6 and carried neither table.
|
||||||
|
# Keying off the version number would therefore register a view over a file
|
||||||
|
# that does not exist and fail at CREATE VIEW time on exactly the corpora this
|
||||||
|
# check exists to tolerate.
|
||||||
|
.representation_view_files <- c(
|
||||||
|
"36-representation.sql" = "representation.parquet",
|
||||||
|
"37-code_set.sql" = "code_set.parquet"
|
||||||
|
)
|
||||||
|
|
||||||
|
#' Does the mounted corpus publish `file` (e.g. "code_set.parquet")?
|
||||||
|
#' Reads the manifest's metadata list rather than stat-ing the URL, so it
|
||||||
|
#' works identically for a local fixture and a remote share.
|
||||||
|
#' @noRd
|
||||||
|
.corpus_has_table <- function(manifest, file) {
|
||||||
|
paths <- vapply(manifest$files$metadata %||% list(),
|
||||||
|
function(f) as.character(f$path %||% ""), character(1))
|
||||||
|
file %in% basename(paths)
|
||||||
|
}
|
||||||
|
|
||||||
#' Register DuckDB views from inst/sql/ SQL files
|
#' Register DuckDB views from inst/sql/ SQL files
|
||||||
#' @noRd
|
#' @noRd
|
||||||
.register_views <- function(con, url, manifest) {
|
.register_views <- function(con, url, manifest) {
|
||||||
sql_dir <- system.file("sql", package = "uscogdata")
|
sql_dir <- system.file("sql", package = "uscogdata")
|
||||||
files <- list.files(sql_dir, pattern = "\\.sql$", full.names = TRUE)
|
files <- sort(list.files(sql_dir, pattern = "\\.sql$", full.names = TRUE))
|
||||||
|
schema_version <- suppressWarnings(as.integer(manifest$schema_version %||% 0L))
|
||||||
for (f in files) {
|
for (f in files) {
|
||||||
|
base <- basename(f)
|
||||||
|
if (base %in% .harmonization_view_files && schema_version < 5L) next
|
||||||
|
if (base %in% names(.representation_view_files) &&
|
||||||
|
!.corpus_has_table(manifest, .representation_view_files[[base]])) next
|
||||||
sql <- paste(readLines(f, warn = FALSE), collapse = "\n")
|
sql <- paste(readLines(f, warn = FALSE), collapse = "\n")
|
||||||
sql <- gsub("\\{url\\}", url, sql, fixed = FALSE)
|
sql <- gsub("\\{url\\}", url, sql, fixed = FALSE)
|
||||||
DBI::dbExecute(con, sql)
|
DBI::dbExecute(con, sql)
|
||||||
|
|||||||
@@ -19,20 +19,99 @@ package implements.
|
|||||||
# pak::pkg_install("gitea.civilytics.org/Civilytics/uscogdata")
|
# pak::pkg_install("gitea.civilytics.org/Civilytics/uscogdata")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Amounts are in full US dollars
|
||||||
|
|
||||||
|
Every amount column this package returns — `amt_nominal`, `amt_real`,
|
||||||
|
`amt_per_capita_nominal`, `amt_per_capita_real` — is in **full US dollars**.
|
||||||
|
|
||||||
|
The raw Census source files report **thousands of dollars**, and the corpus's
|
||||||
|
own `amt` column preserves that. The verbs multiply by 1000 on the way out, so
|
||||||
|
you never have to. The conversion is recorded in every result:
|
||||||
|
|
||||||
|
```r
|
||||||
|
r <- cog_spending("552025209777", 2020L)
|
||||||
|
attr(r, "provenance")$transformations$units_conversion
|
||||||
|
#> $applied TRUE $source_unit "$1,000s (raw Census)" $target_unit "$USD" $multiplier 1000
|
||||||
|
```
|
||||||
|
|
||||||
|
**Do not multiply again.** If you have read elsewhere that COG amounts are in
|
||||||
|
`$1,000s` — true of the raw corpus, and of `cog_explorer`'s conventions doc —
|
||||||
|
that rule does not apply to anything a `cog_*()` verb hands you. Applying it
|
||||||
|
twice overstates every figure by 1000x, and the result looks plausible rather
|
||||||
|
than obviously wrong.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
- `USCOGDATA_URL` — corpus root URL (public Nextcloud share, trailing slash)
|
- `USCOGDATA_URL` — corpus root URL (public Nextcloud share, trailing slash)
|
||||||
- `USCOGDATA_CACHE_DIR` — optional override for the manifest cache directory
|
- `USCOGDATA_CACHE_DIR` — optional override for the manifest cache directory
|
||||||
- `USCOGDATA_MANIFEST_TTL_SECS` — optional manifest re-fetch TTL (default 3600)
|
- `USCOGDATA_MANIFEST_TTL_SECS` — optional manifest re-fetch TTL (default 3600)
|
||||||
|
|
||||||
|
## Primary vs Direct vs Total spending
|
||||||
|
|
||||||
|
`cog_spending(..., expenditure_concept = c("primary", "direct", "total"))`
|
||||||
|
controls whose spending a result counts. Concepts are defined as sets of the
|
||||||
|
crosswalk's `spend_subtype` values — never item-code first letters, which
|
||||||
|
cannot classify correctly (the letter `Y` alone spans revenue, expenditure,
|
||||||
|
and balance codes):
|
||||||
|
|
||||||
|
- `"primary"` (the default) is the government's own service provision:
|
||||||
|
current operations, capital outlay, and assistance payments.
|
||||||
|
- `"direct"` is Census's published Direct Expenditure: `primary` plus
|
||||||
|
interest on debt and insurance trust benefit payments (e.g. pensions).
|
||||||
|
- `"total"` additionally adds the intergovernmental leg — money handed to
|
||||||
|
other governments to spend (`M`/`L` codes plus `Q11`/`Q12`/`Q18` state
|
||||||
|
payments to school systems) — which is meaningful for describing one
|
||||||
|
government's own budget over time, but double-counts when summed across
|
||||||
|
governments (a state's payment to a county is the same dollar the county
|
||||||
|
reports as its own direct spending).
|
||||||
|
|
||||||
|
**Rule of thumb: any figure that spans more than one government uses
|
||||||
|
`primary` or `direct`.** `cog_geographic_rollup()` and `cog_peer_compare()`
|
||||||
|
enforce this by refusing `expenditure_concept = "total"`. See
|
||||||
|
`vignette("total-spending", package = "uscogdata")` for the full
|
||||||
|
explanation with worked examples.
|
||||||
|
|
||||||
|
## General vs Total revenue
|
||||||
|
|
||||||
|
`cog_revenue(..., revenue_concept = c("general", "total"))` selects between
|
||||||
|
Census's two published revenue concepts, again defined as crosswalk
|
||||||
|
`revenue_subtype` sets rather than item-code prefixes:
|
||||||
|
|
||||||
|
- `"general"` (the default) is Census **General Revenue**: own-source
|
||||||
|
(taxes, charges, miscellaneous) plus federal, state and local
|
||||||
|
intergovernmental aid.
|
||||||
|
- `"total"` is Census **Total Revenue**: `general` plus utility revenue
|
||||||
|
(`A91`–`A94`), liquor store revenue (`A90`), and insurance trust revenue
|
||||||
|
(unemployment and workers' compensation `Y` codes plus the
|
||||||
|
employee-retirement `X` codes).
|
||||||
|
|
||||||
|
The manual defines the first by subtracting the other three from the second,
|
||||||
|
so the two are related by Census's own identity:
|
||||||
|
|
||||||
|
```
|
||||||
|
Total Revenue = General + Utility + Liquor Store + Insurance Trust
|
||||||
|
```
|
||||||
|
|
||||||
|
Two things worth knowing before switching to `"total"`:
|
||||||
|
|
||||||
|
- **Utility revenue is large for cities.** Measured on the bundled fixture,
|
||||||
|
utility plus liquor store revenue is 15.9% of city (type 2) revenue, versus
|
||||||
|
1.2% for states and 1.7% for counties. `general` excludes it by definition.
|
||||||
|
- **The employee-retirement (`X`) codes stop at FY2016**, when those systems
|
||||||
|
moved out of the annual finance file into the separate Annual Survey of
|
||||||
|
Public Pensions. A `"total"` series therefore steps down at the
|
||||||
|
FY2016/FY2017 seam for reasons of collection scope, not revenue (series
|
||||||
|
breaks `SB197`–`SB202`, in the corpus's `series_breaks` table).
|
||||||
|
|
||||||
## Developer notes
|
## Developer notes
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
The package ships a bundled fixture corpus at `inst/extdata/fixture_corpus/` —
|
The package ships a bundled fixture corpus at `inst/extdata/fixture_corpus/` —
|
||||||
a 3.6 MB two-year slice (2019 + 2020) of the full corpus covering all 50
|
a 15 MB four-year slice (2011, 2012, 2019, 2020) of the full corpus covering
|
||||||
states. `tests/testthat/setup.R` automatically points `USCOGDATA_URL` at this
|
all 50 states. `tests/testthat/setup.R` automatically points `USCOGDATA_URL`
|
||||||
fixture, so the full test suite runs offline with no network dependency:
|
at this fixture, so the full test suite runs offline with no network
|
||||||
|
dependency:
|
||||||
|
|
||||||
```r
|
```r
|
||||||
devtools::test() # uses bundled fixture, no credentials required
|
devtools::test() # uses bundled fixture, no credentials required
|
||||||
|
|||||||
@@ -3,12 +3,22 @@
|
|||||||
# Regenerate inst/extdata/fixture_corpus/ from a cog_pipeline publish tree.
|
# Regenerate inst/extdata/fixture_corpus/ from a cog_pipeline publish tree.
|
||||||
#
|
#
|
||||||
# What this does:
|
# What this does:
|
||||||
# 1. Copies the year=2019 and year=2020 long partitions as-is (byte-for-
|
# 1. Copies each requested year's long partition as-is (byte-for-byte)
|
||||||
# byte) from <publish_cache>/data/long/ into the fixture.
|
# from <publish_cache>/data/long/ into the fixture. Default years are
|
||||||
# 2. Copies the full canonical_fips_xwalk.parquet, canonical_alias.parquet,
|
# c(2011L, 2012L, 2019L, 2020L): 2011/2012 straddle the wide-aggregate
|
||||||
# and summary_categories.parquet metadata tables as-is (these are small
|
# -> modern-leaf format boundary (the harmonization/recipe seam), and
|
||||||
# cross-vintage registries, not partitioned by year, so the fixture
|
# 2019/2020 are the pre-existing per-capita/CPI regression anchors.
|
||||||
# ships the complete tables rather than a year-scoped subset).
|
# Each partition is a full year (all states/govs) as published, so
|
||||||
|
# Broward County FL and every other previously-pinned government stay
|
||||||
|
# covered without any per-gov slicing logic.
|
||||||
|
# 2. Copies every metadata parquet the publish tree ships (see
|
||||||
|
# .FIXTURE_METADATA_FILES) as-is. These are small cross-vintage
|
||||||
|
# registries, not partitioned by year, so the fixture ships the complete
|
||||||
|
# tables rather than a year-scoped subset. representation.parquet and
|
||||||
|
# code_set.parquet are what make the sparse wide era interpretable --
|
||||||
|
# absence means "$0" in a dense_source year and "not reported" in a
|
||||||
|
# sparse_source one -- so a fixture without them cannot represent the
|
||||||
|
# published corpus.
|
||||||
# 3. Resyncs the four reference docs (data_dictionary.md,
|
# 3. Resyncs the four reference docs (data_dictionary.md,
|
||||||
# reader-specification.md, README.md, series_breaks.md) from the
|
# reader-specification.md, README.md, series_breaks.md) from the
|
||||||
# publish tree's docs/.
|
# publish tree's docs/.
|
||||||
@@ -30,12 +40,28 @@
|
|||||||
# source("data-raw/regenerate_fixture_corpus.R")
|
# source("data-raw/regenerate_fixture_corpus.R")
|
||||||
# regenerate_fixture_corpus(publish_cache_dir = "/path/to/publish_cache")
|
# regenerate_fixture_corpus(publish_cache_dir = "/path/to/publish_cache")
|
||||||
|
|
||||||
|
# Every metadata parquet the publish tree ships, in the order they appear in
|
||||||
|
# the corpus manifest. Single source of truth for both the copy step and the
|
||||||
|
# fixture manifest, so the two can never drift apart.
|
||||||
|
.FIXTURE_METADATA_FILES <- c(
|
||||||
|
"canonical_alias.parquet",
|
||||||
|
"canonical_fips_xwalk.parquet",
|
||||||
|
"census_collection_coverage.parquet",
|
||||||
|
"code_set.parquet",
|
||||||
|
"harmonization_map.parquet",
|
||||||
|
"harmonization_recipes.parquet",
|
||||||
|
"lineage_events.parquet",
|
||||||
|
"representation.parquet",
|
||||||
|
"series_breaks.parquet",
|
||||||
|
"summary_categories.parquet"
|
||||||
|
)
|
||||||
|
|
||||||
regenerate_fixture_corpus <- function(
|
regenerate_fixture_corpus <- function(
|
||||||
publish_cache_dir = file.path(
|
publish_cache_dir = file.path(
|
||||||
"..", "cog_pipeline", "_targets", "publish_cache"
|
"..", "cog_pipeline", "_targets", "publish_cache"
|
||||||
),
|
),
|
||||||
fixture_dir = file.path("inst", "extdata", "fixture_corpus"),
|
fixture_dir = file.path("inst", "extdata", "fixture_corpus"),
|
||||||
fixture_years = c(2019L, 2020L)) {
|
fixture_years = c(2011L, 2012L, 2019L, 2020L)) {
|
||||||
stopifnot(
|
stopifnot(
|
||||||
requireNamespace("digest", quietly = TRUE),
|
requireNamespace("digest", quietly = TRUE),
|
||||||
requireNamespace("jsonlite", quietly = TRUE),
|
requireNamespace("jsonlite", quietly = TRUE),
|
||||||
@@ -92,16 +118,11 @@ regenerate_fixture_corpus <- function(
|
|||||||
invisible(NULL)
|
invisible(NULL)
|
||||||
}
|
}
|
||||||
|
|
||||||
# Copy the full (not year-scoped) canonical_fips_xwalk, canonical_alias, and
|
# Copy the full (not year-scoped) metadata tables listed in
|
||||||
# summary_categories parquet tables.
|
# .FIXTURE_METADATA_FILES.
|
||||||
#' @noRd
|
#' @noRd
|
||||||
.copy_metadata_parquets <- function(publish_cache_dir, fixture_dir) {
|
.copy_metadata_parquets <- function(publish_cache_dir, fixture_dir) {
|
||||||
files <- c(
|
for (f in .FIXTURE_METADATA_FILES) {
|
||||||
"canonical_fips_xwalk.parquet",
|
|
||||||
"canonical_alias.parquet",
|
|
||||||
"summary_categories.parquet"
|
|
||||||
)
|
|
||||||
for (f in files) {
|
|
||||||
src <- file.path(publish_cache_dir, "data", f)
|
src <- file.path(publish_cache_dir, "data", f)
|
||||||
dst <- file.path(fixture_dir, "data", f)
|
dst <- file.path(fixture_dir, "data", f)
|
||||||
if (!file.exists(src)) {
|
if (!file.exists(src)) {
|
||||||
@@ -167,12 +188,7 @@ regenerate_fixture_corpus <- function(
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
metadata_files <- c(
|
metadata <- lapply(.FIXTURE_METADATA_FILES, function(f) {
|
||||||
"canonical_alias.parquet",
|
|
||||||
"canonical_fips_xwalk.parquet",
|
|
||||||
"summary_categories.parquet"
|
|
||||||
)
|
|
||||||
metadata <- lapply(metadata_files, function(f) {
|
|
||||||
rel <- file.path("data", f)
|
rel <- file.path("data", f)
|
||||||
path <- file.path(fixture_dir, rel)
|
path <- file.path(fixture_dir, rel)
|
||||||
list(
|
list(
|
||||||
@@ -187,11 +203,18 @@ regenerate_fixture_corpus <- function(
|
|||||||
built_at = format(Sys.time(), "%Y-%m-%dT%H:%M:%SZ", tz = "UTC"),
|
built_at = format(Sys.time(), "%Y-%m-%dT%H:%M:%SZ", tz = "UTC"),
|
||||||
pipeline_commit = source_manifest$pipeline_commit,
|
pipeline_commit = source_manifest$pipeline_commit,
|
||||||
fixture_note = paste(
|
fixture_note = paste(
|
||||||
"Two-year (2019-2020) fixture for uscogdata tests. Full corpus",
|
"Four-year (2011, 2012, 2019, 2020) fixture for uscogdata tests. Full",
|
||||||
"available via USCOGDATA_URL. Regenerated for Phase P",
|
"corpus available via USCOGDATA_URL. Regenerated from the sparsified",
|
||||||
"(schema_version 4, uniformly 12-char canonical_govid) with the full",
|
"schema-v6 corpus: the wide era (<= FY2011) no longer stores explicit",
|
||||||
"canonical_fips_xwalk master and the new canonical_alias lookup",
|
"zeros, so FY2011 absence means Census published $0 while FY2012+",
|
||||||
"table via data-raw/regenerate_fixture_corpus.R."
|
"absence means not reported. representation.parquet and",
|
||||||
|
"code_set.parquet carry that rule and ship in full, as do every other",
|
||||||
|
"metadata table in the publish tree. 2011/2012 straddle both the",
|
||||||
|
"wide-aggregate -> modern-leaf format boundary (exercised by",
|
||||||
|
"basis=\"harmonized\" and recipe= queries) and the dense -> sparse",
|
||||||
|
"representation boundary (SB194); 2019/2020 retain the prior",
|
||||||
|
"per-capita/CPI regression anchors. Regenerated via",
|
||||||
|
"data-raw/regenerate_fixture_corpus.R."
|
||||||
),
|
),
|
||||||
data_vintage = source_manifest$data_vintage,
|
data_vintage = source_manifest$data_vintage,
|
||||||
scope = source_manifest$scope,
|
scope = source_manifest$scope,
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+77
-15
@@ -1,11 +1,24 @@
|
|||||||
{
|
{
|
||||||
"schema_version": 4,
|
"schema_version": 6,
|
||||||
"built_at": "2026-07-13T23:35:09Z",
|
"built_at": "2026-07-31T00:47:27Z",
|
||||||
"pipeline_commit": "a082b26",
|
"pipeline_commit": "aadb46b",
|
||||||
"fixture_note": "Two-year (2019-2020) fixture for uscogdata tests. Full corpus available via USCOGDATA_URL. Regenerated for Phase P (schema_version 4, uniformly 12-char canonical_govid) with the full canonical_fips_xwalk master and the new canonical_alias lookup table via data-raw/regenerate_fixture_corpus.R.",
|
"fixture_note": "Four-year (2011, 2012, 2019, 2020) fixture for uscogdata tests. Full corpus available via USCOGDATA_URL. Regenerated from the sparsified schema-v6 corpus: the wide era (<= FY2011) no longer stores explicit zeros, so FY2011 absence means Census published $0 while FY2012+ absence means not reported. representation.parquet and code_set.parquet carry that rule and ship in full, as do every other metadata table in the publish tree. 2011/2012 straddle both the wide-aggregate -> modern-leaf format boundary (exercised by basis=\"harmonized\" and recipe= queries) and the dense -> sparse representation boundary (SB194); 2019/2020 retain the prior per-capita/CPI regression anchors. Regenerated via data-raw/regenerate_fixture_corpus.R.",
|
||||||
"data_vintage": {
|
"data_vintage": {
|
||||||
"census_source_downloaded": "unknown",
|
"source_vintages": {
|
||||||
"cpi_vintage": "FRED CPIAUCSL",
|
"2012": "10162019",
|
||||||
|
"2013": "10162019",
|
||||||
|
"2014": "10162019",
|
||||||
|
"2015": "10162019",
|
||||||
|
"2016": "10162019",
|
||||||
|
"2017": "06102021",
|
||||||
|
"2018": "06102021",
|
||||||
|
"2019": "06102021",
|
||||||
|
"2020": "06122023",
|
||||||
|
"2021": "06122023",
|
||||||
|
"2022": "06052025",
|
||||||
|
"2023": "06052025"
|
||||||
|
},
|
||||||
|
"registry_rows": 148,
|
||||||
"acs_vintage": "ACS 2018-2022 5-year"
|
"acs_vintage": "ACS 2018-2022 5-year"
|
||||||
},
|
},
|
||||||
"scope": {
|
"scope": {
|
||||||
@@ -14,41 +27,90 @@
|
|||||||
"scope_note": "v0.1 covers state, county, city/municipality, and township governments. Special districts (type 4) and school districts (type 5) are excluded pending validation in a future cycle."
|
"scope_note": "v0.1 covers state, county, city/municipality, and township governments. Special districts (type 4) and school districts (type 5) are excluded pending validation in a future cycle."
|
||||||
},
|
},
|
||||||
"schema": {
|
"schema": {
|
||||||
"long_column_count": 24,
|
"long_column_count": 28,
|
||||||
"long_columns": ["fips_state", "type", "fips_county", "govid", "gov_blank", "gov_name", "county_name", "fips_state_code", "fips_county_code", "fips_place_code", "population", "popyear", "enrollment", "enrollyear", "function_code", "sch_level_code", "fiscal_year_end", "srvy_year", "item_code", "amt", "srv_data", "impute_flag", "is_aggregate", "canonical_govid"],
|
"long_columns": ["fips_state", "type", "fips_county", "govid", "gov_blank", "gov_name", "county_name", "fips_state_asof", "fips_county_asof", "cog_legacy_state", "cog_legacy_county", "fips_place_code", "population", "popyear", "enrollment", "enrollyear", "function_code", "sch_level_code", "fiscal_year_end", "srvy_year", "item_code", "amt", "srv_data", "impute_flag", "is_aggregate", "canonical_govid", "harmonized_code", "survey_weight"],
|
||||||
"data_dictionary": "docs/data_dictionary.md"
|
"data_dictionary": "docs/data_dictionary.md"
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"long_partitions": [
|
"long_partitions": [
|
||||||
|
{
|
||||||
|
"year": 2011,
|
||||||
|
"path": "data/long/year=2011/part-0.parquet",
|
||||||
|
"sha256": "7848e18497080c8980a4f89c5b386205b2c5bc90db6773827ea01ab3943d16b1",
|
||||||
|
"row_count": 496004,
|
||||||
|
"size_bytes": 2202455
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"year": 2012,
|
||||||
|
"path": "data/long/year=2012/part-0.parquet",
|
||||||
|
"sha256": "b82ac82d5e35f844b26c887445601f3748438c52c998ba4e403b025941a6f170",
|
||||||
|
"row_count": 1163338,
|
||||||
|
"size_bytes": 5929917
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"year": 2019,
|
"year": 2019,
|
||||||
"path": "data/long/year=2019/part-0.parquet",
|
"path": "data/long/year=2019/part-0.parquet",
|
||||||
"sha256": "c0a2bf0758af129d5dfddb6ff6665cc435ddee87fd6879e788fb56ed53ab22b8",
|
"sha256": "5cbd4726dcc7d0dab5c2a05a64702e979533ae119ed0587073cd31c089e0d737",
|
||||||
"row_count": 318139,
|
"row_count": 318139,
|
||||||
"size_bytes": 1441404
|
"size_bytes": 1719548
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"year": 2020,
|
"year": 2020,
|
||||||
"path": "data/long/year=2020/part-0.parquet",
|
"path": "data/long/year=2020/part-0.parquet",
|
||||||
"sha256": "92570b9d55ec3425d034db37838f91c3b8359d0454d3d98730a6016b62e4bb48",
|
"sha256": "ee548fec80bf1beda844fe03916ac145f10dd34c45968407cc330ec260935f00",
|
||||||
"row_count": 317500,
|
"row_count": 317500,
|
||||||
"size_bytes": 1444011
|
"size_bytes": 1722918
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"metadata": [
|
"metadata": [
|
||||||
{
|
{
|
||||||
"path": "data/canonical_alias.parquet",
|
"path": "data/canonical_alias.parquet",
|
||||||
"sha256": "feb8d01a640fb16c9a4b4ad66726b50b8fe8a1ce2a771bed8c1190fec51d5c8d",
|
"sha256": "3f617051c23a99bea322889857f7106df0c92954564afeec181df7083ee6698e",
|
||||||
"description": "canonical_alias.parquet"
|
"description": "canonical_alias.parquet"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "data/canonical_fips_xwalk.parquet",
|
"path": "data/canonical_fips_xwalk.parquet",
|
||||||
"sha256": "1ae47981531c7389f69eff3f7656045428564bfbe8032200eb9c039d32739a7e",
|
"sha256": "f98742f941269dacf8f7de5c273aa4dd4e75017a5bb70c054da35852a95a8d46",
|
||||||
"description": "canonical_fips_xwalk.parquet"
|
"description": "canonical_fips_xwalk.parquet"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "data/census_collection_coverage.parquet",
|
||||||
|
"sha256": "143e025616cde684da7c4442bc00d07fbd1556fabb0ea96223931b737e5d10a4",
|
||||||
|
"description": "census_collection_coverage.parquet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "data/code_set.parquet",
|
||||||
|
"sha256": "4cffcb0198dd51e4ff2b694050bb371a5f9965cdac12f25521cb628fb8e118a9",
|
||||||
|
"description": "code_set.parquet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "data/harmonization_map.parquet",
|
||||||
|
"sha256": "4cf32d0f817079ba4f28dc0ce65450d3247ebbf08d94c0c26c0d02af597bf812",
|
||||||
|
"description": "harmonization_map.parquet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "data/harmonization_recipes.parquet",
|
||||||
|
"sha256": "1133e9a0b02f8f34f5f936e55c5ecd596bb8a55d8425dcce76767f0f3203581c",
|
||||||
|
"description": "harmonization_recipes.parquet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "data/lineage_events.parquet",
|
||||||
|
"sha256": "36c16acfbe621d61010984767f1c566993b8a5f481a2c1e134c4c0a600e4502f",
|
||||||
|
"description": "lineage_events.parquet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "data/representation.parquet",
|
||||||
|
"sha256": "31ec328a7dd505a321b45f97aafff12e53d68a1a986f63509863035b22a4360d",
|
||||||
|
"description": "representation.parquet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "data/series_breaks.parquet",
|
||||||
|
"sha256": "06dcc995ff533e57cc65fa25086cc9bf83ba592c58bf7cc99269dc2576f69944",
|
||||||
|
"description": "series_breaks.parquet"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "data/summary_categories.parquet",
|
"path": "data/summary_categories.parquet",
|
||||||
"sha256": "dd59e7f58a022679ad43511c8c8e938b8dd4be81196bbeeee21e67bdcca2295b",
|
"sha256": "e3b0efa00ce713b8f45829b89cfde24b55333f26101f0495df82d85997d18d8e",
|
||||||
"description": "summary_categories.parquet"
|
"description": "summary_categories.parquet"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -10,11 +10,49 @@
|
|||||||
"target": { "type": "object" },
|
"target": { "type": "object" },
|
||||||
"years": { "type": "array", "items": { "type": "integer" } },
|
"years": { "type": "array", "items": { "type": "integer" } },
|
||||||
"category": { "type": ["string", "array", "null"] },
|
"category": { "type": ["string", "array", "null"] },
|
||||||
|
"basis": { "type": ["string", "null"] },
|
||||||
|
"basis_note": { "type": ["string", "null"] },
|
||||||
|
"expenditure_concept": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["primary", "direct", "total"],
|
||||||
|
"description": "Which spending concept produced this result, defined as crosswalk spend_subtype sets (never item-code prefixes). 'primary' (the default) is the government's own service provision: operations + capital + assistance. 'direct' adds interest on debt and insurance trust benefit payments (Census's published Direct Expenditure). 'total' adds intergovernmental payments (M to local governments, L to state government, Q11/Q12/Q18 to school systems). Only 'primary' and 'direct' are valid for results combined across governments."
|
||||||
|
},
|
||||||
|
"expenditure_concept_note": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"description": "How the intergovernmental leg was assembled; null for 'primary' and 'direct'."
|
||||||
|
},
|
||||||
|
"expenditure_concept_direct_suppressed": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "TRUE when expenditure_concept = 'total' and at least one requested (year, category) has intergovernmental rows but NO Direct rows in this corpus (typically a legacy aggregate-only family) -- those result rows report the intergovernmental leg alone, not Direct + IG. Always FALSE for expenditure_concept = 'primary' or 'direct'. See the affected rows' `notes` for the recovering recipe, if any."
|
||||||
|
},
|
||||||
|
"revenue_concept": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["general", "total"],
|
||||||
|
"description": "Which revenue concept produced this result, defined as crosswalk revenue_subtype sets (never item-code prefixes). 'general' (the default) is Census General Revenue: own_source + federal + state + local_aid. 'total' is Census Total Revenue: general plus utility, liquor store and insurance trust revenue. Census defines the first by subtracting the other three from the second (manual section 4.3). Meaningful for cog_revenue() results; spending results carry the default.",
|
||||||
|
"$comment": "The employee-retirement (X) codes inside insurance_trust stop at FY2016, so a 'total' series steps at the FY2016/FY2017 seam for collection-scope reasons (series breaks SB197-SB202)."
|
||||||
|
},
|
||||||
|
"harmonization": { "type": "object" },
|
||||||
|
"recipe": { "type": ["object", "null"] },
|
||||||
|
"suggestions": { "type": "array" },
|
||||||
"scope": { "type": "object" },
|
"scope": { "type": "object" },
|
||||||
"codes_summed": { "type": "object" },
|
"codes_summed": { "type": "object" },
|
||||||
"aggregate_fallback": { "type": ["object", "null"] },
|
"aggregate_fallback": { "type": ["object", "null"] },
|
||||||
"transformations":{ "type": "object" },
|
"transformations":{ "type": "object" },
|
||||||
"series_break_refs": { "type": "array", "items": { "type": "string" } },
|
"series_break_refs": { "type": "array", "items": { "type": "string" } },
|
||||||
|
"completion": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "What `complete = TRUE` filled. `applied` is FALSE on an ordinary query. `rows_filled` counts cells added to the requested grid, and `absence_means` maps each requested year to the meaning of an absent cell there ('census_zero' in a dense_source year, 'not_reported' in a sparse_source one). Filled rows carry `value_source` in the result: 'reported', 'census_zero' (amount 0 -- Census published $0), or 'not_reported' (amount NA -- unknown).",
|
||||||
|
"properties": {
|
||||||
|
"applied": { "type": "boolean" },
|
||||||
|
"rows_filled": { "type": "integer" },
|
||||||
|
"absence_means": { "type": "object" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"corpus_break_refs": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" },
|
||||||
|
"description": "Ids of catalogued series breaks whose fin_code is the literal 'ALL' -- caveats about the corpus as a whole (dollar precision across 1976/1977, imputation exclusion from 2002, the dense -> sparse representation change at 2012, the government id scheme change at 2017) rather than about one item code. Selected on the break_year window alone, so they do not depend on which codes a result contains. Disjoint from series_break_refs by construction: an entry qualifies the whole result, not one series."
|
||||||
|
},
|
||||||
"manifest": { "type": "object" },
|
"manifest": { "type": "object" },
|
||||||
"sql_query": { "type": "string" }
|
"sql_query": { "type": "string" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Category crosswalk. Numbered 11 (not with the other reference tables at
|
||||||
|
-- 30+) because the flow views (20-25) classify by MEMBERSHIP in this table
|
||||||
|
-- and DuckDB binds a view's sources eagerly at CREATE VIEW time, so it must
|
||||||
|
-- already exist when they register.
|
||||||
|
CREATE OR REPLACE VIEW summary_categories AS
|
||||||
|
SELECT *
|
||||||
|
FROM read_parquet('{url}data/summary_categories.parquet');
|
||||||
@@ -1,5 +1,22 @@
|
|||||||
|
-- Direct-side expenditure rows, classified by crosswalk MEMBERSHIP
|
||||||
|
-- (summary_categories.category_type = 'expenditure'), never by item-code
|
||||||
|
-- first letter: prefix Y alone spans revenue (Y01/Y02), expenditure
|
||||||
|
-- (Y05/Y06) and balance codes, so no first-letter allowlist can route it
|
||||||
|
-- (uscogdata#11, finding F-018). Which subtypes a query actually returns is
|
||||||
|
-- decided per expenditure_concept in R (.verb_spendrev); this view carries
|
||||||
|
-- every non-intergovernmental expenditure subtype: operations, capital,
|
||||||
|
-- assistance, interest, insurance_benefits.
|
||||||
|
--
|
||||||
|
-- The intergovernmental subtype (M/L/Q codes) is deliberately carved out
|
||||||
|
-- into ig_long: its legacy-era rows are published ONLY as aggregate-flagged
|
||||||
|
-- rows, so it cannot live behind this view's NOT is_aggregate filter (see
|
||||||
|
-- 24-ig_long.sql).
|
||||||
CREATE OR REPLACE VIEW spending_long AS
|
CREATE OR REPLACE VIEW spending_long AS
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM long
|
FROM long
|
||||||
WHERE LEFT(item_code, 1) IN ('E', 'F', 'G', 'K')
|
WHERE item_code IN (
|
||||||
|
SELECT item_code FROM summary_categories
|
||||||
|
WHERE category_type = 'expenditure'
|
||||||
|
AND spend_subtype <> 'intergovernmental'
|
||||||
|
)
|
||||||
AND NOT is_aggregate;
|
AND NOT is_aggregate;
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
|
-- Revenue rows, classified by crosswalk MEMBERSHIP rather than item-code
|
||||||
|
-- first letter (see 20-spending_long.sql for why prefixes cannot work).
|
||||||
|
--
|
||||||
|
-- Carries EVERY revenue subtype. Which of Census's two published concepts a
|
||||||
|
-- query actually returns is decided per revenue_concept in R
|
||||||
|
-- (.verb_spendrev), exactly as expenditure_concept narrows spending_long:
|
||||||
|
-- general = own_source + federal + state + local_aid (the default)
|
||||||
|
-- total = general + utility + liquor_store + insurance_trust
|
||||||
|
-- Census defines the first by subtracting the other three from the second
|
||||||
|
-- (manual section 4.3), so both concepts need all four families present here.
|
||||||
CREATE OR REPLACE VIEW revenue_long AS
|
CREATE OR REPLACE VIEW revenue_long AS
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM long
|
FROM long
|
||||||
WHERE LEFT(item_code, 1) IN ('T', 'A', 'U', 'B', 'C', 'D')
|
WHERE item_code IN (
|
||||||
|
SELECT item_code FROM summary_categories
|
||||||
|
WHERE category_type = 'revenue'
|
||||||
|
)
|
||||||
AND NOT is_aggregate;
|
AND NOT is_aggregate;
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Harmonized-basis twin of 20-spending_long.sql: same crosswalk-membership
|
||||||
|
-- classification, applied to harmonized_code (the code the row is folded
|
||||||
|
-- onto) rather than the published item_code. Safe because the harmonized
|
||||||
|
-- space is leaf-only and every harmonized_code in the corpus is a
|
||||||
|
-- summary_categories member (verified at fixture regen; a code the
|
||||||
|
-- crosswalk cannot classify would be silently dropped here).
|
||||||
|
CREATE OR REPLACE VIEW spending_long_harmonized AS
|
||||||
|
SELECT * REPLACE (harmonized_code AS item_code)
|
||||||
|
FROM long
|
||||||
|
WHERE NOT is_aggregate
|
||||||
|
AND harmonized_code IS NOT NULL
|
||||||
|
AND harmonized_code IN (
|
||||||
|
SELECT item_code FROM summary_categories
|
||||||
|
WHERE category_type = 'expenditure'
|
||||||
|
AND spend_subtype <> 'intergovernmental'
|
||||||
|
);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Harmonized-basis twin of 21-revenue_long.sql: same crosswalk-membership
|
||||||
|
-- classification (every revenue subtype; the concept narrows in R), applied
|
||||||
|
-- to harmonized_code rather than the published item_code.
|
||||||
|
CREATE OR REPLACE VIEW revenue_long_harmonized AS
|
||||||
|
SELECT * REPLACE (harmonized_code AS item_code)
|
||||||
|
FROM long
|
||||||
|
WHERE NOT is_aggregate
|
||||||
|
AND harmonized_code IS NOT NULL
|
||||||
|
AND harmonized_code IN (
|
||||||
|
SELECT item_code FROM summary_categories
|
||||||
|
WHERE category_type = 'revenue'
|
||||||
|
);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- Intergovernmental expenditure rows: crosswalk spend_subtype =
|
||||||
|
-- 'intergovernmental' (M = to local govts, L = to state govts, Q11/Q12/Q18
|
||||||
|
-- = state payments to school systems -- uscogdata#11, finding F-017).
|
||||||
|
--
|
||||||
|
-- Deliberately does NOT filter `NOT is_aggregate`, unlike spending_long. In the
|
||||||
|
-- wide era (<= FY2011) the IG families M05/M12/M47/M89/L47/L89 are published
|
||||||
|
-- ONLY as aggregate-flagged rows -- filtering them would hide ~70% of legacy IG
|
||||||
|
-- dollars and make Total silently collapse to Direct. This is safe because the
|
||||||
|
-- aggregate codes and their modern leaf components are strictly year-disjoint
|
||||||
|
-- (M47 ends 2011 / M94 starts 2012; M89 is aggregate only <= 2011 and a leaf
|
||||||
|
-- from 2012 alongside M91-93), so no row is ever counted twice. Same argument
|
||||||
|
-- the pipeline's recipe joins use.
|
||||||
|
--
|
||||||
|
-- `L--` stays excluded: it is the IG-to-state FAMILY TOTAL and genuinely
|
||||||
|
-- rolls up the L-NN codes, so including it would double-count. The crosswalk
|
||||||
|
-- deliberately carries no `--` family-total codes, so membership excludes it
|
||||||
|
-- (guarded by "the IG leg never includes the L-- family total" in
|
||||||
|
-- tests/testthat/test-expenditure-concept.R).
|
||||||
|
CREATE OR REPLACE VIEW ig_long AS
|
||||||
|
SELECT *
|
||||||
|
FROM long
|
||||||
|
WHERE item_code IN (
|
||||||
|
SELECT item_code FROM summary_categories
|
||||||
|
WHERE spend_subtype = 'intergovernmental'
|
||||||
|
);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- Harmonized-basis IG rows. Uses COALESCE(harmonized_code, item_code) rather
|
||||||
|
-- than harmonized_code alone: aggregate rows carry NO harmonized_code by
|
||||||
|
-- construction (harmonized space is leaf-only), so a plain
|
||||||
|
-- `harmonized_code IS NOT NULL` filter would drop every legacy IG aggregate --
|
||||||
|
-- in the bundled fixture corpus (year 2011; 2012+ all carry a harmonized_code)
|
||||||
|
-- that is $379,016,063k across 25,688 M rows and $2,277,458k across 19,266 L
|
||||||
|
-- rows (`SELECT year, LEFT(item_code,1), SUM(amt), COUNT(*) FROM ig_long
|
||||||
|
-- WHERE harmonized_code IS NULL GROUP BY 1, 2`). COALESCE keeps the one real
|
||||||
|
-- IG collapse rule (M38 -> M36, SB012, year-disjoint 1967-2011 vs 2012+)
|
||||||
|
-- while never dropping a row.
|
||||||
|
--
|
||||||
|
-- Membership is checked on the published item_code (mirroring 24-ig_long.sql)
|
||||||
|
-- rather than the COALESCEd code: every IG harmonization target (M36) is
|
||||||
|
-- itself an IG crosswalk member, so the two are equivalent, and item_code is
|
||||||
|
-- the column that exists on every row.
|
||||||
|
CREATE OR REPLACE VIEW ig_long_harmonized AS
|
||||||
|
SELECT * REPLACE (COALESCE(harmonized_code, item_code) AS item_code)
|
||||||
|
FROM long
|
||||||
|
WHERE item_code IN (
|
||||||
|
SELECT item_code FROM summary_categories
|
||||||
|
WHERE spend_subtype = 'intergovernmental'
|
||||||
|
);
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
CREATE OR REPLACE VIEW summary_categories AS
|
|
||||||
SELECT *
|
|
||||||
FROM read_parquet('{url}data/summary_categories.parquet');
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
CREATE OR REPLACE VIEW harmonization_map AS
|
||||||
|
SELECT *
|
||||||
|
FROM read_parquet('{url}data/harmonization_map.parquet');
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
CREATE OR REPLACE VIEW harmonization_recipes AS
|
||||||
|
SELECT *
|
||||||
|
FROM read_parquet('{url}data/harmonization_recipes.parquet');
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
CREATE OR REPLACE VIEW series_breaks_pq AS
|
||||||
|
SELECT *
|
||||||
|
FROM read_parquet('{url}data/series_breaks.parquet');
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
CREATE OR REPLACE VIEW representation AS
|
||||||
|
SELECT *
|
||||||
|
FROM read_parquet('{url}data/representation.parquet');
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
CREATE OR REPLACE VIEW code_set AS
|
||||||
|
SELECT *
|
||||||
|
FROM read_parquet('{url}data/code_set.parquet');
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE OR REPLACE VIEW spending_annotated_harmonized AS
|
||||||
|
SELECT
|
||||||
|
s.*,
|
||||||
|
x.gov_name AS xwalk_gov_name,
|
||||||
|
x.govs_type,
|
||||||
|
x.type_label,
|
||||||
|
x.fips_state AS xwalk_fips_state,
|
||||||
|
x.fips_county AS xwalk_fips_county,
|
||||||
|
x.fips_place,
|
||||||
|
x.population_acs,
|
||||||
|
c.category,
|
||||||
|
c.category_type,
|
||||||
|
c.spend_subtype
|
||||||
|
FROM spending_long_harmonized s
|
||||||
|
LEFT JOIN canonical_fips_xwalk x USING (canonical_govid)
|
||||||
|
LEFT JOIN summary_categories c USING (item_code);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE OR REPLACE VIEW revenue_annotated_harmonized AS
|
||||||
|
SELECT
|
||||||
|
s.*,
|
||||||
|
x.gov_name AS xwalk_gov_name,
|
||||||
|
x.govs_type,
|
||||||
|
x.type_label,
|
||||||
|
x.fips_state AS xwalk_fips_state,
|
||||||
|
x.fips_county AS xwalk_fips_county,
|
||||||
|
x.fips_place,
|
||||||
|
x.population_acs,
|
||||||
|
c.category,
|
||||||
|
c.category_type,
|
||||||
|
c.revenue_subtype
|
||||||
|
FROM revenue_long_harmonized s
|
||||||
|
LEFT JOIN canonical_fips_xwalk x USING (canonical_govid)
|
||||||
|
LEFT JOIN summary_categories c USING (item_code);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE OR REPLACE VIEW ig_annotated AS
|
||||||
|
SELECT
|
||||||
|
s.*,
|
||||||
|
x.gov_name AS xwalk_gov_name,
|
||||||
|
x.govs_type,
|
||||||
|
x.type_label,
|
||||||
|
x.fips_state AS xwalk_fips_state,
|
||||||
|
x.fips_county AS xwalk_fips_county,
|
||||||
|
x.fips_place,
|
||||||
|
x.population_acs,
|
||||||
|
c.category,
|
||||||
|
c.category_type,
|
||||||
|
c.spend_subtype
|
||||||
|
FROM ig_long s
|
||||||
|
LEFT JOIN canonical_fips_xwalk x USING (canonical_govid)
|
||||||
|
LEFT JOIN summary_categories c USING (item_code);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE OR REPLACE VIEW ig_annotated_harmonized AS
|
||||||
|
SELECT
|
||||||
|
s.*,
|
||||||
|
x.gov_name AS xwalk_gov_name,
|
||||||
|
x.govs_type,
|
||||||
|
x.type_label,
|
||||||
|
x.fips_state AS xwalk_fips_state,
|
||||||
|
x.fips_county AS xwalk_fips_county,
|
||||||
|
x.fips_place,
|
||||||
|
x.population_acs,
|
||||||
|
c.category,
|
||||||
|
c.category_type,
|
||||||
|
c.spend_subtype
|
||||||
|
FROM ig_long_harmonized s
|
||||||
|
LEFT JOIN canonical_fips_xwalk x USING (canonical_govid)
|
||||||
|
LEFT JOIN summary_categories c USING (item_code);
|
||||||
+10
-1
@@ -11,7 +11,8 @@ cog_find_peers(
|
|||||||
same_state = FALSE,
|
same_state = FALSE,
|
||||||
pop_range = c(0.7, 1.3),
|
pop_range = c(0.7, 1.3),
|
||||||
is_ratio = TRUE,
|
is_ratio = TRUE,
|
||||||
max_peers = 10L
|
max_peers = 10L,
|
||||||
|
coverage = c("all", "census", "consistent")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
\arguments{
|
\arguments{
|
||||||
@@ -34,6 +35,14 @@ target's population at `year` to produce absolute bounds. If `FALSE`,
|
|||||||
`pop_range` is interpreted as absolute population counts.}
|
`pop_range` is interpreted as absolute population counts.}
|
||||||
|
|
||||||
\item{max_peers}{Integer cap on the number of peers returned.}
|
\item{max_peers}{Integer cap on the number of peers returned.}
|
||||||
|
|
||||||
|
\item{coverage}{Survey-cycle handling; see [cog_peer_compare()]. Here it
|
||||||
|
governs the cohort VINTAGE when `year` is `NULL`: `"census"` snaps to the
|
||||||
|
most recent census year with an observed population, so a cohort is not
|
||||||
|
built from a sample year in which most of the candidate universe is
|
||||||
|
absent. `"consistent"` needs a year range, which cohort selection does not
|
||||||
|
have, so it selects like `"all"` and is carried on the result as
|
||||||
|
`attr(x, "coverage")` for [cog_peer_compare()].}
|
||||||
}
|
}
|
||||||
\value{
|
\value{
|
||||||
Tibble with columns `canonical_govid`, `gov_name`, `fips_state`,
|
Tibble with columns `canonical_govid`, `gov_name`, `fips_state`,
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ cog_geographic_rollup(
|
|||||||
category,
|
category,
|
||||||
years,
|
years,
|
||||||
per_capita = FALSE,
|
per_capita = FALSE,
|
||||||
adjust_to_year = NULL
|
adjust_to_year = NULL,
|
||||||
|
expenditure_concept = c("primary", "direct", "total"),
|
||||||
|
coverage = c("all", "census", "consistent")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
\arguments{
|
\arguments{
|
||||||
@@ -27,6 +29,33 @@ population from `gov_population_yearly`. Govs with missing population
|
|||||||
are excluded from the result.}
|
are excluded from the result.}
|
||||||
|
|
||||||
\item{adjust_to_year}{Integer base year for CPI-U conversion, or `NULL`.}
|
\item{adjust_to_year}{Integer base year for CPI-U conversion, or `NULL`.}
|
||||||
|
|
||||||
|
\item{expenditure_concept}{`"primary"` (default), `"direct"`, or
|
||||||
|
`"total"` -- see [cog_spending()] for the three concepts. `"total"` is
|
||||||
|
refused 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); `"primary"` and `"direct"` combine safely.}
|
||||||
|
|
||||||
|
\item{coverage}{How to handle the Census of Governments survey cycle,
|
||||||
|
which is a **complete census only in years ending in 2 and 7** -- every
|
||||||
|
other year is a sample, and the sample varies enormously (on the bundled
|
||||||
|
fixture, Wisconsin's 608-city universe reports 597 governments in FY2012
|
||||||
|
and 112 in FY2019).
|
||||||
|
|
||||||
|
* `"all"` (default) -- every unit that reported that year. Unchanged
|
||||||
|
behaviour, so existing code keeps working.
|
||||||
|
* `"census"` -- census years only. Aborts if the requested range holds
|
||||||
|
none, rather than silently returning nothing.
|
||||||
|
* `"consistent"` -- only units reporting in *every* requested year, giving
|
||||||
|
a balanced panel.
|
||||||
|
|
||||||
|
Regardless of mode, `provenance$coverage` always carries per-year
|
||||||
|
`n_units_reporting`, `n_units_expected` and `is_census_year`, and
|
||||||
|
`provenance$coverage_mode` records the mode. `is_census_year` is a
|
||||||
|
statement about the **survey calendar**, never a claim of completeness:
|
||||||
|
FY1967 is a census year in which only 97 of Wisconsin's 608 cities
|
||||||
|
report. `n_units_reporting` is the number that tells the truth.}
|
||||||
}
|
}
|
||||||
\value{
|
\value{
|
||||||
Tibble with columns `year`, `layer`, `canonical_govid`, `gov_name`,
|
Tibble with columns `year`, `layer`, `canonical_govid`, `gov_name`,
|
||||||
|
|||||||
@@ -32,8 +32,11 @@ the cross-vintage canonical-government registry. Operates in two modes:
|
|||||||
}
|
}
|
||||||
\details{
|
\details{
|
||||||
* **Utility mode** (single `name`, the original behavior): returns all
|
* **Utility mode** (single `name`, the original behavior): returns all
|
||||||
rows whose `gov_name` matches the regex case-insensitively, sorted by
|
rows whose `gov_name` contains `name` as a **literal, case-insensitive
|
||||||
`population_acs` descending. Useful for exploratory lookups.
|
substring**, sorted by `population_acs` descending. Useful for
|
||||||
|
exploratory lookups. Regex metacharacters in `name` are escaped, so a
|
||||||
|
government is findable by its own complete name even when that name
|
||||||
|
contains parentheses or a period.
|
||||||
* **Basket mode** (`length(name) > 1`): resolves each input row to a
|
* **Basket mode** (`length(name) > 1`): resolves each input row to a
|
||||||
single canonical govid and returns a tibble in input order, suitable
|
single canonical govid and returns a tibble in input order, suitable
|
||||||
for piping straight into [cog_spending()] / [cog_revenue()] /
|
for piping straight into [cog_spending()] / [cog_revenue()] /
|
||||||
@@ -45,7 +48,8 @@ the cross-vintage canonical-government registry. Operates in two modes:
|
|||||||
1. Filter `canonical_fips_xwalk` by `state` and (if non-NA) `type`.
|
1. Filter `canonical_fips_xwalk` by `state` and (if non-NA) `type`.
|
||||||
2. **Exact pass:** case-insensitive equality against `gov_name`.
|
2. **Exact pass:** case-insensitive equality against `gov_name`.
|
||||||
Single hit -> resolved. Multiple -> step 4.
|
Single hit -> resolved. Multiple -> step 4.
|
||||||
3. **Substring fallback:** case-insensitive regex against `gov_name`.
|
3. **Substring fallback:** case-insensitive literal substring against
|
||||||
|
`gov_name` (metacharacters escaped).
|
||||||
Single hit -> resolved (`match_method = "substring"`). Zero hits ->
|
Single hit -> resolved (`match_method = "substring"`). Zero hits ->
|
||||||
`status = "no_match"`. Multiple hits -> step 4.
|
`status = "no_match"`. Multiple hits -> step 4.
|
||||||
4. **Disambiguation:** if matches share one `govs_type`, pick the
|
4. **Disambiguation:** if matches share one `govs_type`, pick the
|
||||||
@@ -58,7 +62,7 @@ inputs (`ambiguous` / `no_match`) appear only in the sidecar.
|
|||||||
}
|
}
|
||||||
\examples{
|
\examples{
|
||||||
\dontrun{
|
\dontrun{
|
||||||
# Utility mode — exploratory regex lookup
|
# Utility mode — exploratory substring lookup
|
||||||
cog_gov_search("broward", state = "FL")
|
cog_gov_search("broward", state = "FL")
|
||||||
|
|
||||||
# Basket mode — resolve a known cohort
|
# Basket mode — resolve a known cohort
|
||||||
|
|||||||
+64
-2
@@ -10,7 +10,9 @@ cog_peer_compare(
|
|||||||
category,
|
category,
|
||||||
years,
|
years,
|
||||||
per_capita = TRUE,
|
per_capita = TRUE,
|
||||||
adjust_to_year = NULL
|
adjust_to_year = NULL,
|
||||||
|
expenditure_concept = c("primary", "direct", "total"),
|
||||||
|
coverage = c("all", "census", "consistent")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
\arguments{
|
\arguments{
|
||||||
@@ -27,6 +29,38 @@ cog_peer_compare(
|
|||||||
population.}
|
population.}
|
||||||
|
|
||||||
\item{adjust_to_year}{Integer base year for CPI-U conversion or `NULL`.}
|
\item{adjust_to_year}{Integer base year for CPI-U conversion or `NULL`.}
|
||||||
|
|
||||||
|
\item{expenditure_concept}{`"primary"` (default), `"direct"`, or
|
||||||
|
`"total"` -- see [cog_spending()] for the three concepts. `"total"` is
|
||||||
|
refused here because combining Total across peer sets counts
|
||||||
|
intergovernmental transfers twice; `"primary"` and `"direct"` combine
|
||||||
|
safely.}
|
||||||
|
|
||||||
|
\item{coverage}{How to handle the Census of Governments survey cycle,
|
||||||
|
which is a **complete census only in years ending in 2 and 7** -- every
|
||||||
|
other year is a sample, and the sample varies enormously (on the bundled
|
||||||
|
fixture, Wisconsin's 608-city universe reports 597 governments in FY2012
|
||||||
|
and 112 in FY2019).
|
||||||
|
|
||||||
|
* `"all"` (default) -- every unit that reported that year. Unchanged
|
||||||
|
behaviour, so existing code keeps working.
|
||||||
|
* `"census"` -- census years only. Aborts if the requested range holds
|
||||||
|
none, rather than silently returning nothing.
|
||||||
|
* `"consistent"` -- only units reporting in *every* requested year, giving
|
||||||
|
a balanced panel.
|
||||||
|
|
||||||
|
Regardless of mode, `provenance$coverage` always carries per-year
|
||||||
|
`n_units_reporting`, `n_units_expected` and `is_census_year`, and
|
||||||
|
`provenance$coverage_mode` records the mode. `is_census_year` is a
|
||||||
|
statement about the **survey calendar**, never a claim of completeness:
|
||||||
|
FY1967 is a census year in which only 97 of Wisconsin's 608 cities
|
||||||
|
report. `n_units_reporting` is the number that tells the truth.
|
||||||
|
|
||||||
|
The comparison target is exempt from `"consistent"` balancing -- it is the
|
||||||
|
subject of the comparison, not a member of the cohort -- and the
|
||||||
|
`summary_*` quantiles are computed AFTER the filter, so they describe the
|
||||||
|
cohort actually returned. `n_units_reporting` counts peers only, against
|
||||||
|
the cohort size: "3 of your 15 peers reported in FY2019".}
|
||||||
}
|
}
|
||||||
\value{
|
\value{
|
||||||
Tibble matching [cog_spending()]'s columns, plus a `role`
|
Tibble matching [cog_spending()]'s columns, plus a `role`
|
||||||
@@ -37,11 +71,39 @@ Tibble matching [cog_spending()]'s columns, plus a `role`
|
|||||||
`attr(peers, "cohort_year")`; `NA` when `peers` was a bare character
|
`attr(peers, "cohort_year")`; `NA` when `peers` was a bare character
|
||||||
vector). Provenance reports `verb = "cog_peer_compare"`, `peer_count`,
|
vector). Provenance reports `verb = "cog_peer_compare"`, `peer_count`,
|
||||||
`cohort_year`, and `cohort_govids`.
|
`cohort_year`, and `cohort_govids`.
|
||||||
|
|
||||||
|
**The `summary_*` rows are per-category quantiles: they are not additive.**
|
||||||
|
Each one is computed **within each `(year, spend_subtype,
|
||||||
|
category)` cell** across the peer set, so a `summary_p50` row is *the
|
||||||
|
median peer's value in that one category*, not *the value of the median
|
||||||
|
peer's total*. The median peer for Police and the median peer for Fire
|
||||||
|
are usually different governments, so summing `summary_*` rows across
|
||||||
|
categories does not give any peer's total and misstates the band it
|
||||||
|
appears to describe — measured at −32.7% to +251.0% across 24 years on
|
||||||
|
one cohort, with a sign flip at FY2012.
|
||||||
|
|
||||||
|
Facet by `role` **and** `category` (the documented use, and what the
|
||||||
|
rows are built for). For a genuine "median peer's total spending" line,
|
||||||
|
sum each peer's own categories first and take the quantile of those
|
||||||
|
per-government totals:
|
||||||
|
|
||||||
|
```r
|
||||||
|
library(dplyr)
|
||||||
|
cmp |>
|
||||||
|
filter(role %in% c("target", "peer")) |>
|
||||||
|
group_by(year, role, canonical_govid) |>
|
||||||
|
summarise(total = sum(amt_per_capita_real, na.rm = TRUE), .groups = "drop") |>
|
||||||
|
filter(role == "peer") |>
|
||||||
|
group_by(year) |>
|
||||||
|
summarise(p50 = quantile(total, 0.5, na.rm = TRUE))
|
||||||
|
```
|
||||||
}
|
}
|
||||||
\description{
|
\description{
|
||||||
Pulls spending for the target plus a peer set (either a
|
Pulls spending for the target plus a peer set (either a
|
||||||
[cog_find_peers()] result or a character vector of `canonical_govid`) and
|
[cog_find_peers()] result or a character vector of `canonical_govid`) and
|
||||||
appends peer-distribution summary rows (`summary_p25`, `summary_p50`,
|
appends peer-distribution summary rows (`summary_p25`, `summary_p50`,
|
||||||
`summary_p75`) so the result can be faceted by `role` in a single ggplot
|
`summary_p75`) so the result can be faceted by `role` in a single ggplot
|
||||||
call.
|
call. Those summary rows are quantiles **within each category**, not
|
||||||
|
quantiles of each peer's total — see the `@return` section before summing
|
||||||
|
them.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
% Generated by roxygen2: do not edit by hand
|
||||||
|
% Please edit documentation in R/recipes.R
|
||||||
|
\name{cog_recipes}
|
||||||
|
\alias{cog_recipes}
|
||||||
|
\title{List available harmonization recipes}
|
||||||
|
\usage{
|
||||||
|
cog_recipes(pattern = NULL)
|
||||||
|
}
|
||||||
|
\arguments{
|
||||||
|
\item{pattern}{Optional regex matched case-insensitively against
|
||||||
|
`recipe_id` or `label`.}
|
||||||
|
}
|
||||||
|
\value{
|
||||||
|
Tibble with columns `recipe_id`, `label`, `n_components`,
|
||||||
|
`year_min`, `year_max` (the min/max component year coverage), sorted by
|
||||||
|
`recipe_id`.
|
||||||
|
}
|
||||||
|
\description{
|
||||||
|
Recipes are multi-code cross-vintage series (see [cog_spending()]'s
|
||||||
|
`recipe` argument) catalogued in the corpus's `harmonization_recipes`
|
||||||
|
table. Use this to discover valid `recipe` ids.
|
||||||
|
}
|
||||||
+80
-2
@@ -9,7 +9,11 @@ cog_revenue(
|
|||||||
years,
|
years,
|
||||||
category = NULL,
|
category = NULL,
|
||||||
per_capita = FALSE,
|
per_capita = FALSE,
|
||||||
adjust_to_year = NULL
|
adjust_to_year = NULL,
|
||||||
|
basis = c("harmonized", "raw"),
|
||||||
|
recipe = NULL,
|
||||||
|
revenue_concept = c("general", "total"),
|
||||||
|
complete = FALSE
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
\arguments{
|
\arguments{
|
||||||
@@ -29,12 +33,86 @@ in that year).}
|
|||||||
|
|
||||||
\item{adjust_to_year}{Integer base year for CPI-U real-dollar conversion,
|
\item{adjust_to_year}{Integer base year for CPI-U real-dollar conversion,
|
||||||
or `NULL` for nominal only.}
|
or `NULL` for nominal only.}
|
||||||
|
|
||||||
|
\item{basis}{`"harmonized"` (default) sums item codes through the
|
||||||
|
cross-vintage harmonization mapping (folding series-break-affected
|
||||||
|
codes onto a comparable target and excluding aggregate / discontinued
|
||||||
|
rows -- see the `harmonization` block in `cog_explain()`); `"raw"`
|
||||||
|
reproduces the pre-Phase-R2 behavior (published item codes, no
|
||||||
|
folding). On a corpus with `schema_version < 5` (no harmonization
|
||||||
|
tables), `basis` silently resolves to `"raw"` when left at its default
|
||||||
|
and the resolution is recorded in the provenance; explicitly passing
|
||||||
|
`basis = "harmonized"` on such a corpus aborts. Ignored when `recipe`
|
||||||
|
is set (see below).}
|
||||||
|
|
||||||
|
\item{recipe}{Optional harmonization recipe id (see [cog_recipes()]) for
|
||||||
|
multi-code cross-vintage series that a 1:1 harmonized_code mapping
|
||||||
|
can't express (e.g. a wide-era aggregate that only splits into leaf
|
||||||
|
codes in the modern era). Mutually exclusive with `category`. The
|
||||||
|
result's subtype column reads `"recipe"` and `category` reads the
|
||||||
|
recipe's label. Requires `schema_version >= 5`. A recipe query bypasses
|
||||||
|
`basis` entirely (it joins `long` directly rather than going through
|
||||||
|
the `*_annotated`/`*_annotated_harmonized` views), so the `basis`
|
||||||
|
argument is ignored and the result's provenance reports
|
||||||
|
`basis = "recipe"` with an inert `harmonization` block (`applied =
|
||||||
|
FALSE`, pointing at the `recipe` block instead) rather than a
|
||||||
|
possibly-misleading `"harmonized"`/`"raw"` value.}
|
||||||
|
|
||||||
|
\item{revenue_concept}{Which of Census's two published revenue concepts to
|
||||||
|
return. Concepts are defined as sets of the crosswalk's `revenue_subtype`
|
||||||
|
values -- never as item-code first letters, which cannot classify
|
||||||
|
correctly (prefix `Y` spans revenue, expenditure and balance codes, and
|
||||||
|
prefix `X` does the same):
|
||||||
|
|
||||||
|
* `"general"` (default) -- Census General Revenue: `own_source` +
|
||||||
|
`federal` + `state` + `local_aid`. The manual defines this concept by
|
||||||
|
subtraction (section 4.3: *"General revenue comprises all revenue
|
||||||
|
except that classified as liquor store, utility, or insurance trust
|
||||||
|
revenue"*), so utility (`A91`-`A94`), liquor store (`A90`) and
|
||||||
|
insurance trust revenue are all excluded.
|
||||||
|
* `"total"` -- Census Total Revenue: every revenue subtype, i.e.
|
||||||
|
`general` plus utility, liquor store, and insurance trust revenue
|
||||||
|
(`Y01`/`Y02`/`Y04`/`Y11`/`Y12`/`Y51`/`Y52` and the employee-retirement
|
||||||
|
`X01`/`X02`/`X05`/`X08`).
|
||||||
|
|
||||||
|
The two are related by Census's own identity, `Total Revenue = General +
|
||||||
|
Utility + Liquor Store + Insurance Trust`.
|
||||||
|
|
||||||
|
Note that the employee-retirement (`X`) codes stop at FY2016, when those
|
||||||
|
systems moved out of the annual finance file into the separate Annual
|
||||||
|
Survey of Public Pensions, so a `"total"` series steps down at the
|
||||||
|
FY2016/FY2017 seam for reasons that are about collection scope rather
|
||||||
|
than revenue (series breaks `SB197`-`SB202`).}
|
||||||
|
|
||||||
|
\item{complete}{If `TRUE`, fill the requested grid so that a cell the
|
||||||
|
corpus does not carry still appears, labelled with **why** it is
|
||||||
|
missing, and add a `value_source` column to every row:
|
||||||
|
|
||||||
|
* `"reported"` — the corpus carries this cell.
|
||||||
|
* `"census_zero"` — dense-source year (`<= FY2011`), cell absent:
|
||||||
|
Census published `$0`. `amt_nominal` is `0`.
|
||||||
|
* `"not_reported"` — sparse-source year (`>= FY2012`), cell absent: the
|
||||||
|
government did not report, and the value is unknown. `amt_nominal` is
|
||||||
|
`NA`, **not** `0` — writing a zero there would invent data.
|
||||||
|
|
||||||
|
The grid comes from the corpus's `code_set` table, scoped to each
|
||||||
|
government's own type, so a county is never filled with cells only a
|
||||||
|
state can report. Reported rows are passed through untouched.
|
||||||
|
|
||||||
|
Defaults to `FALSE` (the historical behaviour: absent cells simply do
|
||||||
|
not appear). Needs a corpus published from 2026-07-29 onward, which is
|
||||||
|
when `representation`/`code_set` began shipping; aborts with class
|
||||||
|
`uscogdata_representation_unavailable` otherwise. Not available with
|
||||||
|
`recipe` or with `expenditure_concept = "total"` (class
|
||||||
|
`uscogdata_complete_unsupported`) — neither draws its cells from
|
||||||
|
`code_set`.}
|
||||||
}
|
}
|
||||||
\value{
|
\value{
|
||||||
Tibble with columns `year`, `canonical_govid`, `gov_name`,
|
Tibble with columns `year`, `canonical_govid`, `gov_name`,
|
||||||
`revenue_subtype`, `category`, `amt_nominal`, optional `amt_real`,
|
`revenue_subtype`, `category`, `amt_nominal`, optional `amt_real`,
|
||||||
optional `amt_per_capita_nominal`, optional `amt_per_capita_real`,
|
optional `amt_per_capita_nominal`, optional `amt_per_capita_real`,
|
||||||
optional `pop_source`, `codes_included`, `aggregate_fallback`, `notes`.
|
optional `pop_source`, `codes_included`, `aggregate_fallback`, `notes`,
|
||||||
|
and `value_source` when `complete = TRUE`.
|
||||||
}
|
}
|
||||||
\description{
|
\description{
|
||||||
Mirror of [cog_spending()] for revenue categories. One row per
|
Mirror of [cog_spending()] for revenue categories. One row per
|
||||||
|
|||||||
+98
-3
@@ -9,7 +9,11 @@ cog_spending(
|
|||||||
years,
|
years,
|
||||||
category = NULL,
|
category = NULL,
|
||||||
per_capita = FALSE,
|
per_capita = FALSE,
|
||||||
adjust_to_year = NULL
|
adjust_to_year = NULL,
|
||||||
|
basis = c("harmonized", "raw"),
|
||||||
|
recipe = NULL,
|
||||||
|
expenditure_concept = c("primary", "direct", "total"),
|
||||||
|
complete = FALSE
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
\arguments{
|
\arguments{
|
||||||
@@ -29,13 +33,104 @@ in that year).}
|
|||||||
|
|
||||||
\item{adjust_to_year}{Integer base year for CPI-U real-dollar conversion,
|
\item{adjust_to_year}{Integer base year for CPI-U real-dollar conversion,
|
||||||
or `NULL` for nominal only.}
|
or `NULL` for nominal only.}
|
||||||
|
|
||||||
|
\item{basis}{`"harmonized"` (default) sums item codes through the
|
||||||
|
cross-vintage harmonization mapping (folding series-break-affected
|
||||||
|
codes onto a comparable target and excluding aggregate / discontinued
|
||||||
|
rows -- see the `harmonization` block in `cog_explain()`); `"raw"`
|
||||||
|
reproduces the pre-Phase-R2 behavior (published item codes, no
|
||||||
|
folding). On a corpus with `schema_version < 5` (no harmonization
|
||||||
|
tables), `basis` silently resolves to `"raw"` when left at its default
|
||||||
|
and the resolution is recorded in the provenance; explicitly passing
|
||||||
|
`basis = "harmonized"` on such a corpus aborts. Ignored when `recipe`
|
||||||
|
is set (see below).}
|
||||||
|
|
||||||
|
\item{recipe}{Optional harmonization recipe id (see [cog_recipes()]) for
|
||||||
|
multi-code cross-vintage series that a 1:1 harmonized_code mapping
|
||||||
|
can't express (e.g. a wide-era aggregate that only splits into leaf
|
||||||
|
codes in the modern era). Mutually exclusive with `category`. The
|
||||||
|
result's subtype column reads `"recipe"` and `category` reads the
|
||||||
|
recipe's label. Requires `schema_version >= 5`. A recipe query bypasses
|
||||||
|
`basis` entirely (it joins `long` directly rather than going through
|
||||||
|
the `*_annotated`/`*_annotated_harmonized` views), so the `basis`
|
||||||
|
argument is ignored and the result's provenance reports
|
||||||
|
`basis = "recipe"` with an inert `harmonization` block (`applied =
|
||||||
|
FALSE`, pointing at the `recipe` block instead) rather than a
|
||||||
|
possibly-misleading `"harmonized"`/`"raw"` value.}
|
||||||
|
|
||||||
|
\item{expenditure_concept}{Which spending concept to return. Concepts are
|
||||||
|
defined as sets of the crosswalk's `spend_subtype` values -- never as
|
||||||
|
item-code first letters, which cannot classify correctly (prefix `Y`
|
||||||
|
alone spans revenue, expenditure, and balance codes):
|
||||||
|
|
||||||
|
* `"primary"` (default) -- the government's own service provision:
|
||||||
|
`operations` + `capital` + `assistance` subtypes.
|
||||||
|
* `"direct"` -- Census's published Direct Expenditure: `primary` plus
|
||||||
|
`interest` (interest on debt) and `insurance_benefits` (insurance
|
||||||
|
trust benefit payments, e.g. pensions -- Census manual section
|
||||||
|
5.2.2.1 includes payments to retirees in Direct).
|
||||||
|
* `"total"` -- `direct` plus the intergovernmental leg: payments to
|
||||||
|
local governments (`M` codes), to the state government (`L` codes,
|
||||||
|
excluding the `L--` family-total rollup), and state payments to
|
||||||
|
school systems (`Q11`/`Q12`/`Q18`), so results gain rows with
|
||||||
|
`spend_subtype == "intergovernmental"`. Requires the active corpus's
|
||||||
|
`summary_categories` to carry M/L rows (added by cog_pipeline PR
|
||||||
|
#59); aborts with class `uscogdata_ig_categories_unsupported` on an
|
||||||
|
older corpus rather than silently under-reporting. Mutually
|
||||||
|
exclusive with `recipe` (a recipe already defines its own component
|
||||||
|
codes).
|
||||||
|
|
||||||
|
**Do not sum `"total"` results across levels of government** (e.g.
|
||||||
|
state + county + city): a state's `M12` payment to a school district is
|
||||||
|
the same dollar the district reports as its own direct `E12`, so
|
||||||
|
summing both double-counts it. This matters in particular with
|
||||||
|
[cog_geographic_rollup()], which sums across exactly that kind of
|
||||||
|
multi-layer government set.
|
||||||
|
|
||||||
|
In the legacy wide era (<= FY2011), some functions are published ONLY
|
||||||
|
as an aggregate-flagged family total (e.g. Corrections' `E04`/`E05`
|
||||||
|
split), which the Direct leg excludes by construction but the IG leg
|
||||||
|
deliberately keeps (see `inst/sql/24-ig_long.sql`). For a `"total"`
|
||||||
|
query, any (year, category) where this leaves intergovernmental rows
|
||||||
|
with NO Direct counterpart is flagged: the affected rows' `notes`
|
||||||
|
name the harmonization recipe that recovers the missing Direct
|
||||||
|
component (when one exists), and
|
||||||
|
`provenance$expenditure_concept_direct_suppressed` is `TRUE` -- the
|
||||||
|
figure in those rows is the intergovernmental leg alone, not Direct +
|
||||||
|
IG.}
|
||||||
|
|
||||||
|
\item{complete}{If `TRUE`, fill the requested grid so that a cell the
|
||||||
|
corpus does not carry still appears, labelled with **why** it is
|
||||||
|
missing, and add a `value_source` column to every row:
|
||||||
|
|
||||||
|
* `"reported"` — the corpus carries this cell.
|
||||||
|
* `"census_zero"` — dense-source year (`<= FY2011`), cell absent:
|
||||||
|
Census published `$0`. `amt_nominal` is `0`.
|
||||||
|
* `"not_reported"` — sparse-source year (`>= FY2012`), cell absent: the
|
||||||
|
government did not report, and the value is unknown. `amt_nominal` is
|
||||||
|
`NA`, **not** `0` — writing a zero there would invent data.
|
||||||
|
|
||||||
|
The grid comes from the corpus's `code_set` table, scoped to each
|
||||||
|
government's own type, so a county is never filled with cells only a
|
||||||
|
state can report. Reported rows are passed through untouched.
|
||||||
|
|
||||||
|
Defaults to `FALSE` (the historical behaviour: absent cells simply do
|
||||||
|
not appear). Needs a corpus published from 2026-07-29 onward, which is
|
||||||
|
when `representation`/`code_set` began shipping; aborts with class
|
||||||
|
`uscogdata_representation_unavailable` otherwise. Not available with
|
||||||
|
`recipe` or with `expenditure_concept = "total"` (class
|
||||||
|
`uscogdata_complete_unsupported`) — neither draws its cells from
|
||||||
|
`code_set`.}
|
||||||
}
|
}
|
||||||
\value{
|
\value{
|
||||||
Tibble with columns `year`, `canonical_govid`, `gov_name`,
|
Tibble with columns `year`, `canonical_govid`, `gov_name`,
|
||||||
`spend_subtype`, `category`, `amt_nominal`, optional `amt_real`,
|
`spend_subtype`, `category`, `amt_nominal`, optional `amt_real`,
|
||||||
optional `amt_per_capita_nominal`, optional `amt_per_capita_real`,
|
optional `amt_per_capita_nominal`, optional `amt_per_capita_real`,
|
||||||
optional `pop_source`, `codes_included`, `aggregate_fallback`, `notes`.
|
optional `pop_source`, `codes_included`, `aggregate_fallback`, `notes`,
|
||||||
Carries a `provenance` attribute matching `inst/schemas/provenance-v1.json`.
|
and `value_source` when `complete = TRUE`.
|
||||||
|
Carries a `provenance` attribute matching `inst/schemas/provenance-v1.json`,
|
||||||
|
whose `completion` block reports `applied`, `rows_filled`, and the
|
||||||
|
per-year `absence_means` rule that was applied.
|
||||||
}
|
}
|
||||||
\description{
|
\description{
|
||||||
One row per `(year, canonical_govid, spend_subtype, category)`. Amounts are
|
One row per `(year, canonical_govid, spend_subtype, category)`. Amounts are
|
||||||
|
|||||||
@@ -6,6 +6,33 @@ fixture_corpus_path <- function() {
|
|||||||
if (nzchar(p)) paste0(p, "/") else ""
|
if (nzchar(p)) paste0(p, "/") else ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Path to a file in the SOURCE tree (README.md, man/*.Rd, vignettes/*.Rmd),
|
||||||
|
# or "" when it isn't there.
|
||||||
|
#
|
||||||
|
# Tests that assert on documentation content have to read the sources, and the
|
||||||
|
# sources only exist when the suite runs from a checkout. Under R CMD check the
|
||||||
|
# suite runs from the INSTALLED package, where man/ and vignettes/ are not
|
||||||
|
# shipped and `../../README.md` does not resolve -- so those tests must skip
|
||||||
|
# rather than error. CI runs testthat::test_local() from the checkout BEFORE
|
||||||
|
# rcmdcheck, so the assertions are still enforced on every push; this only
|
||||||
|
# stops them from failing a context that structurally cannot satisfy them.
|
||||||
|
source_tree_path <- function(...) {
|
||||||
|
p <- testthat::test_path("..", "..", ...)
|
||||||
|
if (file.exists(p)) p else ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Skip unless every named source file is present (see source_tree_path()).
|
||||||
|
skip_if_no_source_tree <- function(...) {
|
||||||
|
paths <- vapply(list(...), function(rel) do.call(source_tree_path, as.list(rel)),
|
||||||
|
character(1))
|
||||||
|
missing <- vapply(paths, function(p) !nzchar(p), logical(1))
|
||||||
|
testthat::skip_if(
|
||||||
|
any(missing),
|
||||||
|
"package source tree not available (running against the installed package)"
|
||||||
|
)
|
||||||
|
invisible(paths)
|
||||||
|
}
|
||||||
|
|
||||||
# Skip a test if no corpus is reachable (bundled fixture or explicit remote URL).
|
# Skip a test if no corpus is reachable (bundled fixture or explicit remote URL).
|
||||||
skip_if_no_corpus <- function() {
|
skip_if_no_corpus <- function() {
|
||||||
p <- fixture_corpus_path()
|
p <- fixture_corpus_path()
|
||||||
@@ -26,3 +53,104 @@ with_fixture_corpus <- function(code) {
|
|||||||
}, add = TRUE)
|
}, add = TRUE)
|
||||||
force(code)
|
force(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Copy the bundled fixture to a temp dir with manifest.json's schema_version
|
||||||
|
# patched to `version`, then run `code` against it with a clean session
|
||||||
|
# (mirrors with_fixture_corpus()). Used to exercise the v4/v5 dual-accept
|
||||||
|
# path without a second physical fixture tree: a real v4 corpus has no
|
||||||
|
# harmonization_map/harmonization_recipes/series_breaks parquet files, but
|
||||||
|
# .register_views() only *reads* those when schema_version >= 5 (see
|
||||||
|
# R/views.R), so a doctored copy of the (v5) bundled fixture with the
|
||||||
|
# manifest's schema_version knocked down to 4 is a faithful stand-in.
|
||||||
|
with_doctored_schema_version <- function(version, code) {
|
||||||
|
src <- fixture_corpus_path()
|
||||||
|
tmp <- withr::local_tempdir(.local_envir = parent.frame())
|
||||||
|
file.copy(list.files(src, full.names = TRUE), tmp, recursive = TRUE)
|
||||||
|
|
||||||
|
manifest_path <- file.path(tmp, "manifest.json")
|
||||||
|
m <- jsonlite::fromJSON(manifest_path, simplifyVector = FALSE)
|
||||||
|
m$schema_version <- as.integer(version)
|
||||||
|
writeLines(
|
||||||
|
jsonlite::toJSON(m, auto_unbox = TRUE, pretty = TRUE, null = "null"),
|
||||||
|
manifest_path
|
||||||
|
)
|
||||||
|
|
||||||
|
old_url <- Sys.getenv("USCOGDATA_URL", unset = NA)
|
||||||
|
uscogdata:::cog_close()
|
||||||
|
Sys.setenv(USCOGDATA_URL = paste0(tmp, "/"))
|
||||||
|
on.exit({
|
||||||
|
uscogdata:::cog_close()
|
||||||
|
if (is.na(old_url)) Sys.unsetenv("USCOGDATA_URL") else Sys.setenv(USCOGDATA_URL = old_url)
|
||||||
|
}, add = TRUE)
|
||||||
|
force(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Copy the bundled fixture to a temp dir with representation.parquet and
|
||||||
|
# code_set.parquet removed (and dropped from the manifest's metadata list),
|
||||||
|
# then run `code` against it. Models a corpus published BEFORE sparsification:
|
||||||
|
# schema_version is left alone deliberately, because it was never bumped for
|
||||||
|
# that change -- the pre-sparsification fixture this package shipped until
|
||||||
|
# 2026-07-30 was schema v6 and carried neither table. Presence in the manifest
|
||||||
|
# is therefore the only honest signal, and this helper is what proves the
|
||||||
|
# package keys off it rather than off the version number.
|
||||||
|
with_corpus_missing_representation <- function(code) {
|
||||||
|
src <- fixture_corpus_path()
|
||||||
|
tmp <- withr::local_tempdir(.local_envir = parent.frame())
|
||||||
|
file.copy(list.files(src, full.names = TRUE), tmp, recursive = TRUE)
|
||||||
|
|
||||||
|
dropped <- c("representation.parquet", "code_set.parquet")
|
||||||
|
file.remove(file.path(tmp, "data", dropped))
|
||||||
|
|
||||||
|
manifest_path <- file.path(tmp, "manifest.json")
|
||||||
|
m <- jsonlite::fromJSON(manifest_path, simplifyVector = FALSE)
|
||||||
|
m$files$metadata <- Filter(
|
||||||
|
function(f) !basename(f$path) %in% dropped, m$files$metadata
|
||||||
|
)
|
||||||
|
writeLines(
|
||||||
|
jsonlite::toJSON(m, auto_unbox = TRUE, pretty = TRUE, null = "null"),
|
||||||
|
manifest_path
|
||||||
|
)
|
||||||
|
|
||||||
|
old_url <- Sys.getenv("USCOGDATA_URL", unset = NA)
|
||||||
|
uscogdata:::cog_close()
|
||||||
|
Sys.setenv(USCOGDATA_URL = paste0(tmp, "/"))
|
||||||
|
on.exit({
|
||||||
|
uscogdata:::cog_close()
|
||||||
|
if (is.na(old_url)) Sys.unsetenv("USCOGDATA_URL") else Sys.setenv(USCOGDATA_URL = old_url)
|
||||||
|
}, add = TRUE)
|
||||||
|
force(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Copy the bundled fixture to a temp dir with summary_categories.parquet
|
||||||
|
# rewritten to drop every M/L (intergovernmental) row, then run `code`
|
||||||
|
# against it with a clean session (mirrors with_fixture_corpus()/
|
||||||
|
# with_doctored_schema_version()). Models a real pre-cog_pipeline-PR#59
|
||||||
|
# corpus: the 66 M/L category rows shipped with NO schema_version bump (see
|
||||||
|
# C2 in the expenditure-concept review), so schema_version is left
|
||||||
|
# untouched here -- only the category data itself is rolled back.
|
||||||
|
with_corpus_missing_ig_categories <- function(code) {
|
||||||
|
src <- fixture_corpus_path()
|
||||||
|
tmp <- withr::local_tempdir(.local_envir = parent.frame())
|
||||||
|
file.copy(list.files(src, full.names = TRUE), tmp, recursive = TRUE)
|
||||||
|
|
||||||
|
cats_path <- file.path(tmp, "data", "summary_categories.parquet")
|
||||||
|
filtered_path <- file.path(tmp, "data", "summary_categories_filtered.parquet")
|
||||||
|
write_con <- DBI::dbConnect(duckdb::duckdb())
|
||||||
|
on.exit(DBI::dbDisconnect(write_con, shutdown = TRUE), add = TRUE)
|
||||||
|
DBI::dbExecute(write_con, sprintf(
|
||||||
|
"COPY (SELECT * FROM read_parquet(%s) WHERE LEFT(item_code, 1) NOT IN ('M', 'L'))
|
||||||
|
TO %s (FORMAT PARQUET)",
|
||||||
|
uscogdata:::.sql_lit_chr(cats_path), uscogdata:::.sql_lit_chr(filtered_path)
|
||||||
|
))
|
||||||
|
file.remove(cats_path)
|
||||||
|
file.rename(filtered_path, cats_path)
|
||||||
|
|
||||||
|
old_url <- Sys.getenv("USCOGDATA_URL", unset = NA)
|
||||||
|
uscogdata:::cog_close()
|
||||||
|
Sys.setenv(USCOGDATA_URL = paste0(tmp, "/"))
|
||||||
|
on.exit({
|
||||||
|
uscogdata:::cog_close()
|
||||||
|
if (is.na(old_url)) Sys.unsetenv("USCOGDATA_URL") else Sys.setenv(USCOGDATA_URL = old_url)
|
||||||
|
}, add = TRUE)
|
||||||
|
force(code)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Helper for the Madison-walkthrough finding tests (uscogdata #11-#16).
|
||||||
|
#
|
||||||
|
# Those tests all assert something about what a `cog_*` verb includes or
|
||||||
|
# excludes. The expected amounts must therefore come from the RAW corpus, never
|
||||||
|
# from the verb under test: verifying an absence through the filter that creates
|
||||||
|
# it proves nothing. `wt_raw_*()` opens its own DuckDB connection straight onto
|
||||||
|
# the corpus's `long` parquet partitions, bypassing uscogdata's SQL views (and
|
||||||
|
# therefore its `flow_prefixes` filtering) entirely.
|
||||||
|
|
||||||
|
wt_corpus_glob <- function() {
|
||||||
|
url <- Sys.getenv("USCOGDATA_URL")
|
||||||
|
if (!nzchar(url)) testthat::skip("USCOGDATA_URL is not set")
|
||||||
|
paste0(sub("/$", "", url), "/data/long/**/*.parquet")
|
||||||
|
}
|
||||||
|
|
||||||
|
wt_raw_query <- function(sql) {
|
||||||
|
con <- DBI::dbConnect(duckdb::duckdb())
|
||||||
|
on.exit(DBI::dbDisconnect(con, shutdown = TRUE), add = TRUE)
|
||||||
|
DBI::dbGetQuery(con, sql)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sum of `amt` (in $1,000s, as the corpus stores it) for one government-year,
|
||||||
|
# restricted either to an explicit set of item codes or to a set of first-letter
|
||||||
|
# prefixes. Aggregate rows are excluded, matching every published verb.
|
||||||
|
wt_raw_amt <- function(govid, year, codes = NULL, prefixes = NULL) {
|
||||||
|
stopifnot(xor(is.null(codes), is.null(prefixes)))
|
||||||
|
filter_sql <- if (!is.null(codes)) {
|
||||||
|
paste0("item_code IN (", paste0("'", codes, "'", collapse = ", "), ")")
|
||||||
|
} else {
|
||||||
|
paste0("LEFT(item_code, 1) IN (", paste0("'", prefixes, "'", collapse = ", "), ")")
|
||||||
|
}
|
||||||
|
out <- wt_raw_query(paste0(
|
||||||
|
"SELECT COALESCE(SUM(amt), 0) AS amt FROM read_parquet('", wt_corpus_glob(), "') ",
|
||||||
|
"WHERE canonical_govid = '", govid, "' AND year = ", year,
|
||||||
|
" AND NOT is_aggregate AND ", filter_sql
|
||||||
|
))
|
||||||
|
out$amt[[1]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# The item codes a verb reports having summed, flattened out of the
|
||||||
|
# comma-separated `codes_included` column.
|
||||||
|
wt_codes_included <- function(df) {
|
||||||
|
sort(unique(trimws(unlist(strsplit(stats::na.omit(df$codes_included), ",")))))
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Madison walkthrough audit -- finding F-004. Tracked as uscogdata#15.
|
||||||
|
# See docs/walkthroughs/FINDINGS.md in cog_explorer.
|
||||||
|
#
|
||||||
|
# The raw Census files report thousands of dollars; this package multiplies by
|
||||||
|
# 1000 and returns full US dollars. That is the friendlier choice and is not
|
||||||
|
# wrong -- but cog_explorer's CLAUDE.md states "All raw `amt` values are in
|
||||||
|
# $1,000s", so a reader who applies that rule to amt_nominal overstates every
|
||||||
|
# figure by 1000x, and gets a plausible-looking number rather than an obvious
|
||||||
|
# error. The audit rates this the highest-consequence definitional gap it found.
|
||||||
|
#
|
||||||
|
# Deliberately NOT asserted here: man/cog_spending.Rd and man/cog_revenue.Rd,
|
||||||
|
# which ALREADY carry the statement in their @return sections (verified
|
||||||
|
# 2026-07-29), as does cog-api's data-dictionary.md (since 2b71b41). The gap is
|
||||||
|
# in the surfaces a reader meets first and in cog_explorer's own conventions
|
||||||
|
# doc -- see uscogdata#15 for the full surface-by-surface table and for the two
|
||||||
|
# secondary tasks (cog_explorer/CLAUDE.md, which has no git remote, and
|
||||||
|
# cog-api's llms.txt, which is silent on units).
|
||||||
|
|
||||||
|
test_that("returned amounts are documented as full US dollars where readers meet the package", {
|
||||||
|
|
||||||
|
# README and vignettes ship only in the source tree, not in the installed
|
||||||
|
# package, so these assertions cannot run under R CMD check -- CI's earlier
|
||||||
|
# testthat::test_local() step is what enforces them. See
|
||||||
|
# skip_if_no_source_tree() in helper-fixture.R.
|
||||||
|
docs <- skip_if_no_source_tree(
|
||||||
|
"README.md",
|
||||||
|
c("vignettes", "total-spending.Rmd"),
|
||||||
|
c("vignettes", "population-denominators.Rmd")
|
||||||
|
)
|
||||||
|
|
||||||
|
says_units <- function(path) {
|
||||||
|
txt <- paste(readLines(path, warn = FALSE), collapse = " ")
|
||||||
|
grepl("full US dollars|full U\\.S\\. dollars", txt, ignore.case = TRUE) &&
|
||||||
|
grepl("\\$1,000s|thousands of dollars", txt, ignore.case = TRUE)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (path in docs) expect_true(says_units(path))
|
||||||
|
|
||||||
|
# Pin the documented claim to the actual behaviour, so the two cannot drift.
|
||||||
|
# The expected raw amount is read straight from the corpus's parquet
|
||||||
|
# partitions -- never through cog_spending(), which is the thing being
|
||||||
|
# described. Madison FY2020: E/F/G = 623,347 ($1,000s) -> $623,347,000.
|
||||||
|
raw_thousands <- wt_raw_amt("552025209777", 2020L, prefixes = c("E", "F", "G"))
|
||||||
|
expect_equal(raw_thousands, 623347)
|
||||||
|
|
||||||
|
returned <- cog_spending(govid = "552025209777", years = 2020L)
|
||||||
|
expect_equal(sum(returned$amt_nominal), raw_thousands * 1000)
|
||||||
|
|
||||||
|
units <- attr(returned, "provenance")$transformations$units_conversion
|
||||||
|
expect_true(units$applied)
|
||||||
|
expect_equal(units$multiplier, 1000)
|
||||||
|
})
|
||||||
@@ -8,22 +8,55 @@ test_that("cog_categories returns all categories grouped by subtype", {
|
|||||||
expect_gt(nrow(r), 10L)
|
expect_gt(nrow(r), 10L)
|
||||||
# corpus preserves Census-native "expenditure" vocabulary; the API takes
|
# corpus preserves Census-native "expenditure" vocabulary; the API takes
|
||||||
# "spending" as a friendlier alias.
|
# "spending" as a friendlier alias.
|
||||||
expect_setequal(unique(r$category_type), c("expenditure", "revenue"))
|
#
|
||||||
|
# `balance` joined as a third category_type with the cash-and-security
|
||||||
|
# holding codes (pipeline#76). `cog_categories()` is a CATALOGUE verb, not a
|
||||||
|
# money verb, so it surfaces every category_type the corpus carries -- the
|
||||||
|
# stock/flow guard belongs on cog_spending()/cog_revenue(), which must never
|
||||||
|
# return a balance row.
|
||||||
|
expect_setequal(unique(r$category_type),
|
||||||
|
c("expenditure", "revenue", "balance"))
|
||||||
})
|
})
|
||||||
|
|
||||||
test_that("cog_categories(type = 'spending') returns only expenditure rows", {
|
test_that("cog_categories(type = 'spending') returns only expenditure rows", {
|
||||||
skip_if_no_corpus()
|
skip_if_no_corpus()
|
||||||
r <- cog_categories(type = "spending")
|
r <- cog_categories(type = "spending")
|
||||||
expect_true(all(r$category_type == "expenditure"))
|
expect_true(all(r$category_type == "expenditure"))
|
||||||
expect_true(all(r$subtype %in% c("operations", "capital")))
|
# "assistance" (the J-prefix aid/benefit codes) joined the vocabulary with
|
||||||
|
# the crosswalk completion in cog_pipeline#60/#65 -- every flow code
|
||||||
|
# carrying dollars now maps to a category.
|
||||||
|
# `interest` (I89, I91-I94) and `insurance_benefits` (Y05/Y06/Y14/Y53)
|
||||||
|
# joined with the I/Q/Y flow batch -- the last two characters of Census's
|
||||||
|
# expenditure taxonomy. `interest` is what makes the three-concept model
|
||||||
|
# computable: primary = direct minus debt service.
|
||||||
|
expect_true(all(r$subtype %in%
|
||||||
|
c("operations", "capital", "intergovernmental", "assistance",
|
||||||
|
"interest", "insurance_benefits")))
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("cog_categories surfaces the intergovernmental spending subtype", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
r <- cog_categories(type = "spending")
|
||||||
|
expect_true("intergovernmental" %in% r$subtype)
|
||||||
|
# IG rows reuse the existing functional categories -- they add a subtype,
|
||||||
|
# not new category values.
|
||||||
|
ig_cats <- sort(unique(r$category[r$subtype == "intergovernmental"]))
|
||||||
|
direct_cats <- sort(unique(r$category[r$subtype != "intergovernmental"]))
|
||||||
|
expect_true(all(ig_cats %in% c(direct_cats, "Other Education")))
|
||||||
})
|
})
|
||||||
|
|
||||||
test_that("cog_categories(type = 'revenue') returns only revenue rows", {
|
test_that("cog_categories(type = 'revenue') returns only revenue rows", {
|
||||||
skip_if_no_corpus()
|
skip_if_no_corpus()
|
||||||
r <- cog_categories(type = "revenue")
|
r <- cog_categories(type = "revenue")
|
||||||
expect_true(all(r$category_type == "revenue"))
|
expect_true(all(r$category_type == "revenue"))
|
||||||
|
# The four non-general subtypes are deliberately NOT own_source: Census's
|
||||||
|
# General Revenue excludes insurance trust (Y01 alone is $1.31T corpus-wide,
|
||||||
|
# plus the employee-retirement X codes), utility (A91-A94) and liquor store
|
||||||
|
# (A90) revenue by definition, which is what makes both of its published
|
||||||
|
# revenue concepts computable -- see `revenue_concept` in `?cog_revenue`.
|
||||||
expect_true(all(r$subtype %in%
|
expect_true(all(r$subtype %in%
|
||||||
c("own_source", "federal", "state", "local_aid")))
|
c("own_source", "federal", "state", "local_aid",
|
||||||
|
"insurance_trust", "utility", "liquor_store")))
|
||||||
})
|
})
|
||||||
|
|
||||||
test_that("cog_categories(pattern = ...) filters case-insensitively", {
|
test_that("cog_categories(pattern = ...) filters case-insensitively", {
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
# tests/testthat/test-complete.R
|
||||||
|
#
|
||||||
|
# uscogdata#18. The published corpus no longer stores the wide era's explicit
|
||||||
|
# zeros (cog_pipeline#64, series break SB194), so absence means two different
|
||||||
|
# things:
|
||||||
|
#
|
||||||
|
# <= FY2011 (dense_source) : cell absent => Census published $0
|
||||||
|
# >= FY2012 (sparse_source): cell absent => not reported, unknown
|
||||||
|
#
|
||||||
|
# `complete = TRUE` fills the requested grid from `code_set` and stamps every
|
||||||
|
# row's `value_source` so the two are distinguishable. Expected row sets here
|
||||||
|
# are built from the corpus parquet directly, never from the verb under test --
|
||||||
|
# verifying what a filter does through that same filter proves nothing.
|
||||||
|
|
||||||
|
# The (subtype, category) cells that SHOULD exist for one government-year:
|
||||||
|
# every code in force for that government's type, mapped through
|
||||||
|
# summary_categories, matching the verb's crosswalk subtype scope (the
|
||||||
|
# default concept, `primary`, is operations/capital/assistance -- see
|
||||||
|
# uscogdata#11) and excluding aggregate-flagged codes (which
|
||||||
|
# spending_long/revenue_long drop).
|
||||||
|
raw_expected_cells <- function(govid, year, subtypes, subtype_col) {
|
||||||
|
fx <- sub("/$", "", Sys.getenv("USCOGDATA_URL"))
|
||||||
|
q <- function(f) sprintf("read_parquet('%s/data/%s')", fx, f)
|
||||||
|
wt_raw_query(sprintf(
|
||||||
|
"SELECT DISTINCT c.%s AS subtype, c.category
|
||||||
|
FROM %s cs
|
||||||
|
JOIN %s x ON x.govs_type = cs.type
|
||||||
|
JOIN %s c ON c.item_code = cs.item_code
|
||||||
|
WHERE x.canonical_govid = '%s'
|
||||||
|
AND cs.year = %d
|
||||||
|
AND NOT cs.is_aggregate
|
||||||
|
AND c.category IS NOT NULL
|
||||||
|
AND c.%s IN (%s)",
|
||||||
|
subtype_col, q("code_set.parquet"), q("canonical_fips_xwalk.parquet"),
|
||||||
|
q("summary_categories.parquet"), govid, year,
|
||||||
|
subtype_col, paste0("'", subtypes, "'", collapse = ",")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
# The default expenditure concept's subtype scope, mirrored from
|
||||||
|
# R/spending.R's .spend_subtypes_primary.
|
||||||
|
primary_subtypes <- c("operations", "capital", "assistance")
|
||||||
|
|
||||||
|
test_that("complete = FALSE is the default and changes nothing", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
with_fixture_corpus({
|
||||||
|
plain <- cog_spending("121011212191", 2011L)
|
||||||
|
explicit <- cog_spending("121011212191", 2011L, complete = FALSE)
|
||||||
|
expect_equal(nrow(plain), nrow(explicit))
|
||||||
|
expect_false("value_source" %in% names(plain))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("complete = TRUE round-trips a dense-source year to the pre-sparsification cells", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
with_fixture_corpus({
|
||||||
|
# FY2011 is dense_source: before sparsification this government carried a
|
||||||
|
# row for every code in force, most of them $0. complete = TRUE must
|
||||||
|
# reproduce that cell set exactly.
|
||||||
|
r <- cog_spending("121011212191", 2011L, complete = TRUE)
|
||||||
|
expected <- raw_expected_cells("121011212191", 2011L,
|
||||||
|
primary_subtypes, "spend_subtype")
|
||||||
|
|
||||||
|
key <- function(sub, cat) paste(sub, cat, sep = "|")
|
||||||
|
expect_setequal(key(r$spend_subtype, r$category),
|
||||||
|
key(expected$subtype, expected$category))
|
||||||
|
expect_gt(nrow(expected), 0L)
|
||||||
|
|
||||||
|
# Every filled cell in a dense-source year is a Census-published $0 --
|
||||||
|
# never "unknown", which is what the modern era's absences mean.
|
||||||
|
expect_setequal(unique(r$value_source), c("reported", "census_zero"))
|
||||||
|
expect_true(all(r$amt_nominal[r$value_source == "census_zero"] == 0))
|
||||||
|
expect_true(all(r$amt_nominal[r$value_source == "reported"] != 0))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("complete = TRUE preserves the reported rows and their amounts exactly", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
with_fixture_corpus({
|
||||||
|
plain <- cog_spending("121011212191", 2011L)
|
||||||
|
full <- cog_spending("121011212191", 2011L, complete = TRUE)
|
||||||
|
|
||||||
|
# Filling adds rows; it must never alter or drop one.
|
||||||
|
expect_gt(nrow(full), nrow(plain))
|
||||||
|
reported <- full[full$value_source == "reported", ]
|
||||||
|
expect_equal(nrow(reported), nrow(plain))
|
||||||
|
expect_equal(sum(reported$amt_nominal), sum(plain$amt_nominal))
|
||||||
|
# ... and the total is unchanged, because every added cell is $0.
|
||||||
|
expect_equal(sum(full$amt_nominal, na.rm = TRUE), sum(plain$amt_nominal))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("a sparse-source year's absences are unknown, not zero", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
with_fixture_corpus({
|
||||||
|
# FY2019 is sparse_source: an absent cell means the government did not
|
||||||
|
# report, which is NOT a zero. Filling those with 0 would invent data --
|
||||||
|
# the exact error the representation contract exists to prevent.
|
||||||
|
r <- cog_spending("121011212191", 2019L, complete = TRUE)
|
||||||
|
filled <- r[r$value_source != "reported", ]
|
||||||
|
expect_gt(nrow(filled), 0L)
|
||||||
|
expect_true(all(filled$value_source == "not_reported"))
|
||||||
|
expect_true(all(is.na(filled$amt_nominal)))
|
||||||
|
expect_false(any(r$value_source == "census_zero"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("the fill is scoped to each government's own type", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
with_fixture_corpus({
|
||||||
|
# Filling against the union of all types would invent cells for codes a
|
||||||
|
# county can never report. Every filled category must be one that
|
||||||
|
# code_set puts in force for type 1 (county) specifically.
|
||||||
|
r <- cog_spending("121011212191", 2011L, complete = TRUE)
|
||||||
|
county_cells <- raw_expected_cells("121011212191", 2011L,
|
||||||
|
primary_subtypes, "spend_subtype")
|
||||||
|
expect_true(all(r$category %in% county_cells$category))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("complete = TRUE respects the category filter", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
with_fixture_corpus({
|
||||||
|
r <- cog_spending("121011212191", 2011L, category = "Police",
|
||||||
|
complete = TRUE)
|
||||||
|
expect_true(all(r$category == "Police"))
|
||||||
|
expect_true("value_source" %in% names(r))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("cog_revenue() completes on its own flow", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
with_fixture_corpus({
|
||||||
|
r <- cog_revenue("121011212191", 2011L, complete = TRUE)
|
||||||
|
expected <- raw_expected_cells("121011212191", 2011L,
|
||||||
|
c("own_source", "federal", "state", "local_aid"),
|
||||||
|
"revenue_subtype")
|
||||||
|
key <- function(sub, cat) paste(sub, cat, sep = "|")
|
||||||
|
expect_setequal(key(r$revenue_subtype, r$category),
|
||||||
|
key(expected$subtype, expected$category))
|
||||||
|
expect_setequal(unique(r$value_source), c("reported", "census_zero"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("provenance records the completion and its absence rule", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
with_fixture_corpus({
|
||||||
|
prov <- attr(cog_spending("121011212191", 2011L, complete = TRUE),
|
||||||
|
"provenance")
|
||||||
|
expect_true(prov$completion$applied)
|
||||||
|
expect_equal(prov$completion$absence_means$`2011`, "census_zero")
|
||||||
|
expect_gt(prov$completion$rows_filled, 0L)
|
||||||
|
|
||||||
|
off <- attr(cog_spending("121011212191", 2011L), "provenance")
|
||||||
|
expect_false(off$completion$applied)
|
||||||
|
expect_equal(off$completion$rows_filled, 0L)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("complete = TRUE is refused where the fill would be guesswork", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
with_fixture_corpus({
|
||||||
|
# A recipe defines its own component codes and does not go through
|
||||||
|
# summary_categories at all, so there is no grid to fill from.
|
||||||
|
expect_error(
|
||||||
|
cog_spending("121011212191", 2011L, recipe = "corrections_combined",
|
||||||
|
complete = TRUE),
|
||||||
|
class = "uscogdata_complete_unsupported"
|
||||||
|
)
|
||||||
|
# The intergovernmental leg keeps aggregate rows by design
|
||||||
|
# (inst/sql/24-ig_long.sql), so its grid is not code_set's grid.
|
||||||
|
expect_error(
|
||||||
|
cog_spending("121011212191", 2011L, expenditure_concept = "total",
|
||||||
|
complete = TRUE),
|
||||||
|
class = "uscogdata_complete_unsupported"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("complete = TRUE aborts on a corpus with no representation contract", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
# A corpus published before sparsification carries neither table, so there
|
||||||
|
# is nothing to fill from and no rule saying what an absence means. That
|
||||||
|
# must abort rather than guess.
|
||||||
|
with_corpus_missing_representation({
|
||||||
|
expect_error(
|
||||||
|
cog_spending("121011212191", 2011L, complete = TRUE),
|
||||||
|
class = "uscogdata_representation_unavailable"
|
||||||
|
)
|
||||||
|
# ... while an ordinary query on the same corpus still works.
|
||||||
|
expect_gt(nrow(cog_spending("121011212191", 2011L)), 0L)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -26,3 +26,41 @@ test_that(".resolve_cache_dir falls back to R_user_dir", {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Trailing-slash normalization (uscogdata #3 follow-up).
|
||||||
|
#
|
||||||
|
# EVERY consumer builds paths by concatenation: paste0(url, "manifest.json")
|
||||||
|
# (manifest.R), paste0(url, e$path) (mirror.R), and the parquet glob in
|
||||||
|
# views.R. mirror.R:104 even comments 'url ends in "/"' -- an assumption the
|
||||||
|
# package documents and relies on but never enforced.
|
||||||
|
#
|
||||||
|
# A URL missing its trailing slash therefore fails SILENTLY and confusingly:
|
||||||
|
# HTTPS -> ".../downloadmanifest.json" -> the host answers with an HTML 404
|
||||||
|
# page -> the jsonlite lexical error that issue #3 reported;
|
||||||
|
# local -> ".../corpusdata/long/**/*.parquet" -> DuckDB "No files found".
|
||||||
|
# Neither message points at the real cause. Normalize once, at resolution.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test_that(".resolve_url appends a missing trailing slash", {
|
||||||
|
withr::local_envvar(USCOGDATA_URL = "https://example.org/s/TOKEN/download")
|
||||||
|
expect_equal(.resolve_url(), "https://example.org/s/TOKEN/download/")
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that(".resolve_url leaves an existing trailing slash alone", {
|
||||||
|
withr::local_envvar(USCOGDATA_URL = "https://example.org/s/TOKEN/download/")
|
||||||
|
expect_equal(.resolve_url(), "https://example.org/s/TOKEN/download/")
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that(".resolve_url normalizes a local path without a trailing slash", {
|
||||||
|
withr::local_envvar(USCOGDATA_URL = "/tmp/corpus")
|
||||||
|
expect_equal(.resolve_url(), "/tmp/corpus/")
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that(".resolve_url does not invent a slash for an empty setting", {
|
||||||
|
# An unset/empty URL must stay empty so the "not configured" guard in
|
||||||
|
# manifest.R still fires, rather than degrading into a bare "/" root.
|
||||||
|
withr::local_envvar(USCOGDATA_URL = "")
|
||||||
|
withr::local_options(uscogdata.url = "")
|
||||||
|
expect_equal(.resolve_url(), "")
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# tests/testthat/test-corpus-breaks.R
|
||||||
|
#
|
||||||
|
# uscogdata#19. Four catalogued series breaks carry fin_code = "ALL" -- they
|
||||||
|
# are caveats about the corpus itself rather than about one item code:
|
||||||
|
#
|
||||||
|
# SB085 1977 dollar precision across the 1976/1977 boundary
|
||||||
|
# SB087 2002 imputation exclusion FY2002-2006
|
||||||
|
# SB194 2012 dense -> sparse representation change
|
||||||
|
# SB086 2017 government ID scheme change
|
||||||
|
#
|
||||||
|
# .build_series_break_refs() matches `fin_code IN (<codes in the result>)`,
|
||||||
|
# and no row's item_code is ever the literal "ALL", so none of them could
|
||||||
|
# ever reach a user. They now travel in their own provenance field,
|
||||||
|
# `corpus_break_refs`, which keeps them distinguishable from the
|
||||||
|
# code-specific `series_break_refs` (an ALL caveat qualifies the whole
|
||||||
|
# result, not one series).
|
||||||
|
|
||||||
|
test_that("corpus_break_refs surfaces an ALL-scoped break the year range spans", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
with_fixture_corpus({
|
||||||
|
# SB194 sits at FY2012 -- the dense/sparse boundary. A query spanning
|
||||||
|
# 2011 -> 2012 straddles it, and this is the case cog_pipeline#64's
|
||||||
|
# DoD 4 intended to reach users.
|
||||||
|
r <- cog_spending("121011212191", 2011:2012, "Police")
|
||||||
|
prov <- attr(r, "provenance")
|
||||||
|
expect_true("SB194" %in% prov$corpus_break_refs)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("corpus_break_refs stays empty when no ALL break falls in the range", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
with_fixture_corpus({
|
||||||
|
# 2019-2020 spans no catalogued corpus-wide break.
|
||||||
|
r <- cog_spending("121011212191", 2019:2020, "Police")
|
||||||
|
expect_equal(attr(r, "provenance")$corpus_break_refs, character(0))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("corpus_break_refs and series_break_refs stay disjoint", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
with_fixture_corpus({
|
||||||
|
r <- cog_spending("121011212191", 2011:2012, "Police")
|
||||||
|
prov <- attr(r, "provenance")
|
||||||
|
expect_type(prov$series_break_refs, "character")
|
||||||
|
expect_type(prov$corpus_break_refs, "character")
|
||||||
|
# An ALL caveat must never masquerade as a break in a specific series.
|
||||||
|
expect_length(intersect(prov$series_break_refs, prov$corpus_break_refs), 0L)
|
||||||
|
expect_false("SB194" %in% prov$series_break_refs)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that(".build_corpus_break_refs matches on the break_year window alone", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
con <- cog_open()
|
||||||
|
on.exit(cog_close())
|
||||||
|
|
||||||
|
# SB085's boundary is 1976/1977, outside the fixture's partitions -- the
|
||||||
|
# series_breaks table is a full cross-vintage registry, so the matching
|
||||||
|
# logic is testable there even though no long partition covers it.
|
||||||
|
expect_true("SB085" %in% uscogdata:::.build_corpus_break_refs(
|
||||||
|
con, years = 1975:1980, schema_version = 6L
|
||||||
|
))
|
||||||
|
# ... and does not fire for a range that misses it, unlike a filter keyed
|
||||||
|
# on the era rather than the boundary.
|
||||||
|
expect_false("SB085" %in% uscogdata:::.build_corpus_break_refs(
|
||||||
|
con, years = 1978:1980, schema_version = 6L
|
||||||
|
))
|
||||||
|
|
||||||
|
# Unlike code-specific refs, these do not depend on which codes a result
|
||||||
|
# happens to contain -- that dependency is the whole defect.
|
||||||
|
expect_setequal(
|
||||||
|
uscogdata:::.build_corpus_break_refs(con, years = 2001:2003, schema_version = 6L),
|
||||||
|
"SB087"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Gated on schema_version >= 5: series_breaks_pq is not registered below it.
|
||||||
|
expect_equal(
|
||||||
|
uscogdata:::.build_corpus_break_refs(con, years = 2011:2012, schema_version = 4L),
|
||||||
|
character(0)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("cog_explain() prints corpus-wide caveats under their own heading", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
with_fixture_corpus({
|
||||||
|
r <- cog_spending("121011212191", 2011:2012, "Police")
|
||||||
|
out <- paste(c(
|
||||||
|
capture.output(cog_explain(r)),
|
||||||
|
capture.output(cog_explain(r), type = "message")
|
||||||
|
), collapse = "\n")
|
||||||
|
expect_match(out, "Corpus-wide caveats", fixed = TRUE)
|
||||||
|
expect_match(out, "SB194", fixed = TRUE)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Madison walkthrough audit -- findings F-020 and F-023. Tracked as uscogdata#13.
|
||||||
|
# See docs/walkthroughs/FINDINGS.md in cog_explorer.
|
||||||
|
#
|
||||||
|
# The owner's settled design (2026-07-28): a `coverage` argument on
|
||||||
|
# cog_geographic_rollup(), cog_find_peers()/cog_peer_compare() and their
|
||||||
|
# cog-api equivalents --
|
||||||
|
# "all" every unit that reported that year (today's behaviour, DEFAULT)
|
||||||
|
# "census" census years only (years ending 2 or 7)
|
||||||
|
# "consistent" only units reporting in every requested year (balanced panel)
|
||||||
|
# -- PLUS always-on coverage metadata on every result regardless of mode:
|
||||||
|
# n_units_reporting, n_units_expected, is_census_year.
|
||||||
|
#
|
||||||
|
# Motivating principle: using these verbs correctly must not require the user to
|
||||||
|
# know that the Census of Governments is a complete census only in years ending
|
||||||
|
# in 2 and 7.
|
||||||
|
#
|
||||||
|
# The helper below accepts that metadata either as columns on the returned
|
||||||
|
# tibble or as a per-year table in provenance$coverage -- the design fixes the
|
||||||
|
# three field names and that they reach the caller, not the container.
|
||||||
|
|
||||||
|
wt_coverage <- function(x) {
|
||||||
|
prov <- attr(x, "provenance")
|
||||||
|
cov <- prov$coverage
|
||||||
|
if (is.null(cov)) {
|
||||||
|
needed <- c("year", "n_units_reporting", "n_units_expected", "is_census_year")
|
||||||
|
expect_true(all(needed %in% names(x)))
|
||||||
|
cov <- unique(x[, needed])
|
||||||
|
}
|
||||||
|
cov[order(cov$year), ]
|
||||||
|
}
|
||||||
|
|
||||||
|
test_that("multi-government aggregates disclose reporting coverage on every result", {
|
||||||
|
|
||||||
|
# -- F-020: geographic rollups -------------------------------------------
|
||||||
|
# Wisconsin's city/village universe is 608 governments. On the bundled
|
||||||
|
# fixture, FY2012 (a census year) has 597 of them reporting while FY2019 and
|
||||||
|
# FY2020 (sample years) have 112 and 114 -- an 18%-98% swing that today's
|
||||||
|
# return value says nothing about. Counts cross-checked against the raw
|
||||||
|
# corpus, not through cog_geographic_rollup(), which is under test.
|
||||||
|
wi <- cog_gov_search(name = NULL, state = "WI", type = "city")
|
||||||
|
expect_equal(nrow(wi), 608L)
|
||||||
|
|
||||||
|
roll <- cog_geographic_rollup(govids = list(city = wi$canonical_govid),
|
||||||
|
category = NULL, years = c(2011L, 2012L, 2019L, 2020L))
|
||||||
|
cov <- wt_coverage(roll)
|
||||||
|
|
||||||
|
expect_equal(cov$n_units_expected, rep(608L, 4L))
|
||||||
|
expect_equal(cov$n_units_reporting, c(152L, 597L, 112L, 114L))
|
||||||
|
expect_equal(cov$is_census_year, c(FALSE, TRUE, FALSE, FALSE))
|
||||||
|
|
||||||
|
# Cross-check against the raw partitions, scoped to the SAME universe the
|
||||||
|
# rollup was given -- the 608 govids above. Scoping instead on the long
|
||||||
|
# table's own `type`/`fips_state` asks a different question and answers 595:
|
||||||
|
# VERNON VILLAGE and WAUKESHA VILLAGE carry type = 3 there (their as-of-year
|
||||||
|
# identity, when they were townships) while the xwalk lists them as
|
||||||
|
# govs_type = 2 (their present identity, as villages). Schema v6 made the
|
||||||
|
# long table's geography present-harmonized and moved as-of-year to the
|
||||||
|
# *_asof columns, but `type` still reads as-of-year -- see .validate_schema()
|
||||||
|
# in R/manifest.R. n_units_reporting counts against the requested universe,
|
||||||
|
# so 597 is the number that answers "how many of the governments I asked
|
||||||
|
# about reported".
|
||||||
|
raw_2012 <- wt_raw_query(paste0(
|
||||||
|
"SELECT COUNT(DISTINCT canonical_govid) n FROM read_parquet('", wt_corpus_glob(), "') ",
|
||||||
|
"WHERE year = 2012 AND LEFT(item_code, 1) IN ('E','F','G') AND NOT is_aggregate ",
|
||||||
|
"AND canonical_govid IN (",
|
||||||
|
paste0("'", wi$canonical_govid, "'", collapse = ","), ")"))
|
||||||
|
expect_equal(cov$n_units_reporting[cov$year == 2012], as.integer(raw_2012$n[[1]]))
|
||||||
|
|
||||||
|
# -- F-023: peer cohorts --------------------------------------------------
|
||||||
|
# CHILTON CITY, WI (ACS population 4,017): a 15-peer cohort fixed at FY2012
|
||||||
|
# reports 15 of 15 in FY2012 and only 3 of 15 in FY2019 and FY2020. Nothing
|
||||||
|
# in cog_peer_compare()'s return distinguishes those years today.
|
||||||
|
chilton <- "552015177095"
|
||||||
|
peers <- cog_find_peers(chilton, year = 2012L, max_peers = 15L)
|
||||||
|
expect_equal(nrow(peers), 15L)
|
||||||
|
|
||||||
|
cmp <- cog_peer_compare(target_govid = chilton, peers = peers, category = NULL,
|
||||||
|
years = c(2012L, 2019L, 2020L), per_capita = TRUE)
|
||||||
|
cov_peers <- wt_coverage(cmp)
|
||||||
|
expect_equal(cov_peers$n_units_expected, rep(15L, 3L))
|
||||||
|
expect_equal(cov_peers$n_units_reporting, c(15L, 3L, 3L))
|
||||||
|
expect_equal(cov_peers$is_census_year, c(TRUE, FALSE, FALSE))
|
||||||
|
|
||||||
|
# -- the three coverage modes --------------------------------------------
|
||||||
|
expect_equal(attr(cog_peer_compare(target_govid = chilton, peers = peers,
|
||||||
|
category = NULL, years = c(2012L, 2019L, 2020L),
|
||||||
|
per_capita = TRUE),
|
||||||
|
"provenance")$coverage_mode, "all") # unchanged default
|
||||||
|
|
||||||
|
consistent <- cog_peer_compare(target_govid = chilton, peers = peers,
|
||||||
|
category = NULL, years = c(2012L, 2019L, 2020L),
|
||||||
|
per_capita = TRUE, coverage = "consistent")
|
||||||
|
n_by_year <- tapply(consistent$canonical_govid[consistent$role == "peer"],
|
||||||
|
consistent$year[consistent$role == "peer"],
|
||||||
|
function(g) length(unique(g)))
|
||||||
|
expect_equal(unname(as.integer(n_by_year)), c(3L, 3L, 3L)) # balanced panel
|
||||||
|
|
||||||
|
census_only <- cog_geographic_rollup(govids = list(city = wi$canonical_govid),
|
||||||
|
category = NULL,
|
||||||
|
years = c(2011L, 2012L, 2019L, 2020L),
|
||||||
|
coverage = "census")
|
||||||
|
expect_equal(sort(unique(census_only$year)), 2012)
|
||||||
|
})
|
||||||
@@ -0,0 +1,553 @@
|
|||||||
|
test_that("the corpus contains no K-prefix rows, so the Direct leg omits K", {
|
||||||
|
con <- .ensure_session()
|
||||||
|
n <- DBI::dbGetQuery(con,
|
||||||
|
"SELECT COUNT(*) AS n FROM long WHERE LEFT(item_code, 1) = 'K'")$n
|
||||||
|
expect_equal(n, 0)
|
||||||
|
|
||||||
|
sql_files <- c("20-spending_long.sql", "22-spending_long_harmonized.sql")
|
||||||
|
for (f in sql_files) {
|
||||||
|
txt <- paste(readLines(system.file("sql", f, package = "uscogdata")),
|
||||||
|
collapse = " ")
|
||||||
|
expect_false(grepl("'K'", txt, fixed = TRUE),
|
||||||
|
label = paste(f, "must not reference the inert K prefix"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("expenditure_concept defaults to primary; direct matches it on a pure operations/capital category", {
|
||||||
|
gov <- "010000226085" # Alabama state government
|
||||||
|
base <- cog_spending(gov, years = 2019, category = "Police")
|
||||||
|
expect_equal(attr(base, "provenance")$expenditure_concept, "primary")
|
||||||
|
# Police maps only to operations/capital codes (E62/F62/G62), so the
|
||||||
|
# direct concept's extra subtypes (interest, insurance_benefits) cannot
|
||||||
|
# contribute and the two concepts must agree exactly here.
|
||||||
|
expl <- cog_spending(gov, years = 2019, category = "Police",
|
||||||
|
expenditure_concept = "direct")
|
||||||
|
expect_equal(base$amt_nominal, expl$amt_nominal)
|
||||||
|
expect_false("intergovernmental" %in% base$spend_subtype)
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("expenditure_concept = 'total' adds an intergovernmental subtype", {
|
||||||
|
gov <- "010000226085"
|
||||||
|
d <- cog_spending(gov, years = 2019, category = "Police",
|
||||||
|
expenditure_concept = "direct")
|
||||||
|
t <- cog_spending(gov, years = 2019, category = "Police",
|
||||||
|
expenditure_concept = "total")
|
||||||
|
expect_true("intergovernmental" %in% t$spend_subtype)
|
||||||
|
# Direct rows are untouched; Total only ever ADDS. Use %in% rather than
|
||||||
|
# != : a category = NULL result can contain a NULL-subtype group (codes
|
||||||
|
# with no summary_categories row, e.g. E16/E21/E85/F16/F85/G16/G21/G85),
|
||||||
|
# and `NA != "intergovernmental"` is NA, not TRUE, which would silently
|
||||||
|
# smuggle an all-NA phantom row into dt.
|
||||||
|
dt <- t[!(t$spend_subtype %in% "intergovernmental"), ]
|
||||||
|
expect_equal(sort(dt$amt_nominal), sort(d$amt_nominal))
|
||||||
|
expect_gt(sum(t$amt_nominal), sum(d$amt_nominal))
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("legacy-era Total does not collapse to Direct (the is_aggregate trap)", {
|
||||||
|
# In the wide era the IG dollars live almost entirely on aggregate-flagged
|
||||||
|
# rows. A Total leg that inherited the Direct leg's NOT is_aggregate filter
|
||||||
|
# would silently return Total == Direct here.
|
||||||
|
gov <- "010000226085"
|
||||||
|
d <- cog_spending(gov, years = 2011, category = "Education K-12",
|
||||||
|
expenditure_concept = "direct")
|
||||||
|
t <- cog_spending(gov, years = 2011, category = "Education K-12",
|
||||||
|
expenditure_concept = "total")
|
||||||
|
expect_true("intergovernmental" %in% t$spend_subtype)
|
||||||
|
ig <- sum(t$amt_nominal[t$spend_subtype == "intergovernmental"])
|
||||||
|
expect_gt(ig, 0)
|
||||||
|
expect_gt(sum(t$amt_nominal), sum(d$amt_nominal))
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("the IG leg never includes the L-- family total", {
|
||||||
|
con <- .ensure_session()
|
||||||
|
codes <- DBI::dbGetQuery(con,
|
||||||
|
"SELECT DISTINCT item_code FROM ig_long")$item_code
|
||||||
|
expect_false(any(grepl("--$", codes)))
|
||||||
|
# Q joined the IG family with the crosswalk-membership rewrite
|
||||||
|
# (uscogdata#11 / F-017: Q11/Q12/Q18 are state payments to school systems).
|
||||||
|
expect_true(all(substr(codes, 1, 1) %in% c("M", "L", "Q")))
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("expenditure_concept rejects unknown values", {
|
||||||
|
expect_error(
|
||||||
|
cog_spending("010000226085", years = 2019, expenditure_concept = "gross"),
|
||||||
|
class = "rlang_error"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("total composes with basis = 'raw' and basis = 'harmonized'", {
|
||||||
|
gov <- "010000226085"
|
||||||
|
h <- cog_spending(gov, years = 2011, category = "Education K-12",
|
||||||
|
expenditure_concept = "total", basis = "harmonized")
|
||||||
|
r <- cog_spending(gov, years = 2011, category = "Education K-12",
|
||||||
|
expenditure_concept = "total", basis = "raw")
|
||||||
|
ig_h <- sum(h$amt_nominal[h$spend_subtype == "intergovernmental"])
|
||||||
|
ig_r <- sum(r$amt_nominal[r$spend_subtype == "intergovernmental"])
|
||||||
|
# The only IG harmonization rule is M38 -> M36 (year-disjoint), so the IG
|
||||||
|
# total must agree between bases even though the code labels may differ.
|
||||||
|
expect_equal(ig_h, ig_r)
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("recipe = and expenditure_concept = 'total' together aborts", {
|
||||||
|
expect_error(
|
||||||
|
cog_spending("121011212191", 2020L, recipe = "corrections_combined",
|
||||||
|
expenditure_concept = "total"),
|
||||||
|
class = "uscogdata_recipe_concept_conflict"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("aggregate-sourced IG dollars are flagged aggregate_fallback = TRUE (bool_or, not bool_and)", {
|
||||||
|
# Regression test: .build_verb_sql() originally used bool_and(is_aggregate)
|
||||||
|
# for aggregate_fallback, which is correct for the Direct leg (a group can
|
||||||
|
# never mix aggregate and non-aggregate rows there -- spending_long filters
|
||||||
|
# NOT is_aggregate) but wrong for the IG leg. The wide era is dense -- every
|
||||||
|
# government has a $0 row for every code in a family -- so a $0 leaf sits in
|
||||||
|
# the same (year, gov, subtype, category) group as the real aggregate row
|
||||||
|
# and flips bool_and() to FALSE. Measured: AL state 2011 had $5,740,775,000
|
||||||
|
# of aggregate-sourced IG dollars (Corrections $31,358,000 + Education K-12
|
||||||
|
# $5,152,385,000 + General Government $557,032,000) reporting
|
||||||
|
# aggregate_fallback = FALSE under bool_and(), with the only TRUE row being
|
||||||
|
# Transit Utilities at $0. bool_or() reports all of them correctly.
|
||||||
|
gov <- "010000226085"
|
||||||
|
t <- cog_spending(gov, years = 2011, category = "Education K-12",
|
||||||
|
expenditure_concept = "total")
|
||||||
|
ig <- t[t$spend_subtype == "intergovernmental", ]
|
||||||
|
expect_equal(nrow(ig), 1L)
|
||||||
|
expect_true(ig$aggregate_fallback)
|
||||||
|
expect_true(nzchar(ig$notes))
|
||||||
|
expect_match(ig$notes, "Aggregate fallback applied", fixed = TRUE)
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("legacy aggregate IG codes are year-disjoint from their modern leaf components", {
|
||||||
|
# The safety of ig_long's deliberate omission of `NOT is_aggregate` (see
|
||||||
|
# inst/sql/24-ig_long.sql) rests entirely on each legacy code's AGGREGATE
|
||||||
|
# instance being year-disjoint from the modern leaf codes it rolls up --
|
||||||
|
# if a future corpus rebuild ever back-filled a leaf into a year where the
|
||||||
|
# code is still flagged aggregate, `total` would silently double-count and
|
||||||
|
# this suite would still pass. This test fails loudly if that ever
|
||||||
|
# happens.
|
||||||
|
#
|
||||||
|
# Note the invariant is scoped to the AGGREGATE flag, not bare code
|
||||||
|
# presence: M89/L89 do NOT disappear after the wide era the way M47/L47
|
||||||
|
# do -- they continue past 2011 as their OWN independent leaf line item
|
||||||
|
# (is_aggregate = FALSE) alongside M91-93/L91-93, which is fine because a
|
||||||
|
# non-aggregate M89/L89 no longer represents a rollup of those codes.
|
||||||
|
# (Verified in the fixture: M89/L89 are is_aggregate = TRUE only in 2011,
|
||||||
|
# when M91-93/L91-93 don't exist yet; from 2012 on M89/L89 are
|
||||||
|
# is_aggregate = FALSE leaves coexisting with M91-93/L91-93.)
|
||||||
|
#
|
||||||
|
# Pairs are the M/L-prefixed components (this package's ig_long only
|
||||||
|
# covers M/L; other prefixes in the same rollup, e.g. N/O/P/Q/R, fall
|
||||||
|
# outside its domain and are irrelevant here) enumerated in
|
||||||
|
# cog_pipeline's data/wide_to_long_xwalk.csv `full_desc` column (read
|
||||||
|
# once at authoring time, not at test time -- this test stays offline):
|
||||||
|
# M47 "To local governments, total (includes N47, O47, P47, R47, and M94)"
|
||||||
|
# M89 "To local governments, total (incl N89, O89, P89, R89, M91, M92, and M93)"
|
||||||
|
# L47 "To state government (includes L94)"
|
||||||
|
# L89 "To state government (includes L91, L92, and L93)"
|
||||||
|
con <- .ensure_session()
|
||||||
|
pairs <- list(
|
||||||
|
list(aggregate = "M47", components = "M94"),
|
||||||
|
list(aggregate = "M89", components = c("M91", "M92", "M93")),
|
||||||
|
list(aggregate = "L47", components = "L94"),
|
||||||
|
list(aggregate = "L89", components = c("L91", "L92", "L93"))
|
||||||
|
)
|
||||||
|
agg_years_by_code <- DBI::dbGetQuery(con,
|
||||||
|
"SELECT DISTINCT year, item_code FROM ig_long WHERE is_aggregate")
|
||||||
|
codes_by_year <- DBI::dbGetQuery(con, "SELECT DISTINCT year, item_code FROM ig_long")
|
||||||
|
|
||||||
|
for (p in pairs) {
|
||||||
|
agg_years <- agg_years_by_code$year[agg_years_by_code$item_code == p$aggregate]
|
||||||
|
for (yr in agg_years) {
|
||||||
|
codes_yr <- codes_by_year$item_code[codes_by_year$year == yr]
|
||||||
|
has_component <- any(p$components %in% codes_yr)
|
||||||
|
expect_false(
|
||||||
|
has_component,
|
||||||
|
label = sprintf(
|
||||||
|
"year %s has aggregate-flagged %s co-occurring with a modern component (%s)",
|
||||||
|
yr, p$aggregate, paste(p$components, collapse = ",")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that(".verb_spendrev rejects expenditure_concept = 'total' for a non-spending view_base", {
|
||||||
|
# cog_revenue() never exposes expenditure_concept and always resolves it
|
||||||
|
# to the "direct" default, so there is no revenue codepath that reaches
|
||||||
|
# this today -- but .verb_spendrev() is shared, and nothing else stops a
|
||||||
|
# future caller from passing expenditure_concept = "total" alongside
|
||||||
|
# view_base = "revenue_annotated", which would UNION expenditure M/L rows
|
||||||
|
# into a revenue result. Exercise the internal helper directly.
|
||||||
|
expect_error(
|
||||||
|
uscogdata:::.verb_spendrev(
|
||||||
|
verb = "cog_revenue_test", view_base = "revenue_annotated",
|
||||||
|
subtype_col = "revenue_subtype",
|
||||||
|
flow_prefixes = c("T", "A", "U", "B", "C", "D"),
|
||||||
|
call = quote(cog_revenue_test()),
|
||||||
|
govid = "010000226085", years = 2019L, category = NULL,
|
||||||
|
per_capita = FALSE, adjust_to_year = NULL, basis = "raw",
|
||||||
|
recipe = NULL, expenditure_concept = "total"
|
||||||
|
),
|
||||||
|
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")
|
||||||
|
expect_match(msg, "cog_geographic_rollup")
|
||||||
|
|
||||||
|
# Test that cog_peer_compare's message names its own function
|
||||||
|
err2 <- tryCatch(
|
||||||
|
cog_peer_compare(target_govid = "010000226085", peers = "010000226085",
|
||||||
|
category = "Police", years = 2019,
|
||||||
|
expenditure_concept = "total"),
|
||||||
|
condition = function(e) e
|
||||||
|
)
|
||||||
|
msg2 <- paste(conditionMessage(err2), collapse = " ")
|
||||||
|
expect_match(msg2, "direct")
|
||||||
|
expect_match(msg2, "double-count|double count")
|
||||||
|
expect_match(msg2, "cog_peer_compare")
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
expect_no_error(
|
||||||
|
cog_peer_compare(target_govid = "010000226085", peers = "010000226085",
|
||||||
|
category = "Police", years = 2019)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("provenance always records the expenditure concept", {
|
||||||
|
p <- cog_spending("010000226085", years = 2019, category = "Police")
|
||||||
|
d <- cog_spending("010000226085", years = 2019, category = "Police",
|
||||||
|
expenditure_concept = "direct")
|
||||||
|
t <- cog_spending("010000226085", years = 2019, category = "Police",
|
||||||
|
expenditure_concept = "total")
|
||||||
|
expect_equal(attr(p, "provenance")$expenditure_concept, "primary")
|
||||||
|
expect_equal(attr(d, "provenance")$expenditure_concept, "direct")
|
||||||
|
expect_equal(attr(t, "provenance")$expenditure_concept, "total")
|
||||||
|
# The note explains the non-obvious part: how legacy IG was assembled.
|
||||||
|
expect_true(nzchar(attr(t, "provenance")$expenditure_concept_note))
|
||||||
|
expect_true(is.na(attr(d, "provenance")$expenditure_concept_note) ||
|
||||||
|
!nzchar(attr(d, "provenance")$expenditure_concept_note))
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("the provenance schema documents expenditure_concept", {
|
||||||
|
sch <- jsonlite::fromJSON(
|
||||||
|
system.file("schemas", "provenance-v1.json", package = "uscogdata"),
|
||||||
|
simplifyVector = FALSE
|
||||||
|
)
|
||||||
|
expect_true("expenditure_concept" %in% names(sch$properties))
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("a firing suggestion names the intergovernmental counterpart recipe", {
|
||||||
|
# Corrections has no legacy leaf rows, so the coverage-gap suggestion fires;
|
||||||
|
# corrections_ig_local_combined is its IG counterpart.
|
||||||
|
r <- suppressMessages(
|
||||||
|
cog_spending("010000226085", years = c(2005, 2011), category = "Corrections")
|
||||||
|
)
|
||||||
|
sugg <- attr(r, "provenance")$suggestions
|
||||||
|
expect_gt(length(sugg), 0L)
|
||||||
|
ids <- vapply(sugg, function(s) s$recipe_id %||% "", character(1))
|
||||||
|
expect_true("corrections_combined" %in% ids)
|
||||||
|
ig <- unlist(lapply(sugg, function(s) s$ig_recipe_id))
|
||||||
|
expect_true("corrections_ig_local_combined" %in% ig)
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("no suggestion fires for a healthy query", {
|
||||||
|
r <- cog_spending("010000226085", years = 2019, category = "Police")
|
||||||
|
expect_length(attr(r, "provenance")$suggestions, 0L)
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("a mis-scoped cog_spending() call never attaches an M/L counterpart to a revenue-flavored recipe", {
|
||||||
|
# "IG Federal" is a revenue-only category (summary_categories maps it to
|
||||||
|
# B-prefixed component codes only; its recipes are ig_federal_b47_wide /
|
||||||
|
# ig_federal_b89_wide). A cog_spending() call scoped to it returns zero
|
||||||
|
# spending rows for every requested year -- there is no spending
|
||||||
|
# component in this category at all -- so the coverage-gap machinery
|
||||||
|
# fires for real (not hypothetically) even though this isn't the kind of
|
||||||
|
# format-boundary gap the recipe catalog is meant to signpost. This is
|
||||||
|
# exactly the live-corpus risk flagged in review: ig_federal_b47_wide's
|
||||||
|
# own component codes (B47/B94, suffixes {"47","94"}) are an EXACT
|
||||||
|
# suffix-set match for the expenditure recipe ige_local_m47_wide
|
||||||
|
# (M47/M94, same suffixes) -- a coincidence of reused digits, not a real
|
||||||
|
# Direct/Total pairing. The flow-family gate in
|
||||||
|
# .attach_ig_counterparts() must keep ig_recipe_id NULL here.
|
||||||
|
#
|
||||||
|
# Anchored on FL state government, not AL. Coverage is presence-based: a
|
||||||
|
# recipe is only suggested when its component codes have rows for the
|
||||||
|
# requested government-year. AL state's only FY2011 B47 cell was an
|
||||||
|
# explicit zero, which the corpus no longer stores after sparsification
|
||||||
|
# (SB194, cog_pipeline#64), so the recipe stopped being a candidate there.
|
||||||
|
# FL state carries a real FY2011 B47 amount, so this exercises the guard
|
||||||
|
# against a suggestion that genuinely fires.
|
||||||
|
r <- suppressMessages(
|
||||||
|
cog_spending("120000226351", years = c(2005, 2011), category = "IG Federal")
|
||||||
|
)
|
||||||
|
sugg <- attr(r, "provenance")$suggestions
|
||||||
|
expect_gt(length(sugg), 0L)
|
||||||
|
ids <- vapply(sugg, function(s) s$recipe_id %||% "", character(1))
|
||||||
|
expect_true("ig_federal_b47_wide" %in% ids)
|
||||||
|
ig <- unlist(lapply(sugg, function(s) s$ig_recipe_id))
|
||||||
|
expect_length(ig, 0L)
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("C1: 'total' on a legacy aggregate-only family reports the IG-only figure honestly, not as Direct + IG", {
|
||||||
|
# AL state government, Corrections, 2011. Measured pre-fix: 'total'
|
||||||
|
# returned $31,358,000 (the IG leg alone, on an aggregate-flagged M04/M05
|
||||||
|
# row) with 0 suggestions (the surviving IG row made the gap-detection
|
||||||
|
# machinery think the Direct leg was covered) and a note asserting
|
||||||
|
# "Total = Direct + intergovernmental" with no caveat. True Direct (via
|
||||||
|
# recipe = "corrections_combined") is $521,651,000 -- the IG-only figure
|
||||||
|
# is ~6% of it.
|
||||||
|
gov <- "010000226085"
|
||||||
|
|
||||||
|
d <- cog_spending(gov, years = 2011, category = "Corrections",
|
||||||
|
expenditure_concept = "direct")
|
||||||
|
expect_equal(nrow(d), 0L)
|
||||||
|
|
||||||
|
t <- suppressMessages(cog_spending(
|
||||||
|
gov, years = 2011, category = "Corrections", expenditure_concept = "total"
|
||||||
|
))
|
||||||
|
expect_equal(nrow(t), 1L)
|
||||||
|
expect_equal(t$spend_subtype, "intergovernmental")
|
||||||
|
expect_equal(t$amt_nominal, 31358000)
|
||||||
|
|
||||||
|
r <- cog_spending(gov, years = 2011, recipe = "corrections_combined")
|
||||||
|
expect_equal(r$amt_nominal, 521651000)
|
||||||
|
|
||||||
|
# C1(a): the recipe hints must fire for "total" exactly as they do for
|
||||||
|
# "direct" -- the surviving IG row must not be mistaken for Direct
|
||||||
|
# coverage.
|
||||||
|
prov <- attr(t, "provenance")
|
||||||
|
expect_gt(length(prov$suggestions), 0L)
|
||||||
|
ids <- vapply(prov$suggestions, function(s) s$recipe_id %||% "", character(1))
|
||||||
|
expect_true("corrections_combined" %in% ids)
|
||||||
|
|
||||||
|
# C1(b): the affected row's notes name a recovering recipe rather than
|
||||||
|
# staying silent, and the provenance carries a flag a downstream consumer
|
||||||
|
# (e.g. cog-api, which passes provenance through verbatim) can test.
|
||||||
|
expect_true(nzchar(t$notes))
|
||||||
|
expect_match(t$notes, "unavailable", fixed = TRUE)
|
||||||
|
expect_match(t$notes, "corrections_combined", fixed = TRUE)
|
||||||
|
expect_true(prov$expenditure_concept_direct_suppressed)
|
||||||
|
|
||||||
|
# The base "Total = Direct + IG" note must NOT stand unqualified when that
|
||||||
|
# arithmetic didn't actually happen for this row.
|
||||||
|
expect_match(prov$expenditure_concept_note, "NOTE", fixed = TRUE)
|
||||||
|
expect_match(prov$expenditure_concept_note,
|
||||||
|
"expenditure_concept_direct_suppressed", fixed = TRUE)
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("C1(b): expenditure_concept_direct_suppressed is FALSE when the Direct leg is present", {
|
||||||
|
d <- cog_spending("010000226085", years = 2019, category = "Police",
|
||||||
|
expenditure_concept = "direct")
|
||||||
|
t <- cog_spending("010000226085", years = 2019, category = "Police",
|
||||||
|
expenditure_concept = "total")
|
||||||
|
expect_false(isTRUE(attr(d, "provenance")$expenditure_concept_direct_suppressed))
|
||||||
|
expect_false(isTRUE(attr(t, "provenance")$expenditure_concept_direct_suppressed))
|
||||||
|
expect_false(any(nzchar(t$notes[t$spend_subtype == "intergovernmental"]) &
|
||||||
|
grepl("unavailable", t$notes[t$spend_subtype == "intergovernmental"])))
|
||||||
|
})
|
||||||
|
|
||||||
|
# M/I fix: .detect_direct_suppressed() was equating "no Direct sibling row"
|
||||||
|
# with "Direct was suppressed", but the dominant real cause is a government
|
||||||
|
# that simply has no direct spending in that category -- correct, ordinary
|
||||||
|
# data. The fix gates the flag (and its row note) on a harmonization recipe
|
||||||
|
# ACTUALLY covering that exact (year, canonical_govid, category) triple.
|
||||||
|
|
||||||
|
test_that("M/I: true positive, category supplied explicitly (unchanged behavior)", {
|
||||||
|
al <- "010000226085"
|
||||||
|
t_cat <- suppressMessages(cog_spending(
|
||||||
|
al, years = 2011, category = "Corrections", expenditure_concept = "total"
|
||||||
|
))
|
||||||
|
expect_true(attr(t_cat, "provenance")$expenditure_concept_direct_suppressed)
|
||||||
|
expect_match(t_cat$notes, "corrections_combined", fixed = TRUE)
|
||||||
|
expect_match(t_cat$notes, "unavailable", fixed = TRUE)
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("M/I: true positive, category = NULL now also names the recipe (was the fallback bug)", {
|
||||||
|
# Root bug: .build_suggestions() short-circuits to list() when category is
|
||||||
|
# NULL, so the note previously always hit its "no covering recipe found"
|
||||||
|
# fallback here even though corrections_combined genuinely covers this row.
|
||||||
|
al <- "010000226085"
|
||||||
|
t_null <- suppressMessages(cog_spending(
|
||||||
|
al, years = 2011, category = NULL, expenditure_concept = "total"
|
||||||
|
))
|
||||||
|
corr_row <- t_null[t_null$category %in% "Corrections", ]
|
||||||
|
expect_equal(nrow(corr_row), 1L)
|
||||||
|
expect_true(attr(t_null, "provenance")$expenditure_concept_direct_suppressed)
|
||||||
|
expect_match(corr_row$notes, "corrections_combined", fixed = TRUE)
|
||||||
|
expect_match(corr_row$notes, "unavailable", fixed = TRUE)
|
||||||
|
expect_false(grepl("no covering recipe found", corr_row$notes, fixed = TRUE))
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("M/I: false positive -- Virginia Education K-12 FY2019 total is NOT flagged", {
|
||||||
|
# States fund K-12 through school districts, so the Direct leg (E12/F12/
|
||||||
|
# G12) is genuinely, correctly zero -- not suppressed. Must not be flagged
|
||||||
|
# and must carry no suppression note.
|
||||||
|
va <- "510000227542"
|
||||||
|
t_va <- suppressMessages(cog_spending(
|
||||||
|
va, years = 2019, category = "Education K-12", expenditure_concept = "total"
|
||||||
|
))
|
||||||
|
expect_equal(nrow(t_va), 1L)
|
||||||
|
expect_equal(t_va$spend_subtype, "intergovernmental")
|
||||||
|
expect_equal(t_va$amt_nominal, 8028179000)
|
||||||
|
expect_false(isTRUE(attr(t_va, "provenance")$expenditure_concept_direct_suppressed))
|
||||||
|
expect_false(nzchar(t_va$notes) && grepl("unavailable", t_va$notes))
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("M/I: false positive by construction -- 'Other Education' has no E/F/G code, never flagged", {
|
||||||
|
# "Other Education" maps only to M21/L21 in summary_categories -- there is
|
||||||
|
# no E/F/G code for it in this corpus at all, so no Direct-recovering
|
||||||
|
# recipe can exist and it must never be flagged, in any fixture year.
|
||||||
|
con <- uscogdata:::.ensure_session()
|
||||||
|
years_all <- DBI::dbGetQuery(con, "SELECT DISTINCT year FROM long ORDER BY year")$year
|
||||||
|
states <- DBI::dbGetQuery(con,
|
||||||
|
"SELECT DISTINCT canonical_govid FROM long WHERE type = 0")$canonical_govid
|
||||||
|
oe <- suppressMessages(cog_spending(
|
||||||
|
states, years = years_all, category = "Other Education",
|
||||||
|
expenditure_concept = "total"
|
||||||
|
))
|
||||||
|
expect_false(isTRUE(attr(oe, "provenance")$expenditure_concept_direct_suppressed))
|
||||||
|
expect_false(any(nzchar(oe$notes) & grepl("unavailable", oe$notes)))
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("M/I: a clean FY2019 category = NULL total query flags far fewer than the pre-fix 32/50 states", {
|
||||||
|
con <- uscogdata:::.ensure_session()
|
||||||
|
states <- DBI::dbGetQuery(con,
|
||||||
|
"SELECT DISTINCT canonical_govid FROM long WHERE type = 0")$canonical_govid
|
||||||
|
r <- suppressMessages(cog_spending(
|
||||||
|
states, years = 2019, category = NULL, expenditure_concept = "total"
|
||||||
|
))
|
||||||
|
ig <- r[r$spend_subtype == "intergovernmental", ]
|
||||||
|
flagged <- ig[nzchar(ig$notes) & grepl("unavailable", ig$notes), ]
|
||||||
|
expect_lt(length(unique(flagged$canonical_govid)), 32L)
|
||||||
|
# Every remaining flagged row must actually name a covering recipe --
|
||||||
|
# never the old no-recipe-found fallback.
|
||||||
|
expect_true(all(grepl("recipe = '", flagged$notes, fixed = TRUE)))
|
||||||
|
expect_false(any(grepl("no covering recipe found", flagged$notes, fixed = TRUE)))
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("C2: expenditure_concept = 'total' aborts on a corpus with no intergovernmental category rows", {
|
||||||
|
with_corpus_missing_ig_categories({
|
||||||
|
con <- uscogdata:::.ensure_session()
|
||||||
|
n <- DBI::dbGetQuery(con,
|
||||||
|
"SELECT COUNT(*) AS n FROM summary_categories WHERE LEFT(item_code, 1) IN ('M', 'L')"
|
||||||
|
)$n
|
||||||
|
expect_equal(n, 0)
|
||||||
|
|
||||||
|
err <- tryCatch(
|
||||||
|
cog_spending("010000226085", years = 2019, category = "Police",
|
||||||
|
expenditure_concept = "total"),
|
||||||
|
condition = function(e) e
|
||||||
|
)
|
||||||
|
expect_s3_class(err, "uscogdata_ig_categories_unsupported")
|
||||||
|
msg <- conditionMessage(err)
|
||||||
|
expect_match(msg, "PR #59|predates", perl = TRUE)
|
||||||
|
})
|
||||||
|
|
||||||
|
# 'direct' is unaffected on the same corpus -- the guard is scoped to
|
||||||
|
# expenditure_concept = "total" only.
|
||||||
|
with_corpus_missing_ig_categories({
|
||||||
|
expect_no_error(
|
||||||
|
cog_spending("010000226085", years = 2019, category = "Police",
|
||||||
|
expenditure_concept = "direct")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("C2: expenditure_concept = 'total' still works on a corpus that DOES carry M/L category rows", {
|
||||||
|
expect_no_error(
|
||||||
|
cog_spending("010000226085", years = 2019, category = "Police",
|
||||||
|
expenditure_concept = "total")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("I2: an intergovernmental (M/L) recipe never appears as its own top-level suggestion", {
|
||||||
|
# Task 1's M04/M05 category rows share the "Corrections" summary_categories
|
||||||
|
# category with the Direct-flavored E04/E05, so `corrections_ig_local_
|
||||||
|
# combined` (entirely M-prefixed) becomes a raw *candidate* in
|
||||||
|
# .build_suggestions()'s component_code-driven query. Following a
|
||||||
|
# "re-run with recipe = 'corrections_ig_local_combined'" hint on a plain
|
||||||
|
# cog_spending() call would silently return intergovernmental dollars
|
||||||
|
# under provenance$expenditure_concept = "direct". Task 6's gate
|
||||||
|
# (.attach_ig_counterparts()) already protects the *counterpart* lookup;
|
||||||
|
# this exercises that the candidate list itself is filtered too.
|
||||||
|
r <- suppressMessages(
|
||||||
|
cog_spending("010000226085", years = c(2005, 2011), category = "Corrections")
|
||||||
|
)
|
||||||
|
sugg <- attr(r, "provenance")$suggestions
|
||||||
|
ids <- vapply(sugg, function(s) s$recipe_id %||% "", character(1))
|
||||||
|
expect_true("corrections_combined" %in% ids)
|
||||||
|
expect_false("corrections_ig_local_combined" %in% ids)
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that(".attach_ig_counterparts() never pairs a revenue-side recipe with its coincidental M/L suffix twin", {
|
||||||
|
# Broader version of the case above, run at the matching-helper level
|
||||||
|
# (the same level code review's pairwise enumeration was done at) rather
|
||||||
|
# than end-to-end: the fixture has no (govid, year) combination where
|
||||||
|
# cog_revenue() itself produces a covered gap for any B/C/D recipe, so an
|
||||||
|
# end-to-end repro for THIS specific set of recipes isn't reachable
|
||||||
|
# today. Each of these six recipes shares an exact suffix set with an
|
||||||
|
# M/L expenditure recipe purely by reused-digit coincidence:
|
||||||
|
# ig_federal_b47_wide {"47","94"} == ige_local_m47_wide / ige_state_l47_wide
|
||||||
|
# ig_federal_b89_wide {"89","91","92","93"} == ige_local_m89_wide / ige_state_l89_wide
|
||||||
|
# ig_state_c47_wide {"47","94"} == ige_local_m47_wide / ige_state_l47_wide
|
||||||
|
# ig_state_c89_wide {"89","91","92","93"} == ige_local_m89_wide / ige_state_l89_wide
|
||||||
|
# ig_local_d47_wide {"47","94"} == ige_local_m47_wide / ige_state_l47_wide
|
||||||
|
# ig_local_d89_wide {"89","91","92","93"} == ige_local_m89_wide / ige_state_l89_wide
|
||||||
|
# None of them may receive an ig_recipe_id under cog_revenue()'s own
|
||||||
|
# flow_prefixes, since M/L only ever pairs with the direct-expenditure
|
||||||
|
# (E/F/G) family.
|
||||||
|
con <- uscogdata:::.ensure_session()
|
||||||
|
fake_suggestion <- function(rid) {
|
||||||
|
list(recipe_id = rid, label = "x", available_years = c(1967L, 2023L),
|
||||||
|
hint = "h")
|
||||||
|
}
|
||||||
|
fake_suggestions <- lapply(
|
||||||
|
c("ig_federal_b47_wide", "ig_federal_b89_wide",
|
||||||
|
"ig_state_c47_wide", "ig_state_c89_wide",
|
||||||
|
"ig_local_d47_wide", "ig_local_d89_wide"),
|
||||||
|
fake_suggestion
|
||||||
|
)
|
||||||
|
out <- uscogdata:::.attach_ig_counterparts(
|
||||||
|
con, fake_suggestions, c("T", "A", "U", "B", "C", "D")
|
||||||
|
)
|
||||||
|
ig <- unlist(lapply(out, function(s) s$ig_recipe_id))
|
||||||
|
expect_length(ig, 0L)
|
||||||
|
})
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Madison walkthrough audit -- findings F-012, F-017, F-018.
|
||||||
|
# Tracked as uscogdata#11. See docs/walkthroughs/FINDINGS.md in cog_explorer.
|
||||||
|
#
|
||||||
|
# The owner's settled three-concept model (2026-07-28):
|
||||||
|
# total = primary + interest + intergovernmental transfers
|
||||||
|
# direct = primary + interest (Census's published Direct Expenditure)
|
||||||
|
# primary = direct minus debt service (the NEW DEFAULT)
|
||||||
|
# implemented by reclassifying on the crosswalk's `spend_type` column, NOT on
|
||||||
|
# item-code first letters -- F-018 shows prefix `Y` carries both revenue
|
||||||
|
# (Y01/Y02) and expenditure (Y05/Y06) codes, so no first-letter allowlist can
|
||||||
|
# route them correctly.
|
||||||
|
#
|
||||||
|
# Fixture reproducibility: the finding's headline reconciliation is Madison
|
||||||
|
# FY2022, where the corpus carries I89 = 46,609 (thousands) and Census's
|
||||||
|
# published Direct Expenditure is $654,893,000 against cog_spending()'s
|
||||||
|
# $608,284,000 (-7.1%). FY2022 is outside the bundled fixture's year window
|
||||||
|
# (2011/2012/2019/2020), so the same invariant is asserted on FY2020, where the
|
||||||
|
# fixture carries I89 = 27,704. Anyone running against the full corpus should
|
||||||
|
# also check the FY2022 numbers above.
|
||||||
|
|
||||||
|
test_that("expenditure concepts classify on spend_type, not item-code prefix", {
|
||||||
|
mad <- "552025209777" # MADISON CITY, WI
|
||||||
|
wi_state <- "550000227544" # WISCONSIN (state government)
|
||||||
|
|
||||||
|
# -- F-012: `primary` is the new default, and equals today's E/F/G figure ---
|
||||||
|
primary <- cog_spending(govid = mad, years = 2020L)
|
||||||
|
expect_equal(attr(primary, "provenance")$expenditure_concept, "primary")
|
||||||
|
expect_equal(sum(primary$amt_nominal), 623347000)
|
||||||
|
|
||||||
|
# -- F-012: `direct` adds interest on long-term debt ------------------------
|
||||||
|
# Expected interest read from the RAW corpus, never through cog_spending(),
|
||||||
|
# which is the filter under test.
|
||||||
|
interest <- wt_raw_amt(mad, 2020L, prefixes = "I")
|
||||||
|
expect_equal(interest, 27704) # I89, in $1,000s
|
||||||
|
|
||||||
|
direct <- cog_spending(govid = mad, years = 2020L, expenditure_concept = "direct")
|
||||||
|
expect_equal(sum(direct$amt_nominal), 651051000) # 623,347 + 27,704 thousands
|
||||||
|
expect_equal(sum(direct$amt_nominal) - sum(primary$amt_nominal), interest * 1000)
|
||||||
|
expect_true("I89" %in% wt_codes_included(direct))
|
||||||
|
|
||||||
|
# -- F-017: `total` carries Q12/Q18, state IG transfers to school districts --
|
||||||
|
# Wisconsin FY2019: Q12 = 6,431,530 and Q18 = 533,391 (thousands). Today
|
||||||
|
# neither verb's flow_prefixes contains "Q", so both are dropped from the one
|
||||||
|
# concept that is supposed to include intergovernmental transfers.
|
||||||
|
ig_expected <- wt_raw_amt(wi_state, 2019L, prefixes = c("M", "L", "Q"))
|
||||||
|
expect_equal(ig_expected, 11609814) # M 4,644,893 + Q 6,964,921
|
||||||
|
|
||||||
|
wi_direct <- cog_spending(govid = wi_state, years = 2019L,
|
||||||
|
expenditure_concept = "direct")
|
||||||
|
wi_total <- cog_spending(govid = wi_state, years = 2019L,
|
||||||
|
expenditure_concept = "total")
|
||||||
|
|
||||||
|
# total - direct is exactly the intergovernmental component. Asserted as a
|
||||||
|
# delta rather than a grand total so this stays correct however the J and Y
|
||||||
|
# families land inside `primary`.
|
||||||
|
expect_equal(sum(wi_total$amt_nominal) - sum(wi_direct$amt_nominal),
|
||||||
|
ig_expected * 1000)
|
||||||
|
expect_true(all(c("Q12", "Q18") %in% wt_codes_included(wi_total)))
|
||||||
|
|
||||||
|
# -- F-018: prefix Y splits revenue from expenditure, by spend_type ---------
|
||||||
|
# Y01/Y02 are Insurance Trust revenue; Y05/Y06 are Insurance Trust benefit
|
||||||
|
# payments. All four share the first letter `Y`, so no first-letter allowlist
|
||||||
|
# can route them. The proof that classification is crosswalk-keyed:
|
||||||
|
# Y05 lands in `total` spending (insurance_benefits is inside `direct`),
|
||||||
|
# while Y01 -- same prefix -- is classified `revenue` by the crosswalk and
|
||||||
|
# therefore can never appear in a spending result.
|
||||||
|
#
|
||||||
|
# Per the owner's 2026-07-30 ruling (#11 DoD item 4 vs #12), cog_revenue()'s
|
||||||
|
# DEFAULT stays Census General Revenue and so excludes insurance-trust
|
||||||
|
# revenue; Y01's revenue-side classification is asserted against the
|
||||||
|
# crosswalk itself, not the default call. Surfacing Y01 through an explicit
|
||||||
|
# revenue concept argument is uscogdata#12.
|
||||||
|
wi_revenue <- cog_revenue(govid = wi_state, years = 2019L)
|
||||||
|
spend_codes <- wt_codes_included(wi_total)
|
||||||
|
rev_codes <- wt_codes_included(wi_revenue)
|
||||||
|
|
||||||
|
expect_true("Y05" %in% spend_codes)
|
||||||
|
expect_false("Y05" %in% rev_codes)
|
||||||
|
expect_false("Y01" %in% spend_codes)
|
||||||
|
expect_false("Y01" %in% rev_codes) # default = general revenue (#12 ruling)
|
||||||
|
|
||||||
|
con <- uscogdata:::.ensure_session()
|
||||||
|
y_class <- DBI::dbGetQuery(con,
|
||||||
|
"SELECT item_code, category_type, spend_subtype, revenue_subtype
|
||||||
|
FROM summary_categories WHERE item_code IN ('Y01', 'Y05')")
|
||||||
|
expect_equal(y_class$category_type[y_class$item_code == "Y01"], "revenue")
|
||||||
|
expect_equal(y_class$revenue_subtype[y_class$item_code == "Y01"], "insurance_trust")
|
||||||
|
expect_equal(y_class$category_type[y_class$item_code == "Y05"], "expenditure")
|
||||||
|
expect_equal(y_class$spend_subtype[y_class$item_code == "Y05"], "insurance_benefits")
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("no balance code or category ever reaches a spending or revenue result (uscogdata#25)", {
|
||||||
|
# Stocks are not flows. The crosswalk's balance codes (W/X/Y/Z fund
|
||||||
|
# balances) share first letters with flow codes, so this could never be
|
||||||
|
# guaranteed under prefix classification; under crosswalk membership it
|
||||||
|
# falls out structurally -- asserted here at the verb level, on a
|
||||||
|
# government-year the fixture gives real balance rows (Wisconsin carries
|
||||||
|
# Y07/Y08/Y21/Y61-type balances in FY2019).
|
||||||
|
wi_state <- "550000227544"
|
||||||
|
con <- uscogdata:::.ensure_session()
|
||||||
|
balance <- DBI::dbGetQuery(con,
|
||||||
|
"SELECT item_code, category FROM summary_categories WHERE category_type = 'balance'")
|
||||||
|
expect_gt(nrow(balance), 0L)
|
||||||
|
|
||||||
|
spend <- cog_spending(wi_state, 2019L, expenditure_concept = "total")
|
||||||
|
rev <- cog_revenue(wi_state, 2019L)
|
||||||
|
|
||||||
|
expect_false(any(spend$category %in% balance$category))
|
||||||
|
expect_false(any(rev$category %in% balance$category))
|
||||||
|
expect_length(intersect(wt_codes_included(spend), balance$item_code), 0L)
|
||||||
|
expect_length(intersect(wt_codes_included(rev), balance$item_code), 0L)
|
||||||
|
})
|
||||||
@@ -31,6 +31,76 @@ test_that("cog_explain errors on non-verb input", {
|
|||||||
expect_error(cog_explain(df), "provenance")
|
expect_error(cog_explain(df), "provenance")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test_that("cog_explain prints basis + harmonization block", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
r <- cog_spending("121011212191", 2020L, "Corrections")
|
||||||
|
txt <- paste(c(
|
||||||
|
capture.output(cog_explain(r)),
|
||||||
|
capture.output(cog_explain(r), type = "message")
|
||||||
|
), collapse = "\n")
|
||||||
|
expect_true(grepl("Basis: harmonized", txt))
|
||||||
|
expect_true(grepl("Harmonization", txt))
|
||||||
|
expect_true(grepl("Excluded 0 row", txt))
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("cog_explain prints a Recipe section for recipe = results", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
r <- cog_spending("121011212191", c(2011L, 2012L), recipe = "corrections_combined")
|
||||||
|
txt <- paste(c(
|
||||||
|
capture.output(cog_explain(r)),
|
||||||
|
capture.output(cog_explain(r), type = "message")
|
||||||
|
), collapse = "\n")
|
||||||
|
expect_true(grepl("Recipe", txt))
|
||||||
|
expect_true(grepl("corrections_combined", txt))
|
||||||
|
expect_true(grepl("E04", txt))
|
||||||
|
expect_true(grepl("E05", txt))
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("cog_explain prints a Suggestions section when the provenance has one", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
r <- suppressMessages(
|
||||||
|
cog_spending("121011212191", c(2011L, 2012L), category = "Corrections")
|
||||||
|
)
|
||||||
|
txt <- paste(c(
|
||||||
|
capture.output(cog_explain(r)),
|
||||||
|
capture.output(cog_explain(r), type = "message")
|
||||||
|
), collapse = "\n")
|
||||||
|
expect_true(grepl("Suggestions", txt))
|
||||||
|
expect_true(grepl("corrections_combined", txt))
|
||||||
|
expect_true(grepl("re-run with recipe", txt))
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("cog_explain prints the expenditure concept (I1)", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
d <- cog_spending("010000226085", years = 2019, category = "Police")
|
||||||
|
t <- cog_spending("010000226085", years = 2019, category = "Police",
|
||||||
|
expenditure_concept = "total")
|
||||||
|
txt_d <- paste(c(
|
||||||
|
capture.output(cog_explain(d)),
|
||||||
|
capture.output(cog_explain(d), type = "message")
|
||||||
|
), collapse = "\n")
|
||||||
|
txt_t <- paste(c(
|
||||||
|
capture.output(cog_explain(t)),
|
||||||
|
capture.output(cog_explain(t), type = "message")
|
||||||
|
), collapse = "\n")
|
||||||
|
expect_true(grepl("Concept: primary", txt_d))
|
||||||
|
expect_true(grepl("Concept: total", txt_t))
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("cog_explain surfaces the C1(b) direct-suppressed flag as a warning", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
t <- suppressMessages(cog_spending(
|
||||||
|
"010000226085", years = 2011, category = "Corrections",
|
||||||
|
expenditure_concept = "total"
|
||||||
|
))
|
||||||
|
expect_true(attr(t, "provenance")$expenditure_concept_direct_suppressed)
|
||||||
|
txt <- paste(c(
|
||||||
|
capture.output(cog_explain(t)),
|
||||||
|
capture.output(cog_explain(t), type = "message")
|
||||||
|
), collapse = "\n")
|
||||||
|
expect_true(grepl("Direct leg unavailable", txt))
|
||||||
|
})
|
||||||
|
|
||||||
test_that("cog_explain prints denominator + popyear_range + counts", {
|
test_that("cog_explain prints denominator + popyear_range + counts", {
|
||||||
skip_if_no_corpus()
|
skip_if_no_corpus()
|
||||||
with_fixture_corpus({
|
with_fixture_corpus({
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# tests/testthat/test-fixture-vintage.R
|
||||||
|
#
|
||||||
|
# The bundled fixture is a slice of a real cog_pipeline publish tree, and
|
||||||
|
# every test in this package -- plus the whole cog-api suite -- runs against
|
||||||
|
# it. When the published corpus changes shape and the fixture does not, both
|
||||||
|
# suites stay green against a corpus that no longer exists (uscogdata#18).
|
||||||
|
#
|
||||||
|
# These tests pin the structural facts that distinguish the current published
|
||||||
|
# vintage from its predecessor, so a stale fixture fails loudly instead of
|
||||||
|
# passing quietly. They assert shape, never dollar values: re-running
|
||||||
|
# data-raw/regenerate_fixture_corpus.R against a newer publish tree should
|
||||||
|
# keep them green.
|
||||||
|
|
||||||
|
# Open a bare DuckDB connection on the fixture's parquet files. Deliberately
|
||||||
|
# not the package session: these assertions are about what the fixture
|
||||||
|
# CONTAINS, and routing them through the reader's own views would let a
|
||||||
|
# filter hide the very absence being checked.
|
||||||
|
fixture_query <- function(sql, ...) {
|
||||||
|
con <- DBI::dbConnect(duckdb::duckdb())
|
||||||
|
on.exit(DBI::dbDisconnect(con, shutdown = TRUE), add = TRUE)
|
||||||
|
path <- function(rel) {
|
||||||
|
sprintf("read_parquet(%s)",
|
||||||
|
DBI::dbQuoteString(con, file.path(fixture_corpus_path(), rel)))
|
||||||
|
}
|
||||||
|
DBI::dbGetQuery(con, do.call(sprintf, c(list(sql), lapply(c(...), path))))
|
||||||
|
}
|
||||||
|
|
||||||
|
test_that("fixture ships every metadata table the publish tree does", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
# representation/code_set are what make a sparse corpus interpretable; a
|
||||||
|
# fixture without them predates sparsification (cog_pipeline#64).
|
||||||
|
expected <- c(
|
||||||
|
"canonical_alias.parquet", "canonical_fips_xwalk.parquet",
|
||||||
|
"census_collection_coverage.parquet", "code_set.parquet",
|
||||||
|
"harmonization_map.parquet", "harmonization_recipes.parquet",
|
||||||
|
"lineage_events.parquet", "representation.parquet",
|
||||||
|
"series_breaks.parquet", "summary_categories.parquet"
|
||||||
|
)
|
||||||
|
on_disk <- basename(list.files(
|
||||||
|
file.path(fixture_corpus_path(), "data"), pattern = "\\.parquet$"
|
||||||
|
))
|
||||||
|
expect_true(all(expected %in% on_disk))
|
||||||
|
|
||||||
|
# The manifest must list them too -- consumers read the manifest, not ls().
|
||||||
|
in_manifest <- with_fixture_corpus(
|
||||||
|
basename(vapply(cog_manifest()$files$metadata, function(f) f$path, character(1)))
|
||||||
|
)
|
||||||
|
expect_true(all(expected %in% in_manifest))
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("fixture carries the dense/sparse representation contract", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
rep <- fixture_query(
|
||||||
|
"SELECT year, representation, absence_means FROM %s
|
||||||
|
WHERE year IN (2011, 2012, 2019, 2020) ORDER BY year",
|
||||||
|
"data/representation.parquet"
|
||||||
|
)
|
||||||
|
expect_equal(nrow(rep), 4L)
|
||||||
|
expect_equal(rep$representation, c("dense_source", rep("sparse_source", 3L)))
|
||||||
|
expect_equal(rep$absence_means, c("census_zero", rep("not_reported", 3L)))
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("the fixture's wide era is sparse, not zero-padded", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
# FY2011 is a dense_source year: the corpus publishes only the cells Census
|
||||||
|
# reported non-zero, and an absent cell means Census published $0. Before
|
||||||
|
# sparsification this partition was 2,864,212 rows, ~83% of them explicit
|
||||||
|
# zeros. A single explicit zero here means the fixture predates the change.
|
||||||
|
zeros_2011 <- fixture_query(
|
||||||
|
"SELECT COUNT(*) AS n FROM %s WHERE amt = 0",
|
||||||
|
"data/long/year=2011/part-0.parquet"
|
||||||
|
)$n
|
||||||
|
expect_equal(zeros_2011, 0L)
|
||||||
|
|
||||||
|
# The modern era is a different regime: a reported zero there is real data
|
||||||
|
# (the government filed $0), so zeros legitimately survive and must not be
|
||||||
|
# asserted away.
|
||||||
|
expect_gt(
|
||||||
|
fixture_query("SELECT COUNT(*) AS n FROM %s", "data/long/year=2012/part-0.parquet")$n,
|
||||||
|
0L
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("code_set covers every fixture year with the reader-spec columns", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
cs <- fixture_query(
|
||||||
|
"SELECT * FROM %s WHERE year IN (2011, 2012, 2019, 2020)",
|
||||||
|
"data/code_set.parquet"
|
||||||
|
)
|
||||||
|
expect_true(all(
|
||||||
|
c("code_set_id", "year", "type", "item_code", "is_aggregate", "n_units")
|
||||||
|
%in% names(cs)
|
||||||
|
))
|
||||||
|
expect_setequal(unique(cs$year), c(2011L, 2012L, 2019L, 2020L))
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("every flow code carrying dollars has a category, J-prefix included", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
# The J (assistance/benefit) codes were uncategorised until the crosswalk
|
||||||
|
# completion shipped (cog_pipeline#60/#65, J19 held back until #64's
|
||||||
|
# duplication fix landed). Their absence is how a pre-crosswalk fixture
|
||||||
|
# gives itself away.
|
||||||
|
j <- fixture_query(
|
||||||
|
"SELECT item_code, category, category_type, spend_subtype FROM %s
|
||||||
|
WHERE LEFT(item_code, 1) = 'J' ORDER BY item_code",
|
||||||
|
"data/summary_categories.parquet"
|
||||||
|
)
|
||||||
|
expect_true("J19" %in% j$item_code)
|
||||||
|
expect_true(all(j$category_type == "expenditure"))
|
||||||
|
expect_true(all(j$spend_subtype == "assistance"))
|
||||||
|
expect_false(any(is.na(j$category)))
|
||||||
|
})
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Madison walkthrough audit -- finding F-025. Tracked as uscogdata#16.
|
||||||
|
# See docs/walkthroughs/FINDINGS.md in cog_explorer.
|
||||||
|
#
|
||||||
|
# cog_gov_search()'s UTILITY mode interpolates `name` into
|
||||||
|
# regexp_matches(gov_name, <name>, 'i')
|
||||||
|
# unescaped (R/search.R:102), while BASKET mode in the same file already routes
|
||||||
|
# it through .escape_regex() (R/search.R:307) with the comment "so `name` is
|
||||||
|
# treated as a literal substring". Two failure modes result:
|
||||||
|
# correctness -- a real government cannot be found by its own exact name, and
|
||||||
|
# a single "." matches everything (HTTP 200 both ways via the API);
|
||||||
|
# robustness -- malformed regex reaches the engine and errors, which cog-api
|
||||||
|
# surfaces as a 500, reachable by typing a real name one
|
||||||
|
# character at a time.
|
||||||
|
#
|
||||||
|
# NOT asserted here: the finding's `q=St. Louis` example. Under correct literal
|
||||||
|
# matching that search still returns 0 rows, because the stored name is
|
||||||
|
# "ST LOUIS CITY" with no period -- it demonstrates today's over-matching
|
||||||
|
# semantics, not a row the fix makes findable.
|
||||||
|
|
||||||
|
test_that("cog_gov_search() matches name literally, not as an unescaped regex", {
|
||||||
|
|
||||||
|
# -- correctness (1): a government must be findable by its own exact name ---
|
||||||
|
# FREDONIA (BRISCOE) CITY is real; today the parentheses are read as regex
|
||||||
|
# grouping, so its own complete name matches nothing.
|
||||||
|
fredonia <- cog_gov_search(name = "FREDONIA (BRISCOE) CITY")
|
||||||
|
expect_equal(nrow(fredonia), 1L)
|
||||||
|
expect_equal(fredonia$canonical_govid, "052117184386")
|
||||||
|
expect_equal(cog_gov_search(name = "FREDONIA (BRISCOE)")$canonical_govid,
|
||||||
|
"052117184386")
|
||||||
|
|
||||||
|
# -- correctness (2): a metacharacter must not become a wildcard ------------
|
||||||
|
# No Wisconsin city or village name contains a literal period -- established
|
||||||
|
# against the raw registry below, NOT through the verb under test. A literal
|
||||||
|
# search for "." must therefore return nothing; today it returns all 608.
|
||||||
|
con <- DBI::dbConnect(duckdb::duckdb())
|
||||||
|
on.exit(DBI::dbDisconnect(con, shutdown = TRUE), add = TRUE)
|
||||||
|
xwalk <- paste0(sub("/$", "", Sys.getenv("USCOGDATA_URL")),
|
||||||
|
"/data/canonical_fips_xwalk.parquet")
|
||||||
|
with_dot <- DBI::dbGetQuery(con, paste0(
|
||||||
|
"SELECT COUNT(*) n FROM read_parquet('", xwalk, "') ",
|
||||||
|
"WHERE fips_state = '55' AND govs_type = 2 AND gov_name LIKE '%.%'"))
|
||||||
|
expect_equal(as.integer(with_dot$n[[1]]), 0L)
|
||||||
|
|
||||||
|
expect_equal(nrow(cog_gov_search(name = ".", state = "WI", type = "city")), 0L)
|
||||||
|
expect_equal(nrow(cog_gov_search(name = "M.dison", state = "WI", type = "city")), 0L)
|
||||||
|
expect_equal(nrow(cog_gov_search(name = "Mad(i|o)son", state = "WI", type = "city")), 0L)
|
||||||
|
|
||||||
|
# A metacharacter-free name still resolves exactly as before.
|
||||||
|
expect_equal(nrow(cog_gov_search(name = "Madison", state = "WI", type = "city")), 1L)
|
||||||
|
|
||||||
|
# -- robustness: malformed pattern text returns no rows, and does not error --
|
||||||
|
# "[" alone, and "Athens-Clarke County (bal" -- an in-progress substring of
|
||||||
|
# ATHENS-CLARKE COUNTY (BALANCE), a real government -- both currently raise
|
||||||
|
# (DuckDB: "Invalid Input Error: missing ]").
|
||||||
|
expect_equal(nrow(cog_gov_search(name = "[")), 0L)
|
||||||
|
expect_equal(nrow(cog_gov_search(name = "Athens-Clarke County (bal")), 0L)
|
||||||
|
})
|
||||||
@@ -107,6 +107,49 @@ test_that("cog_manifest returns the active session's parsed manifest", {
|
|||||||
expect_true(m$schema_version >= 4L)
|
expect_true(m$schema_version >= 4L)
|
||||||
yrs <- vapply(m$files$long_partitions, function(p) as.integer(p$year),
|
yrs <- vapply(m$files$long_partitions, function(p) as.integer(p$year),
|
||||||
integer(1))
|
integer(1))
|
||||||
expect_setequal(yrs, c(2019L, 2020L))
|
expect_setequal(yrs, c(2011L, 2012L, 2019L, 2020L))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that(".validate_schema accepts schema_version 4, 5 and 6, rejects others", {
|
||||||
|
expect_silent(uscogdata:::.validate_schema(list(schema_version = 4L)))
|
||||||
|
expect_silent(uscogdata:::.validate_schema(list(schema_version = 5L)))
|
||||||
|
# v6 = FIPS geography harmonization (2026-07-22): _code -> _asof rename +
|
||||||
|
# cog_legacy_* columns (26 -> 28 cols). This package references none of the
|
||||||
|
# renamed columns and its geography comes from the xwalk, so v6 is accepted
|
||||||
|
# without behavioural change -- see .validate_schema()'s note.
|
||||||
|
expect_silent(uscogdata:::.validate_schema(list(schema_version = 6L)))
|
||||||
|
expect_error(
|
||||||
|
uscogdata:::.validate_schema(list(schema_version = 3L)),
|
||||||
|
"schema_version"
|
||||||
|
)
|
||||||
|
expect_error(
|
||||||
|
uscogdata:::.validate_schema(list(schema_version = 7L)),
|
||||||
|
"schema_version"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("cog_open succeeds against a doctored schema_version 4 corpus (dual-accept)", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
with_doctored_schema_version(4L, {
|
||||||
|
con <- cog_open()
|
||||||
|
expect_true(DBI::dbIsValid(con))
|
||||||
|
expect_equal(as.integer(cog_manifest()$schema_version), 4L)
|
||||||
|
|
||||||
|
# Core (pre-Phase-R2) views must still register on a v4 corpus.
|
||||||
|
views <- DBI::dbGetQuery(con,
|
||||||
|
"SELECT table_name FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'main' AND table_type = 'VIEW'"
|
||||||
|
)$table_name
|
||||||
|
expect_true(all(c("spending_annotated", "revenue_annotated") %in% views))
|
||||||
|
|
||||||
|
# Schema-v5-only harmonization views must NOT register on a v4 corpus:
|
||||||
|
# their parquet sources don't exist there and DuckDB's read_parquet()
|
||||||
|
# errors eagerly at CREATE VIEW time for a missing file/glob, so
|
||||||
|
# .register_views() gates these on manifest$schema_version >= 5.
|
||||||
|
expect_false(any(c(
|
||||||
|
"spending_long_harmonized", "spending_annotated_harmonized",
|
||||||
|
"harmonization_recipes", "harmonization_map", "series_breaks_pq"
|
||||||
|
) %in% views))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Madison walkthrough audit -- finding F-021. Tracked as uscogdata#14.
|
||||||
|
# See docs/walkthroughs/FINDINGS.md in cog_explorer.
|
||||||
|
#
|
||||||
|
# .peer_summary_rows() computes stats::quantile() separately INSIDE each
|
||||||
|
# (year, spend_subtype, category) cell. A summary_p50 row is therefore "the
|
||||||
|
# median peer's value in that one category", not "the value of the median
|
||||||
|
# peer's total". Summing those rows across categories -- the obvious move for a
|
||||||
|
# caller who wants one peer-median total line and reads only the column names --
|
||||||
|
# misstated a total-spending band by -32.7% to +251.0% across the 24 years the
|
||||||
|
# audit tested, with a sign flip at FY2012.
|
||||||
|
#
|
||||||
|
# The verb is not wrong and its documented use (faceting by role AND category)
|
||||||
|
# is unaffected, so the fix is documentation: one sentence in @return.
|
||||||
|
|
||||||
|
test_that("cog_peer_compare() documents that summary_* rows are per-category quantiles", {
|
||||||
|
|
||||||
|
# man/ ships only in the source tree (the installed package carries a
|
||||||
|
# compiled help database instead), so the prose assertions below cannot run
|
||||||
|
# under R CMD check -- CI's earlier testthat::test_local() step enforces
|
||||||
|
# them. The numeric pin further down needs only the corpus, but it lives in
|
||||||
|
# the same test_that() as the sentence it protects, deliberately: they are
|
||||||
|
# one claim, and splitting them would let the prose drift while a separate
|
||||||
|
# test kept passing.
|
||||||
|
rd_path <- skip_if_no_source_tree(c("man", "cog_peer_compare.Rd"))
|
||||||
|
rd <- paste(readLines(rd_path, warn = FALSE), collapse = " ")
|
||||||
|
|
||||||
|
# The @return section must say the quantile is computed within each cell...
|
||||||
|
expect_match(rd, "within each|per-category|per category", ignore.case = TRUE)
|
||||||
|
# ...and must warn that the rows are not additive across category.
|
||||||
|
expect_match(rd, "not additive|do(es)? not sum|cannot be summed", ignore.case = TRUE)
|
||||||
|
# ...naming the grouping explicitly.
|
||||||
|
expect_match(rd, "spend_subtype", fixed = TRUE)
|
||||||
|
|
||||||
|
# Pin the mechanism numerically so a future refactor that quietly changes the
|
||||||
|
# quantile grouping fails here rather than silently invalidating the sentence
|
||||||
|
# above. Fixture: Madison, 10 peers found at FY2020, category = NULL.
|
||||||
|
peers <- cog_find_peers("552025209777", year = 2020L, max_peers = 10L)
|
||||||
|
cmp <- cog_peer_compare(target_govid = "552025209777", peers = peers,
|
||||||
|
category = NULL, years = 2020L, per_capita = TRUE)
|
||||||
|
|
||||||
|
naive <- sum(cmp$amt_per_capita_nominal[cmp$role == "summary_p50"], na.rm = TRUE)
|
||||||
|
|
||||||
|
peer_rows <- cmp[cmp$role == "peer", ]
|
||||||
|
per_gov <- tapply(peer_rows$amt_per_capita_nominal, peer_rows$canonical_govid,
|
||||||
|
sum, na.rm = TRUE)
|
||||||
|
correct <- unname(stats::quantile(per_gov, 0.5, na.rm = TRUE))
|
||||||
|
|
||||||
|
expect_equal(round(naive), 6180) # summing the built-in summary rows
|
||||||
|
expect_equal(round(correct), 2043) # quantile of each peer's OWN total
|
||||||
|
expect_gt(naive / correct, 2) # a +200% misstatement on this cohort
|
||||||
|
})
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
# tests/testthat/test-recipes.R
|
||||||
|
#
|
||||||
|
# cog_recipes(), recipe = in cog_spending()/cog_revenue(), and the
|
||||||
|
# recipe-component-driven signposting in prov$suggestions (Phase R2 /
|
||||||
|
# Task 11, schema_version 5).
|
||||||
|
|
||||||
|
test_that("cog_recipes lists the curated catalog including corrections_combined", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
r <- cog_recipes()
|
||||||
|
expect_s3_class(r, "tbl_df")
|
||||||
|
expect_equal(names(r), c("recipe_id", "label", "n_components", "year_min", "year_max"))
|
||||||
|
expect_equal(nrow(r), 24L)
|
||||||
|
expect_true("corrections_combined" %in% r$recipe_id)
|
||||||
|
expect_true("t19_selective_sales_wide" %in% r$recipe_id)
|
||||||
|
expect_true("ig_federal_b89_wide" %in% r$recipe_id)
|
||||||
|
expect_true("rents_royalties_u4_wide" %in% r$recipe_id)
|
||||||
|
expect_true("higher_ed_e18_wide" %in% r$recipe_id)
|
||||||
|
expect_true("cash_securities_z77_wide" %in% r$recipe_id)
|
||||||
|
# Superseded id from the pre-curation brief text must NOT be present.
|
||||||
|
expect_false("corrections_judicial_combined" %in% r$recipe_id)
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("cog_recipes(pattern=) filters by recipe_id or label", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
r <- cog_recipes("corrections")
|
||||||
|
expect_true(nrow(r) >= 1L)
|
||||||
|
expect_true(all(grepl("corrections", r$recipe_id, ignore.case = TRUE) |
|
||||||
|
grepl("corrections", r$label, ignore.case = TRUE)))
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("cog_recipes requires schema_version >= 5", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
with_doctored_schema_version(4L, {
|
||||||
|
expect_error(cog_recipes(), class = "uscogdata_schema_unsupported")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
# --- recipe = : generic join, no is_aggregate filter -----------------------
|
||||||
|
|
||||||
|
test_that("recipe = 'corrections_combined' is continuous across the 2011->2012 seam", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
r <- cog_spending("121011212191", years = c(2011L, 2012L),
|
||||||
|
recipe = "corrections_combined")
|
||||||
|
expect_equal(nrow(r), 2L)
|
||||||
|
expect_true(all(c("year", "canonical_govid", "gov_name", "spend_subtype",
|
||||||
|
"category", "amt_nominal", "codes_included",
|
||||||
|
"aggregate_fallback", "notes") %in% names(r)))
|
||||||
|
expect_equal(unique(r$spend_subtype), "recipe")
|
||||||
|
expect_equal(unique(r$category), "Corrections (functions 04+05 combined)")
|
||||||
|
expect_false(any(r$aggregate_fallback))
|
||||||
|
|
||||||
|
r2011 <- r$amt_nominal[r$year == 2011L]
|
||||||
|
r2012 <- r$amt_nominal[r$year == 2012L]
|
||||||
|
# 2011: E05 only exists as a wide-era AGGREGATE row (is_aggregate = TRUE)
|
||||||
|
# for Broward -- data-verified $216,088,000. Since .run_recipe() does NOT
|
||||||
|
# filter is_aggregate (amendment: the recipe join must not, because these
|
||||||
|
# families exist ONLY as aggregate rows in the wide era), the recipe
|
||||||
|
# correctly picks this up.
|
||||||
|
expect_equal(r2011, 216088000)
|
||||||
|
# 2012: modern E04 leaf ($213,056,000); Broward reports no E05 leaf that
|
||||||
|
# year, so the recipe total equals E04 alone -- still continuous with the
|
||||||
|
# 2011 aggregate, proving the wide-aggregate -> modern-leaf handoff.
|
||||||
|
expect_equal(r2012, 213056000)
|
||||||
|
expect_true(all(grepl("E04|E05", r$codes_included)))
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("recipe result carries a recipe provenance block with component rows", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
r <- cog_spending("121011212191", years = c(2011L, 2012L),
|
||||||
|
recipe = "corrections_combined")
|
||||||
|
prov <- attr(r, "provenance")
|
||||||
|
expect_equal(prov$basis, "recipe")
|
||||||
|
expect_equal(prov$category, "Corrections (functions 04+05 combined)")
|
||||||
|
expect_type(prov$recipe, "list")
|
||||||
|
expect_equal(prov$recipe$recipe_id, "corrections_combined")
|
||||||
|
expect_equal(prov$recipe$label, "Corrections (functions 04+05 combined)")
|
||||||
|
expect_length(prov$recipe$components, 2L)
|
||||||
|
comp_codes <- vapply(prov$recipe$components, function(x) x$component_code, character(1))
|
||||||
|
expect_setequal(comp_codes, c("E04", "E05"))
|
||||||
|
# A recipe query resolves its own coverage; it should never also carry
|
||||||
|
# suggestions for itself.
|
||||||
|
expect_length(prov$suggestions, 0L)
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("recipe results report an unambiguous basis/harmonization, ignoring basis=", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
# A recipe query bypasses spending_annotated(_harmonized) entirely --
|
||||||
|
# .run_recipe() joins `long` directly -- so `basis` must never read
|
||||||
|
# "harmonized"/"raw" (which would describe a code path this query never
|
||||||
|
# took) regardless of what the caller passed for `basis`. Task 12
|
||||||
|
# consumes provenance verbatim, so this needs to be unambiguous.
|
||||||
|
r_default <- cog_spending("121011212191", years = c(2011L, 2012L),
|
||||||
|
recipe = "corrections_combined")
|
||||||
|
r_raw <- cog_spending("121011212191", years = c(2011L, 2012L),
|
||||||
|
recipe = "corrections_combined", basis = "raw")
|
||||||
|
r_harm <- cog_spending("121011212191", years = c(2011L, 2012L),
|
||||||
|
recipe = "corrections_combined", basis = "harmonized")
|
||||||
|
|
||||||
|
for (r in list(r_default, r_raw, r_harm)) {
|
||||||
|
prov <- attr(r, "provenance")
|
||||||
|
expect_equal(prov$basis, "recipe")
|
||||||
|
expect_true(is.na(prov$basis_note))
|
||||||
|
expect_false(prov$harmonization$applied)
|
||||||
|
expect_equal(prov$harmonization$na_rows_excluded, 0L)
|
||||||
|
expect_match(prov$harmonization$note, "recipe", ignore.case = TRUE)
|
||||||
|
}
|
||||||
|
|
||||||
|
# basis= truly has zero effect on a recipe query's actual numbers.
|
||||||
|
expect_equal(r_raw$amt_nominal, r_harm$amt_nominal)
|
||||||
|
expect_equal(r_default$amt_nominal, r_raw$amt_nominal)
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("recipe = 't19_selective_sales_wide' sums the local T11/T14 legs when present", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
# Westminster City, CA (canonical_govid 082001211654): T11 = 0 in 2011,
|
||||||
|
# T11 = 568 (T14 = 0/absent) in 2012 -- a real, data-verified equality/
|
||||||
|
# inequality pair inside the amended fixture window (2011-2012), standing
|
||||||
|
# in for the brief's original 2004/2005 example (out of scope per the
|
||||||
|
# amended fixture years; the underlying local-tax-split boundary is
|
||||||
|
# nationally FY2005, but this government's own T11 reporting activates
|
||||||
|
# within our 2011-2012 window).
|
||||||
|
r <- cog_revenue("082001211654", years = c(2011L, 2012L),
|
||||||
|
recipe = "t19_selective_sales_wide")
|
||||||
|
|
||||||
|
# Raw, single-code T19 total (not the "Other Taxes" category total, which
|
||||||
|
# would also sum in T11/T14/T21/T23/T27/T29/T53/T99 -- queried directly to
|
||||||
|
# isolate exactly the code the brief's equality/inequality check is about).
|
||||||
|
con <- uscogdata:::.ensure_session()
|
||||||
|
raw_t19 <- DBI::dbGetQuery(con, "
|
||||||
|
SELECT year, SUM(amt) * 1000.0 AS amt
|
||||||
|
FROM revenue_long
|
||||||
|
WHERE canonical_govid = '082001211654' AND item_code = 'T19'
|
||||||
|
AND year IN (2011, 2012)
|
||||||
|
GROUP BY year ORDER BY year
|
||||||
|
")
|
||||||
|
raw_t19_2011 <- raw_t19$amt[raw_t19$year == 2011L]
|
||||||
|
raw_t19_2012 <- raw_t19$amt[raw_t19$year == 2012L]
|
||||||
|
expect_equal(raw_t19_2011, 2231000)
|
||||||
|
expect_equal(raw_t19_2012, 2365000)
|
||||||
|
|
||||||
|
recipe_2011 <- r$amt_nominal[r$year == 2011L]
|
||||||
|
recipe_2012 <- r$amt_nominal[r$year == 2012L]
|
||||||
|
|
||||||
|
expect_equal(recipe_2011, raw_t19_2011) # equality: no local T11/T14 yet
|
||||||
|
expect_gt(recipe_2012, raw_t19_2012) # inequality: local T11 joins in
|
||||||
|
expect_equal(recipe_2012, raw_t19_2012 + 568000)
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("recipe = and category = together aborts", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
expect_error(
|
||||||
|
cog_spending("121011212191", 2020L, category = "Corrections",
|
||||||
|
recipe = "corrections_combined"),
|
||||||
|
class = "uscogdata_recipe_category_conflict"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("unknown recipe id aborts and lists valid ids", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
err <- tryCatch(
|
||||||
|
cog_spending("121011212191", 2020L, recipe = "does_not_exist"),
|
||||||
|
error = identity
|
||||||
|
)
|
||||||
|
expect_s3_class(err, "uscogdata_unknown_recipe")
|
||||||
|
expect_match(conditionMessage(err), "corrections_combined")
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("recipe = requires schema_version >= 5", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
with_doctored_schema_version(4L, {
|
||||||
|
expect_error(
|
||||||
|
cog_spending("121011212191", 2020L, recipe = "corrections_combined"),
|
||||||
|
class = "uscogdata_schema_unsupported"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
# --- signposting -------------------------------------------------------
|
||||||
|
|
||||||
|
test_that("signposting suggests corrections_combined across the 2011->2012 gap", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
expect_message(
|
||||||
|
r <- cog_spending("121011212191", years = c(2011L, 2012L),
|
||||||
|
category = "Corrections"),
|
||||||
|
"recipe"
|
||||||
|
)
|
||||||
|
prov <- attr(r, "provenance")
|
||||||
|
expect_true(length(prov$suggestions) >= 1L)
|
||||||
|
ids <- vapply(prov$suggestions, function(s) s$recipe_id, character(1))
|
||||||
|
expect_true("corrections_combined" %in% ids)
|
||||||
|
hit <- prov$suggestions[[which(ids == "corrections_combined")]]
|
||||||
|
expect_equal(hit$hint, "re-run with recipe = 'corrections_combined'")
|
||||||
|
expect_equal(hit$available_years, c(1967L, 2023L))
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("no signposting when the result already has full year coverage", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
r <- cog_spending("121011212191", years = 2019:2020, category = "Corrections")
|
||||||
|
prov <- attr(r, "provenance")
|
||||||
|
expect_length(prov$suggestions, 0L)
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("no signposting when category is NULL (unscoped query)", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
r <- cog_spending("121011212191", years = c(2011L, 2012L))
|
||||||
|
prov <- attr(r, "provenance")
|
||||||
|
expect_length(prov$suggestions, 0L)
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("no signposting under basis = 'raw'", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
r <- cog_spending("121011212191", years = c(2011L, 2012L),
|
||||||
|
category = "Corrections", basis = "raw")
|
||||||
|
prov <- attr(r, "provenance")
|
||||||
|
expect_length(prov$suggestions, 0L)
|
||||||
|
})
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# Madison walkthrough audit -- finding F-014. Tracked as uscogdata#12.
|
||||||
|
# See docs/walkthroughs/FINDINGS.md in cog_explorer.
|
||||||
|
#
|
||||||
|
# cog_revenue()'s flow_prefixes = c("T","A","U","B","C","D") never returns
|
||||||
|
# item-code prefix X (Employee Retirement) or Y (other Insurance Trust). Per
|
||||||
|
# Census's standard identity, Total Revenue = General + Utility + Liquor Store +
|
||||||
|
# Insurance Trust Revenue, and Employee Retirement System contributions and
|
||||||
|
# earnings ARE the Insurance Trust Revenue component -- so prefix X sits inside
|
||||||
|
# a published Census revenue concept exactly the way I89 sits inside Census's
|
||||||
|
# Direct Expenditure concept (finding F-012).
|
||||||
|
#
|
||||||
|
# RULED 2026-07-30. `revenue_concept = c("general", "total")` mirrors
|
||||||
|
# `expenditure_concept`, and the two values are Census's two published revenue
|
||||||
|
# concepts, related by the manual's own identity (section 4.3, which defines
|
||||||
|
# the first by SUBTRACTING from the second):
|
||||||
|
#
|
||||||
|
# Total Revenue = General + Utility + Liquor Store + Insurance Trust
|
||||||
|
#
|
||||||
|
# so `general` is the four general subtypes (own_source/federal/state/
|
||||||
|
# local_aid) and `total` is every revenue subtype. Naming utility (A91-A94)
|
||||||
|
# and liquor store (A90) separately is what makes BOTH computable -- before
|
||||||
|
# cog_pipeline#79 they sat in own_source, so the default was really
|
||||||
|
# "General + Utility + Liquor", a concept Census does not publish.
|
||||||
|
#
|
||||||
|
# Fixture reproducibility: Madison's own X-prefix revenue (FY1970-FY1986,
|
||||||
|
# $15,098,000 nominal, $0 thereafter) is outside the bundled fixture's year
|
||||||
|
# window (2011/2012/2019/2020), so the same invariant is asserted on Wisconsin
|
||||||
|
# state government FY2012, where the fixture carries nonzero X01/X02/X05/X08.
|
||||||
|
|
||||||
|
test_that("cog_revenue() can return Census Total Revenue including Insurance Trust (prefix X)", {
|
||||||
|
wi_state <- "550000227544" # WISCONSIN (state government)
|
||||||
|
|
||||||
|
# Revenue-shaped Employee Retirement codes, read from the RAW corpus rather
|
||||||
|
# than through cog_revenue(), which is the filter under test:
|
||||||
|
# X01/X02 employee contributions, X05 contributions from other governments,
|
||||||
|
# X08 total earnings on investments.
|
||||||
|
#
|
||||||
|
# X04 and X06 are deliberately NOT in this set, though an earlier draft of
|
||||||
|
# this test included X04. Both are exhibit codes for INTRAgovernmental
|
||||||
|
# transfers (the administering government paying into its own fund), which
|
||||||
|
# X05's own definition excludes by name. Census agrees: its computed "Total
|
||||||
|
# Emp Ret Rev" for this government-year is exactly the four codes below.
|
||||||
|
x_revenue <- wt_raw_amt(wi_state, 2012L, codes = c("X01", "X02", "X05", "X08"))
|
||||||
|
expect_equal(x_revenue, 2283883) # 615,835 + 245,083 + 560,382 + 862,583
|
||||||
|
|
||||||
|
# The Y-prefix insurance trust revenue (unemployment + workers comp), which
|
||||||
|
# is the other half of the same Census concept.
|
||||||
|
y_revenue <- wt_raw_amt(wi_state, 2012L, codes = c("Y01", "Y11"))
|
||||||
|
expect_equal(y_revenue, 1259785)
|
||||||
|
|
||||||
|
general <- cog_revenue(govid = wi_state, years = 2012L)
|
||||||
|
expect_equal(attr(general, "provenance")$revenue_concept, "general")
|
||||||
|
expect_equal(sum(general$amt_nominal), 31338293000)
|
||||||
|
|
||||||
|
total <- cog_revenue(govid = wi_state, years = 2012L, revenue_concept = "total")
|
||||||
|
expect_equal(attr(total, "provenance")$revenue_concept, "total")
|
||||||
|
|
||||||
|
# total - general is the whole insurance trust leg, X and Y together.
|
||||||
|
# Asserted as a delta as well as a level so this stays correct however the
|
||||||
|
# utility/liquor families land (both are $0 for WI state in FY2012).
|
||||||
|
expect_equal(sum(total$amt_nominal) - sum(general$amt_nominal),
|
||||||
|
(x_revenue + y_revenue) * 1000)
|
||||||
|
expect_equal(sum(total$amt_nominal), 34881961000)
|
||||||
|
expect_true(all(c("X01", "X02", "X05", "X08") %in% wt_codes_included(total)))
|
||||||
|
|
||||||
|
# Sibling codes under the SAME first letter must stay out: X11/X12 are
|
||||||
|
# benefit payments (an expenditure) and X21/X30/X47 are cash and securities
|
||||||
|
# holdings (a balance-sheet stock). This is the F-018 point restated on the
|
||||||
|
# revenue side -- the split comes from the crosswalk, not from the letter X.
|
||||||
|
expect_false(any(c("X11", "X12", "X21", "X30", "X47") %in% wt_codes_included(total)))
|
||||||
|
|
||||||
|
# Every returned row still resolves to a category (cog_pipeline#79 added the
|
||||||
|
# X crosswalk rows; relaxing a prefix filter alone would have produced
|
||||||
|
# category = NA rows).
|
||||||
|
expect_false(any(is.na(total$category)))
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("revenue_concept = 'general' is the default and is strict Census General Revenue", {
|
||||||
|
wi_state <- "550000227544"
|
||||||
|
default <- cog_revenue(govid = wi_state, years = 2012L)
|
||||||
|
explicit <- cog_revenue(govid = wi_state, years = 2012L,
|
||||||
|
revenue_concept = "general")
|
||||||
|
expect_equal(sum(default$amt_nominal), sum(explicit$amt_nominal))
|
||||||
|
|
||||||
|
# General Revenue excludes utility, liquor store AND insurance trust
|
||||||
|
# revenue. WI state carries $0 of utility/liquor in FY2012, so the level
|
||||||
|
# assertion above cannot see those two -- assert the subtype scope directly.
|
||||||
|
#
|
||||||
|
# A subset, not setequal: `state` means "intergovernmental revenue FROM the
|
||||||
|
# state government" (the C codes), which a STATE government does not receive
|
||||||
|
# from itself, so it is legitimately absent here.
|
||||||
|
expect_true(all(default$revenue_subtype %in%
|
||||||
|
c("own_source", "federal", "state", "local_aid")))
|
||||||
|
expect_false(any(c("utility", "liquor_store", "insurance_trust") %in%
|
||||||
|
default$revenue_subtype))
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("utility and liquor store revenue are inside `total` and outside `general`", {
|
||||||
|
# A city, where utility revenue is material: this is the case the WI state
|
||||||
|
# baseline structurally cannot exercise. Measured on the fixture, utility +
|
||||||
|
# liquor is 15.9% of what cog_revenue() returned for type-2 governments
|
||||||
|
# before the general/total split, so this is the largest behaviour change
|
||||||
|
# the concept split introduces.
|
||||||
|
con <- uscogdata:::.ensure_session()
|
||||||
|
gov <- DBI::dbGetQuery(con,
|
||||||
|
"SELECT canonical_govid, SUM(amt) amt FROM long
|
||||||
|
WHERE year = 2012 AND type = 2 AND NOT is_aggregate
|
||||||
|
AND item_code IN ('A91','A92','A93','A94')
|
||||||
|
GROUP BY 1 ORDER BY amt DESC LIMIT 1")$canonical_govid
|
||||||
|
|
||||||
|
util_raw <- wt_raw_amt(gov, 2012L, codes = c("A90", "A91", "A92", "A93", "A94"))
|
||||||
|
expect_gt(util_raw, 0)
|
||||||
|
|
||||||
|
general <- cog_revenue(govid = gov, years = 2012L)
|
||||||
|
total <- cog_revenue(govid = gov, years = 2012L, revenue_concept = "total")
|
||||||
|
|
||||||
|
expect_false(any(c("utility", "liquor_store") %in% general$revenue_subtype))
|
||||||
|
expect_true("utility" %in% total$revenue_subtype)
|
||||||
|
expect_equal(sum(total$amt_nominal) - sum(general$amt_nominal),
|
||||||
|
util_raw * 1000 +
|
||||||
|
wt_raw_amt(gov, 2012L, codes = c("Y01", "Y11", "X01", "X02",
|
||||||
|
"X05", "X08")) * 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("revenue_concept rejects unknown values and never returns a balance row", {
|
||||||
|
expect_error(
|
||||||
|
cog_revenue("550000227544", years = 2012L, revenue_concept = "gross"),
|
||||||
|
class = "uscogdata_invalid_revenue_concept"
|
||||||
|
)
|
||||||
|
|
||||||
|
# uscogdata#25 restated for the widest revenue concept: stocks are not
|
||||||
|
# flows, and `total` must not quietly admit the X/Y/W/Z balance families.
|
||||||
|
con <- uscogdata:::.ensure_session()
|
||||||
|
balance <- DBI::dbGetQuery(con,
|
||||||
|
"SELECT item_code, category FROM summary_categories WHERE category_type = 'balance'")
|
||||||
|
total <- cog_revenue("550000227544", years = 2012L, revenue_concept = "total")
|
||||||
|
expect_false(any(total$category %in% balance$category))
|
||||||
|
expect_length(intersect(wt_codes_included(total), balance$item_code), 0L)
|
||||||
|
})
|
||||||
@@ -36,3 +36,20 @@ test_that("cog_revenue result has provenance attribute", {
|
|||||||
test_that("cog_revenue rejects invalid inputs", {
|
test_that("cog_revenue rejects invalid inputs", {
|
||||||
expect_error(cog_revenue(list(), 2020L), "character|data frame")
|
expect_error(cog_revenue(list(), 2020L), "character|data frame")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test_that("cog_revenue basis = 'harmonized' (default) matches 'raw' in this fixture window", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
r_raw <- cog_revenue("121011212191", 2019:2020, basis = "raw")
|
||||||
|
r_harm <- cog_revenue("121011212191", 2019:2020, basis = "harmonized")
|
||||||
|
expect_equal(attr(r_raw, "provenance")$basis, "raw")
|
||||||
|
expect_equal(attr(r_harm, "provenance")$basis, "harmonized")
|
||||||
|
expect_equal(sum(r_raw$amt_nominal), sum(r_harm$amt_nominal))
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("cog_revenue provenance carries the harmonization block", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
r <- cog_revenue("121011212191", 2020L)
|
||||||
|
h <- attr(r, "provenance")$harmonization
|
||||||
|
expect_true(h$applied)
|
||||||
|
expect_true(h$na_rows_excluded >= 0L)
|
||||||
|
})
|
||||||
|
|||||||
@@ -80,8 +80,11 @@ test_that("cog_geographic_rollup provenance reports the outer verb", {
|
|||||||
|
|
||||||
test_that("cog_geographic_rollup accepts data.frames per layer", {
|
test_that("cog_geographic_rollup accepts data.frames per layer", {
|
||||||
skip_if_no_corpus()
|
skip_if_no_corpus()
|
||||||
fl_state <- cog_gov_search("^FLORIDA$", type = "state")
|
# Unanchored: utility mode matches literally now, so "^...$" would be
|
||||||
broward <- cog_gov_search("^BROWARD COUNTY$", state = "FL", type = "county")
|
# searched for as characters rather than read as anchors (uscogdata#16).
|
||||||
|
# Both still resolve to exactly one row once scoped by type/state.
|
||||||
|
fl_state <- cog_gov_search("FLORIDA", type = "state")
|
||||||
|
broward <- cog_gov_search("BROWARD COUNTY", state = "FL", type = "county")
|
||||||
r <- cog_geographic_rollup(
|
r <- cog_geographic_rollup(
|
||||||
govids = list(state = fl_state, county = broward),
|
govids = list(state = fl_state, county = broward),
|
||||||
category = "Police", years = 2020L
|
category = "Police", years = 2020L
|
||||||
|
|||||||
@@ -98,7 +98,11 @@ test_that("cog_spending rejects invalid inputs", {
|
|||||||
|
|
||||||
test_that("cog_spending accepts a cog_gov_search result directly", {
|
test_that("cog_spending accepts a cog_gov_search result directly", {
|
||||||
skip_if_no_corpus()
|
skip_if_no_corpus()
|
||||||
picks <- cog_gov_search("^BROWARD COUNTY$", state = "FL", type = "county")
|
# Unanchored: utility mode matches `name` as a literal substring now, so
|
||||||
|
# "^...$" would be searched for as those characters rather than read as
|
||||||
|
# anchors (uscogdata#16). Scoped by state and type, the bare name still
|
||||||
|
# resolves to exactly one row.
|
||||||
|
picks <- cog_gov_search("BROWARD COUNTY", state = "FL", type = "county")
|
||||||
expect_gt(nrow(picks), 0L)
|
expect_gt(nrow(picks), 0L)
|
||||||
r <- cog_spending(picks, 2020L, "Corrections")
|
r <- cog_spending(picks, 2020L, "Corrections")
|
||||||
expect_equal(unique(r$canonical_govid), "121011212191")
|
expect_equal(unique(r$canonical_govid), "121011212191")
|
||||||
@@ -189,3 +193,154 @@ test_that("provenance records per-year denominator metadata", {
|
|||||||
expect_equal(length(pc$popyear_range), 2L)
|
expect_equal(length(pc$popyear_range), 2L)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# --- basis = "harmonized" / "raw" (Phase R2, schema v5) --------------------
|
||||||
|
|
||||||
|
test_that("basis = 'raw' reproduces the pre-harmonization Broward Police totals", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
with_fixture_corpus({
|
||||||
|
r <- cog_spending("121011212191", years = 2019:2020, category = "Police",
|
||||||
|
basis = "raw")
|
||||||
|
# Regression pin captured against the schema v5 fixture (2026-07-18,
|
||||||
|
# pipeline_commit ece9b32) before basis = "harmonized" existed as a
|
||||||
|
# concept; these are the same totals the pre-Phase-R2 default query
|
||||||
|
# returned (spending_annotated is untouched by the harmonized views).
|
||||||
|
ops <- r$amt_nominal[r$year == 2019L & r$spend_subtype == "operations"]
|
||||||
|
cap <- r$amt_nominal[r$year == 2020L & r$spend_subtype == "capital"]
|
||||||
|
expect_equal(ops, 483560000)
|
||||||
|
expect_equal(cap, 26693000)
|
||||||
|
expect_equal(attr(r, "provenance")$basis, "raw")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("basis = 'harmonized' (default) matches 'raw' when no harmonization rule applies", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
with_fixture_corpus({
|
||||||
|
# Every `method = "collapse"` mapping in the curated harmonization_map
|
||||||
|
# ends by FY2004 for codes inside the spending/revenue flow-type
|
||||||
|
# prefixes (E/F/G/K, T/A/U/B/C/D); the one collapse extending to FY2011
|
||||||
|
# (L38/M38 -> L36/M36) is intergovernmental-transfer (L/M prefix) codes
|
||||||
|
# that were never part of spending_long/revenue_long to begin with. So
|
||||||
|
# for the fixture's 2011-2020 window, basis = "harmonized" is a
|
||||||
|
# data-verified no-op vs "raw" for in-scope codes -- this is the
|
||||||
|
# positive-control counterpart to the synthetic REPLACE-mechanism test
|
||||||
|
# in test-views.R, which proves the fold itself works when data exists.
|
||||||
|
r_raw <- cog_spending("121011212191", c(2011L, 2012L, 2019L, 2020L),
|
||||||
|
"Police", basis = "raw")
|
||||||
|
r_harm <- cog_spending("121011212191", c(2011L, 2012L, 2019L, 2020L),
|
||||||
|
"Police", basis = "harmonized")
|
||||||
|
expect_equal(attr(r_harm, "provenance")$basis, "harmonized")
|
||||||
|
expect_equal(
|
||||||
|
r_harm$amt_nominal[order(r_harm$year, r_harm$spend_subtype)],
|
||||||
|
r_raw$amt_nominal[order(r_raw$year, r_raw$spend_subtype)]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("basis defaults to 'harmonized' when not passed", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
with_fixture_corpus({
|
||||||
|
r <- cog_spending("121011212191", 2020L, "Police")
|
||||||
|
expect_equal(attr(r, "provenance")$basis, "harmonized")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("provenance carries basis + harmonization block with na_rows_excluded", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
with_fixture_corpus({
|
||||||
|
# FL state government. The harmonization block is scoped by government,
|
||||||
|
# year and flow prefix -- NOT by category -- so a Corrections query still
|
||||||
|
# counts every E/F/G-prefixed row the harmonized basis drops for having
|
||||||
|
# no harmonized_code. The three that apply here are E21/F21/G21
|
||||||
|
# (Education NEC, SB184-186, "discontinued_na", wide-era window ending
|
||||||
|
# FY2011); the other discontinued_na rulings live outside E/F/G.
|
||||||
|
# See docs/phase_r_harmonization_review.md § 1.3/1.4 and cog_pipeline
|
||||||
|
# data/harmonization_map.csv.
|
||||||
|
r <- cog_spending("120000226351", 2011:2012, "Corrections")
|
||||||
|
prov <- attr(r, "provenance")
|
||||||
|
expect_equal(prov$basis, "harmonized")
|
||||||
|
expect_true(prov$harmonization$applied)
|
||||||
|
expect_equal(prov$harmonization$na_rows_excluded, 3L)
|
||||||
|
# $2,825,439 thousands of FY2011 E21 + F21 + G21, reported in full USD.
|
||||||
|
# Pinning a non-zero amount is the point: the earlier Broward anchor's
|
||||||
|
# three rows were all explicit zeros, so the AMOUNT accounting was
|
||||||
|
# asserted only against 0 and could not have caught a bug.
|
||||||
|
expect_equal(prov$harmonization$na_amount_excluded, 2825439 * 1000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("sparsification removed the wide era's zero-pads from the exclusion count", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
with_fixture_corpus({
|
||||||
|
# Broward County FY2011 used to carry E21/F21/G21 rows of exactly $0 --
|
||||||
|
# the wide era stored every government x every code, zeros included. The
|
||||||
|
# published corpus no longer does (SB194, cog_pipeline#64), so there is
|
||||||
|
# now nothing for the harmonized basis to exclude. Absence in a
|
||||||
|
# dense_source year means Census published $0; it does not mean the
|
||||||
|
# exclusion machinery stopped working, which the FL state anchor above
|
||||||
|
# proves independently.
|
||||||
|
r <- cog_spending("121011212191", 2011:2012, "Corrections")
|
||||||
|
h <- attr(r, "provenance")$harmonization
|
||||||
|
expect_true(h$applied)
|
||||||
|
expect_equal(h$na_rows_excluded, 0L)
|
||||||
|
expect_equal(h$na_amount_excluded, 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("basis = 'raw' never populates the harmonization exclusion block", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
with_fixture_corpus({
|
||||||
|
r <- cog_spending("121011212191", 2020L, "Corrections", basis = "raw")
|
||||||
|
h <- attr(r, "provenance")$harmonization
|
||||||
|
expect_false(h$applied)
|
||||||
|
expect_equal(h$na_rows_excluded, 0L)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("v4 corpus: basis silently resolves to raw (default) with a provenance note", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
with_doctored_schema_version(4L, {
|
||||||
|
r <- cog_spending("121011212191", 2019L, "Police")
|
||||||
|
prov <- attr(r, "provenance")
|
||||||
|
expect_equal(prov$basis, "raw")
|
||||||
|
expect_match(prov$basis_note, "raw", fixed = TRUE)
|
||||||
|
expect_match(prov$basis_note, "schema_version", fixed = TRUE)
|
||||||
|
expect_false(prov$harmonization$applied)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("v4 corpus: explicit basis = 'harmonized' aborts", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
with_doctored_schema_version(4L, {
|
||||||
|
expect_error(
|
||||||
|
cog_spending("121011212191", 2019L, "Police", basis = "harmonized"),
|
||||||
|
class = "uscogdata_basis_unsupported"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("provenance$series_break_refs is a populated-when-applicable character vector", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
with_fixture_corpus({
|
||||||
|
r <- cog_spending("121011212191", 2020L, "Corrections")
|
||||||
|
refs <- attr(r, "provenance")$series_break_refs
|
||||||
|
expect_type(refs, "character")
|
||||||
|
# No catalogued code-specific series_breaks_pq row falls inside this
|
||||||
|
# fixture's 2011/2012/2019/2020 window for the codes this query touches
|
||||||
|
# (E04/G04) -- data-verified; the mechanism itself is what's under test
|
||||||
|
# here, via a query-shaped unit test in test-views.R since the fixture
|
||||||
|
# has no positive case to pin against. Corpus-wide ("ALL") entries never
|
||||||
|
# appear in this field by construction -- they travel in
|
||||||
|
# corpus_break_refs; see test-corpus-breaks.R.
|
||||||
|
expect_equal(refs, character(0))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("v4 corpus: explicit basis = 'raw' still works", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
with_doctored_schema_version(4L, {
|
||||||
|
r <- cog_spending("121011212191", 2019L, "Police", basis = "raw")
|
||||||
|
expect_equal(attr(r, "provenance")$basis, "raw")
|
||||||
|
expect_gt(nrow(r), 0L)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+398
-17
@@ -9,19 +9,383 @@ test_that("all expected views register on session open", {
|
|||||||
expected <- c(
|
expected <- c(
|
||||||
"long", "spending_long", "revenue_long",
|
"long", "spending_long", "revenue_long",
|
||||||
"canonical_fips_xwalk", "summary_categories",
|
"canonical_fips_xwalk", "summary_categories",
|
||||||
"spending_annotated", "revenue_annotated"
|
"spending_annotated", "revenue_annotated",
|
||||||
|
"ig_long", "ig_annotated",
|
||||||
|
"ig_long_harmonized", "ig_annotated_harmonized"
|
||||||
)
|
)
|
||||||
expect_true(all(expected %in% views$table_name))
|
expect_true(all(expected %in% views$table_name))
|
||||||
})
|
})
|
||||||
|
|
||||||
test_that("spending_long filters to E/F/G/K prefixes and excludes aggregates", {
|
test_that("inst/sql/22- and 23- harmonized views enforce every WHERE predicate (real SQL text, synthetic parquet)", {
|
||||||
|
# spending_long_harmonized / revenue_long_harmonized are three-predicate
|
||||||
|
# views:
|
||||||
|
# SELECT * REPLACE (harmonized_code AS item_code)
|
||||||
|
# FROM long
|
||||||
|
# WHERE NOT is_aggregate
|
||||||
|
# AND harmonized_code IS NOT NULL
|
||||||
|
# AND LEFT(harmonized_code, 1) IN (<flow prefixes>)
|
||||||
|
# None of the curated harmonization_map's `collapse` rulings land inside
|
||||||
|
# the bundled fixture's 2011-2020 window for spending/revenue-prefixed
|
||||||
|
# codes (see the "basis = 'harmonized' (default) matches 'raw'" test in
|
||||||
|
# test-spending.R and docs/phase_r_harmonization_review.md § 0.2/§ 2), so
|
||||||
|
# there is no real fixture row that exercises a nonzero fold or a
|
||||||
|
# predicate-excluded row. Rather than re-implement the WHERE clause by
|
||||||
|
# hand against an in-memory VALUES table (which would only prove the SQL
|
||||||
|
# *pattern* works, not that the deployed inst/sql/22-/23- text actually
|
||||||
|
# applies it), this test reads the real SQL files off disk, substitutes
|
||||||
|
# {url} exactly as .register_views() does, and executes them -- plus
|
||||||
|
# their 10-long.sql dependency -- against a synthetic hive-partitioned
|
||||||
|
# parquet tree written to a temp dir. A regression in any predicate (e.g.
|
||||||
|
# `NOT is_aggregate` dropped, the crosswalk-membership subquery changed,
|
||||||
|
# the NULL guard removed) would change which of the rows below survive.
|
||||||
|
#
|
||||||
|
# The synthetic parquet is written with DuckDB's own COPY ... TO (FORMAT
|
||||||
|
# PARQUET) rather than the arrow package: this package has no arrow
|
||||||
|
# dependency (CLAUDE.md "No arrow dependency -- DuckDB reads parquet
|
||||||
|
# natively"), and DuckDB can round-trip its own parquet writer/reader
|
||||||
|
# without adding one for tests either.
|
||||||
|
skip_if_no_corpus()
|
||||||
|
|
||||||
|
tmp <- withr::local_tempdir()
|
||||||
|
part_dir <- file.path(tmp, "data", "long", "year=2004")
|
||||||
|
dir.create(part_dir, recursive = TRUE)
|
||||||
|
part_path <- file.path(part_dir, "part-0.parquet")
|
||||||
|
|
||||||
|
write_con <- DBI::dbConnect(duckdb::duckdb())
|
||||||
|
on.exit(DBI::dbDisconnect(write_con, shutdown = TRUE), add = TRUE)
|
||||||
|
DBI::dbExecute(write_con, sprintf("
|
||||||
|
COPY (
|
||||||
|
SELECT * FROM (VALUES
|
||||||
|
-- Spending (E/F/G/K) rows, exercised against spending_long_harmonized:
|
||||||
|
('spend-A', 'E36', 100, false, 'E36'), -- control: passes every predicate as-is
|
||||||
|
('spend-B', 'E38', 50, false, 'E36'), -- collapse-fold: passes every predicate, renamed to E36
|
||||||
|
('spend-C', 'E05', 999999, true, 'E05'), -- excluded ONLY by `NOT is_aggregate`
|
||||||
|
('spend-D', 'E99', 888888, false, NULL), -- excluded by `harmonized_code IS NOT NULL`
|
||||||
|
-- 'S74' and 'Z61' are classified `balance` in the synthetic
|
||||||
|
-- crosswalk below (mirroring the real corpus's own non-flow codes),
|
||||||
|
-- so each is excluded from its view ONLY by the crosswalk-membership
|
||||||
|
-- subquery -- the mechanism that replaced the prefix allowlists
|
||||||
|
-- (uscogdata#11) and keeps balance stocks out of both flows
|
||||||
|
-- (uscogdata#25).
|
||||||
|
('spend-E', 'S74', 777777, false, 'S74'), -- excluded ONLY by crosswalk membership (balance)
|
||||||
|
-- Revenue rows, exercised against revenue_long_harmonized:
|
||||||
|
('rev-A', 'U11', 200, false, 'U11'), -- control: passes every predicate as-is
|
||||||
|
('rev-B', 'U10', 25, false, 'U11'), -- collapse-fold: passes every predicate, renamed to U11
|
||||||
|
('rev-C', 'T29', 555555, true, 'T29'), -- excluded ONLY by `NOT is_aggregate`
|
||||||
|
('rev-D', 'T88', 444444, false, NULL), -- excluded by `harmonized_code IS NOT NULL`
|
||||||
|
('rev-E', 'Z61', 333333, false, 'Z61') -- excluded ONLY by crosswalk membership (balance)
|
||||||
|
) AS t(canonical_govid, item_code, amt, is_aggregate, harmonized_code)
|
||||||
|
) TO %s (FORMAT PARQUET)
|
||||||
|
", uscogdata:::.sql_lit_chr(part_path)))
|
||||||
|
|
||||||
|
# The flow views classify by membership in summary_categories, so the
|
||||||
|
# synthetic corpus needs one too. Every flow code above is a member of its
|
||||||
|
# own flow (so is_aggregate / NULL-harmonized exclusions stay the SOLE
|
||||||
|
# excluder for those rows); S74/Z61 are members but classified balance, so
|
||||||
|
# membership itself is what excludes them.
|
||||||
|
DBI::dbExecute(write_con, sprintf("
|
||||||
|
COPY (
|
||||||
|
SELECT * FROM (VALUES
|
||||||
|
('E36', 'Water Utilities', 'expenditure', 'operations', NULL),
|
||||||
|
('E38', 'Water Utilities', 'expenditure', 'operations', NULL),
|
||||||
|
('E05', 'Corrections', 'expenditure', 'operations', NULL),
|
||||||
|
('E99', 'Other', 'expenditure', 'operations', NULL),
|
||||||
|
('S74', 'Fund Balances', 'balance', NULL, NULL),
|
||||||
|
('U11', 'Interest Earnings','revenue', NULL, 'own_source'),
|
||||||
|
('U10', 'Interest Earnings','revenue', NULL, 'own_source'),
|
||||||
|
('T29', 'Other Taxes', 'revenue', NULL, 'own_source'),
|
||||||
|
('T88', 'Other Taxes', 'revenue', NULL, 'own_source'),
|
||||||
|
('Z61', 'Fund Balances', 'balance', NULL, NULL)
|
||||||
|
) AS t(item_code, category, category_type, spend_subtype, revenue_subtype)
|
||||||
|
) TO %s (FORMAT PARQUET)
|
||||||
|
", uscogdata:::.sql_lit_chr(file.path(tmp, "data", "summary_categories.parquet"))))
|
||||||
|
|
||||||
|
sql_dir <- system.file("sql", package = "uscogdata")
|
||||||
|
.read_view_sql <- function(filename) {
|
||||||
|
txt <- paste(readLines(file.path(sql_dir, filename), warn = FALSE), collapse = "\n")
|
||||||
|
gsub("\\{url\\}", paste0(tmp, "/"), txt, fixed = FALSE)
|
||||||
|
}
|
||||||
|
|
||||||
|
con <- DBI::dbConnect(duckdb::duckdb())
|
||||||
|
on.exit(DBI::dbDisconnect(con, shutdown = TRUE), add = TRUE)
|
||||||
|
DBI::dbExecute(con, .read_view_sql("10-long.sql"))
|
||||||
|
DBI::dbExecute(con, .read_view_sql("11-summary_categories.sql"))
|
||||||
|
DBI::dbExecute(con, .read_view_sql("22-spending_long_harmonized.sql"))
|
||||||
|
DBI::dbExecute(con, .read_view_sql("23-revenue_long_harmonized.sql"))
|
||||||
|
|
||||||
|
spend <- DBI::dbGetQuery(con,
|
||||||
|
"SELECT item_code, SUM(amt) AS amt FROM spending_long_harmonized
|
||||||
|
GROUP BY item_code ORDER BY item_code"
|
||||||
|
)
|
||||||
|
# Exactly one surviving row: spend-C (aggregate), spend-D (NULL
|
||||||
|
# harmonized_code), and spend-E (balance, not an expenditure member) must
|
||||||
|
# all be gone, and spend-A + spend-B must be folded together under E36.
|
||||||
|
expect_equal(nrow(spend), 1L)
|
||||||
|
expect_equal(spend$item_code, "E36")
|
||||||
|
expect_equal(spend$amt, 150)
|
||||||
|
|
||||||
|
rev <- DBI::dbGetQuery(con,
|
||||||
|
"SELECT item_code, SUM(amt) AS amt FROM revenue_long_harmonized
|
||||||
|
GROUP BY item_code ORDER BY item_code"
|
||||||
|
)
|
||||||
|
expect_equal(nrow(rev), 1L)
|
||||||
|
expect_equal(rev$item_code, "U11")
|
||||||
|
expect_equal(rev$amt, 225)
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("inst/sql/24- and 25- IG views retain aggregates, COALESCE NULL harmonized_code, and exclude the L-- family total (real SQL text, synthetic parquet)", {
|
||||||
|
# ig_long / ig_long_harmonized have the subtlest predicates in the package:
|
||||||
|
# a deliberately ABSENT `NOT is_aggregate` (unlike every other *_long view),
|
||||||
|
# and COALESCE(harmonized_code, item_code) instead of a plain
|
||||||
|
# `harmonized_code IS NOT NULL` filter. The only end-to-end guard on this
|
||||||
|
# today is bound to AL state / 2011 / Education K-12, where M12 happens to
|
||||||
|
# be the sole IG code present -- regenerate the fixture without that one
|
||||||
|
# row and the guard would die silently while staying green. As with the
|
||||||
|
# 22-/23- test above, this reads the real inst/sql/24-/25- text off disk
|
||||||
|
# and executes it against a synthetic hive-partitioned parquet tree, so a
|
||||||
|
# regression in either predicate changes which rows survive.
|
||||||
|
skip_if_no_corpus()
|
||||||
|
|
||||||
|
tmp <- withr::local_tempdir()
|
||||||
|
part_dir <- file.path(tmp, "data", "long", "year=2004")
|
||||||
|
dir.create(part_dir, recursive = TRUE)
|
||||||
|
part_path <- file.path(part_dir, "part-0.parquet")
|
||||||
|
|
||||||
|
write_con <- DBI::dbConnect(duckdb::duckdb())
|
||||||
|
on.exit(DBI::dbDisconnect(write_con, shutdown = TRUE), add = TRUE)
|
||||||
|
DBI::dbExecute(write_con, sprintf("
|
||||||
|
COPY (
|
||||||
|
SELECT * FROM (VALUES
|
||||||
|
('ig-A', 'M04', 100, false, 'M04'), -- control: passes through as-is
|
||||||
|
('ig-B', 'M38', 50, false, 'M36'), -- fold control: real SB012 rule, renamed to M36 under harmonized basis
|
||||||
|
('ig-C', 'M47', 99999, true, NULL), -- legacy aggregate, NO harmonized_code: must survive BOTH views
|
||||||
|
('ig-D', 'L--', 55555, false, 'L--'), -- family total: deliberately NOT a crosswalk member, excluded from BOTH views
|
||||||
|
('ig-E', 'T29', 44444, false, 'T29') -- revenue member, not intergovernmental: excluded from BOTH views
|
||||||
|
) AS t(canonical_govid, item_code, amt, is_aggregate, harmonized_code)
|
||||||
|
) TO %s (FORMAT PARQUET)
|
||||||
|
", uscogdata:::.sql_lit_chr(part_path)))
|
||||||
|
|
||||||
|
# The IG views classify by summary_categories membership
|
||||||
|
# (spend_subtype = 'intergovernmental'). L-- is deliberately absent --
|
||||||
|
# exactly as it is from the real crosswalk -- which is what excludes it.
|
||||||
|
DBI::dbExecute(write_con, sprintf("
|
||||||
|
COPY (
|
||||||
|
SELECT * FROM (VALUES
|
||||||
|
('M04', 'Corrections', 'expenditure', 'intergovernmental', NULL),
|
||||||
|
('M38', 'Health', 'expenditure', 'intergovernmental', NULL),
|
||||||
|
('M36', 'Health', 'expenditure', 'intergovernmental', NULL),
|
||||||
|
('M47', 'IG Other', 'expenditure', 'intergovernmental', NULL),
|
||||||
|
('T29', 'Other Taxes', 'revenue', NULL, 'own_source')
|
||||||
|
) AS t(item_code, category, category_type, spend_subtype, revenue_subtype)
|
||||||
|
) TO %s (FORMAT PARQUET)
|
||||||
|
", uscogdata:::.sql_lit_chr(file.path(tmp, "data", "summary_categories.parquet"))))
|
||||||
|
|
||||||
|
sql_dir <- system.file("sql", package = "uscogdata")
|
||||||
|
.read_view_sql <- function(filename) {
|
||||||
|
txt <- paste(readLines(file.path(sql_dir, filename), warn = FALSE), collapse = "\n")
|
||||||
|
gsub("\\{url\\}", paste0(tmp, "/"), txt, fixed = FALSE)
|
||||||
|
}
|
||||||
|
|
||||||
|
con <- DBI::dbConnect(duckdb::duckdb())
|
||||||
|
on.exit(DBI::dbDisconnect(con, shutdown = TRUE), add = TRUE)
|
||||||
|
DBI::dbExecute(con, .read_view_sql("10-long.sql"))
|
||||||
|
DBI::dbExecute(con, .read_view_sql("11-summary_categories.sql"))
|
||||||
|
DBI::dbExecute(con, .read_view_sql("24-ig_long.sql"))
|
||||||
|
DBI::dbExecute(con, .read_view_sql("25-ig_long_harmonized.sql"))
|
||||||
|
|
||||||
|
raw <- DBI::dbGetQuery(con,
|
||||||
|
"SELECT item_code, SUM(amt) AS amt FROM ig_long
|
||||||
|
GROUP BY item_code ORDER BY item_code"
|
||||||
|
)
|
||||||
|
# L-- (family total, not a member) and T29 (revenue, not IG) are gone; the
|
||||||
|
# aggregate row M47 survives -- proof `NOT is_aggregate` is absent from
|
||||||
|
# ig_long.
|
||||||
|
expect_equal(raw$item_code, c("M04", "M38", "M47"))
|
||||||
|
expect_equal(raw$amt, c(100, 50, 99999))
|
||||||
|
|
||||||
|
harmonized <- DBI::dbGetQuery(con,
|
||||||
|
"SELECT item_code, SUM(amt) AS amt FROM ig_long_harmonized
|
||||||
|
GROUP BY item_code ORDER BY item_code"
|
||||||
|
)
|
||||||
|
# M38 folds to M36 (real harmonized_code present); M47 keeps its raw code
|
||||||
|
# via COALESCE(NULL, 'M47') -- proof the aggregate row is NOT dropped by
|
||||||
|
# a plain `harmonized_code IS NOT NULL` filter. L-- and T29 stay excluded.
|
||||||
|
expect_equal(harmonized$item_code, c("M04", "M36", "M47"))
|
||||||
|
expect_equal(harmonized$amt, c(100, 50, 99999))
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that(".build_series_break_refs matches fin_code + break_year window", {
|
||||||
|
# No CODE-SPECIFIC series_breaks_pq row falls inside the bundled fixture's
|
||||||
|
# 2011-2020 window (data-verified; see the "series_break_refs" test in
|
||||||
|
# test-spending.R), so this proves the matching logic itself against the
|
||||||
|
# live view + a synthetic year window that DOES hit a cataloged break
|
||||||
|
# (SB075, fin_code E62, break_year 2005). The corpus-wide entries are a
|
||||||
|
# separate path with its own coverage -- SB194 does sit at 2012, inside
|
||||||
|
# the fixture window; see test-corpus-breaks.R.
|
||||||
skip_if_no_corpus()
|
skip_if_no_corpus()
|
||||||
con <- cog_open()
|
con <- cog_open()
|
||||||
on.exit(cog_close())
|
on.exit(cog_close())
|
||||||
prefixes <- DBI::dbGetQuery(con,
|
refs <- uscogdata:::.build_series_break_refs(
|
||||||
"SELECT DISTINCT LEFT(item_code, 1) AS pfx FROM spending_long"
|
con, codes_observed = c("E62", "E04"), years = c(2003L, 2006L),
|
||||||
)$pfx
|
schema_version = 5L
|
||||||
expect_true(all(prefixes %in% c("E", "F", "G", "K")))
|
)
|
||||||
|
expect_true("SB075" %in% refs)
|
||||||
|
expect_true("SB071" %in% refs)
|
||||||
|
|
||||||
|
# Gated on schema_version >= 5 even when the codes/years would otherwise match.
|
||||||
|
refs_v4 <- uscogdata:::.build_series_break_refs(
|
||||||
|
con, codes_observed = c("E62"), years = c(2003L, 2006L), schema_version = 4L
|
||||||
|
)
|
||||||
|
expect_equal(refs_v4, character(0))
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("schema v5 harmonization views register when the corpus supports them", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
con <- cog_open()
|
||||||
|
on.exit(cog_close())
|
||||||
|
manifest <- uscogdata:::.uscogdata_env$manifest
|
||||||
|
skip_if(as.integer(manifest$schema_version) < 5L, "fixture is schema_version < 5")
|
||||||
|
|
||||||
|
views <- DBI::dbGetQuery(con,
|
||||||
|
"SELECT table_name FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'main' AND table_type = 'VIEW'"
|
||||||
|
)
|
||||||
|
expected_v5 <- c(
|
||||||
|
"spending_long_harmonized", "revenue_long_harmonized",
|
||||||
|
"spending_annotated_harmonized", "revenue_annotated_harmonized",
|
||||||
|
"harmonization_map", "harmonization_recipes", "series_breaks_pq"
|
||||||
|
)
|
||||||
|
expect_true(all(expected_v5 %in% views$table_name))
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that(".harmonization_view_files guard is necessary: registration against a v4-shaped corpus (no harmonized_code column at all) succeeds only because the harmonized views are skipped", {
|
||||||
|
# with_doctored_schema_version() (used elsewhere in this suite) only
|
||||||
|
# rewrites manifest.json's schema_version -- the underlying `long` parquet
|
||||||
|
# is still the bundled v6 fixture, which DOES have a harmonized_code
|
||||||
|
# column, so it only proves the skip *happens*, not that it is *required*.
|
||||||
|
# This test builds a genuinely v4-shaped corpus: `long` has no
|
||||||
|
# harmonized_code column at all, matching a real pre-Phase-R2 publish
|
||||||
|
# tree, and then shows two things: (1) the real .register_views(), gated
|
||||||
|
# on manifest$schema_version, registers cleanly against it; (2) the exact
|
||||||
|
# SQL text of a gated file (25-ig_long_harmonized.sql), executed directly
|
||||||
|
# against the same corpus without the gate, fails -- proving the gate is
|
||||||
|
# load-bearing, not incidental.
|
||||||
|
tmp <- withr::local_tempdir()
|
||||||
|
part_dir <- file.path(tmp, "data", "long", "year=2004")
|
||||||
|
dir.create(part_dir, recursive = TRUE)
|
||||||
|
part_path <- file.path(part_dir, "part-0.parquet")
|
||||||
|
|
||||||
|
write_con <- DBI::dbConnect(duckdb::duckdb())
|
||||||
|
on.exit(DBI::dbDisconnect(write_con, shutdown = TRUE), add = TRUE)
|
||||||
|
DBI::dbExecute(write_con, sprintf("
|
||||||
|
COPY (
|
||||||
|
SELECT * FROM (VALUES
|
||||||
|
('gov-1', 'E36', 100, false, 500000, 2020)
|
||||||
|
) AS t(canonical_govid, item_code, amt, is_aggregate, population, popyear)
|
||||||
|
) TO %s (FORMAT PARQUET)
|
||||||
|
", uscogdata:::.sql_lit_chr(part_path)))
|
||||||
|
|
||||||
|
xwalk_path <- file.path(tmp, "data", "canonical_fips_xwalk.parquet")
|
||||||
|
DBI::dbExecute(write_con, sprintf("
|
||||||
|
COPY (
|
||||||
|
SELECT * FROM (VALUES
|
||||||
|
('gov-1', 'Test Gov', 1, 'County', '01', '001', NULL, 500000)
|
||||||
|
) AS t(canonical_govid, gov_name, govs_type, type_label, fips_state,
|
||||||
|
fips_county, fips_place, population_acs)
|
||||||
|
) TO %s (FORMAT PARQUET)
|
||||||
|
", uscogdata:::.sql_lit_chr(xwalk_path)))
|
||||||
|
|
||||||
|
cats_path <- file.path(tmp, "data", "summary_categories.parquet")
|
||||||
|
DBI::dbExecute(write_con, sprintf("
|
||||||
|
COPY (
|
||||||
|
SELECT * FROM (VALUES
|
||||||
|
('E36', 'Test Category', 'expenditure', 'direct', NULL)
|
||||||
|
) AS t(item_code, category, category_type, spend_subtype, revenue_subtype)
|
||||||
|
) TO %s (FORMAT PARQUET)
|
||||||
|
", uscogdata:::.sql_lit_chr(cats_path)))
|
||||||
|
|
||||||
|
# Confirm the synthetic `long` genuinely lacks harmonized_code (not just
|
||||||
|
# NULL values -- the column itself must be absent) before trusting the
|
||||||
|
# rest of this test.
|
||||||
|
cols <- DBI::dbGetQuery(write_con, sprintf(
|
||||||
|
"DESCRIBE SELECT * FROM read_parquet(%s)", uscogdata:::.sql_lit_chr(part_path)
|
||||||
|
))$column_name
|
||||||
|
expect_false("harmonized_code" %in% cols)
|
||||||
|
|
||||||
|
url <- paste0(tmp, "/")
|
||||||
|
|
||||||
|
# (1) Full .register_views() against this v4-shaped corpus must succeed --
|
||||||
|
# this is the behavior the guard exists to protect.
|
||||||
|
con <- DBI::dbConnect(duckdb::duckdb())
|
||||||
|
on.exit(DBI::dbDisconnect(con, shutdown = TRUE), add = TRUE)
|
||||||
|
expect_no_error(
|
||||||
|
uscogdata:::.register_views(con, url, manifest = list(schema_version = 4L))
|
||||||
|
)
|
||||||
|
views <- DBI::dbGetQuery(con,
|
||||||
|
"SELECT table_name FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'main' AND table_type = 'VIEW'")$table_name
|
||||||
|
expect_true(all(c("ig_long", "ig_annotated", "spending_annotated") %in% views))
|
||||||
|
expect_false(any(c("ig_long_harmonized", "ig_annotated_harmonized",
|
||||||
|
"spending_long_harmonized") %in% views))
|
||||||
|
|
||||||
|
# (2) Prove the gate is load-bearing: the exact SQL text of the skipped
|
||||||
|
# file, executed directly (bypassing .register_views()'s schema_version
|
||||||
|
# check) against the SAME corpus, fails because it references
|
||||||
|
# long.harmonized_code, a column this corpus's `long` does not have.
|
||||||
|
sql_dir <- system.file("sql", package = "uscogdata")
|
||||||
|
.read_view_sql <- function(filename) {
|
||||||
|
txt <- paste(readLines(file.path(sql_dir, filename), warn = FALSE), collapse = "\n")
|
||||||
|
gsub("\\{url\\}", url, txt, fixed = FALSE)
|
||||||
|
}
|
||||||
|
con2 <- DBI::dbConnect(duckdb::duckdb())
|
||||||
|
on.exit(DBI::dbDisconnect(con2, shutdown = TRUE), add = TRUE)
|
||||||
|
DBI::dbExecute(con2, .read_view_sql("10-long.sql"))
|
||||||
|
expect_error(DBI::dbExecute(con2, .read_view_sql("25-ig_long_harmonized.sql")))
|
||||||
|
|
||||||
|
# Reconciling this test with the C2 guard (expenditure-concept review):
|
||||||
|
# `ig_annotated`/`spending_annotated` registering cleanly above proves
|
||||||
|
# only that CREATE VIEW binds against a `summary_categories` with no M/L
|
||||||
|
# rows at all (this synthetic corpus's own summary_categories has a
|
||||||
|
# single E36 row, see the COPY above) -- a LEFT JOIN never fails to
|
||||||
|
# resolve regardless of what the joined-to table contains. It does NOT
|
||||||
|
# mean querying expenditure_concept = "total" against this shape is safe:
|
||||||
|
# exactly this corpus (schema_version reported as supported, but
|
||||||
|
# summary_categories predates the M/L rows cog_pipeline PR #59 added) is
|
||||||
|
# what .require_ig_categories() exists to catch at the *verb* level,
|
||||||
|
# since PR #59 shipped those rows with no schema_version bump. Confirm
|
||||||
|
# the new runtime guard actually fires against this same `con`.
|
||||||
|
expect_error(
|
||||||
|
uscogdata:::.require_ig_categories(con),
|
||||||
|
class = "uscogdata_ig_categories_unsupported"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("spending_long carries exactly the non-IG expenditure crosswalk codes and excludes aggregates", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
con <- cog_open()
|
||||||
|
on.exit(cog_close())
|
||||||
|
|
||||||
|
# Classification is crosswalk membership, not prefixes (uscogdata#11):
|
||||||
|
# every row's code must classify as expenditure and never as
|
||||||
|
# intergovernmental (which lives in ig_long).
|
||||||
|
stray <- DBI::dbGetQuery(con,
|
||||||
|
"SELECT DISTINCT s.item_code
|
||||||
|
FROM spending_long s
|
||||||
|
LEFT JOIN summary_categories c USING (item_code)
|
||||||
|
WHERE c.category_type IS DISTINCT FROM 'expenditure'
|
||||||
|
OR c.spend_subtype = 'intergovernmental'"
|
||||||
|
)$item_code
|
||||||
|
expect_length(stray, 0L)
|
||||||
|
|
||||||
|
# Balance codes are stocks, not flows -- they must never appear in a
|
||||||
|
# spending result (uscogdata#25). Prefix filtering could not guarantee
|
||||||
|
# this (W/X/Y/Z balance codes share letters with flow codes).
|
||||||
|
balance_n <- DBI::dbGetQuery(con,
|
||||||
|
"SELECT count(*) AS n FROM spending_long WHERE item_code IN (
|
||||||
|
SELECT item_code FROM summary_categories WHERE category_type = 'balance'
|
||||||
|
)"
|
||||||
|
)$n
|
||||||
|
expect_equal(balance_n, 0)
|
||||||
|
|
||||||
agg_count <- DBI::dbGetQuery(con,
|
agg_count <- DBI::dbGetQuery(con,
|
||||||
"SELECT count(*) AS n FROM spending_long WHERE is_aggregate"
|
"SELECT count(*) AS n FROM spending_long WHERE is_aggregate"
|
||||||
@@ -29,14 +393,29 @@ test_that("spending_long filters to E/F/G/K prefixes and excludes aggregates", {
|
|||||||
expect_equal(agg_count, 0)
|
expect_equal(agg_count, 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test_that("revenue_long filters to T/A/U/B/C/D prefixes and excludes aggregates", {
|
test_that("revenue_long carries exactly the revenue crosswalk codes and excludes aggregates", {
|
||||||
skip_if_no_corpus()
|
skip_if_no_corpus()
|
||||||
con <- cog_open()
|
con <- cog_open()
|
||||||
on.exit(cog_close())
|
on.exit(cog_close())
|
||||||
prefixes <- DBI::dbGetQuery(con,
|
|
||||||
"SELECT DISTINCT LEFT(item_code, 1) AS pfx FROM revenue_long"
|
# The view carries EVERY revenue subtype; which of Census's two published
|
||||||
)$pfx
|
# concepts a query returns is decided per `revenue_concept` in R
|
||||||
expect_true(all(prefixes %in% c("T", "A", "U", "B", "C", "D")))
|
# (uscogdata#12), exactly as `expenditure_concept` narrows spending_long.
|
||||||
|
stray <- DBI::dbGetQuery(con,
|
||||||
|
"SELECT DISTINCT s.item_code
|
||||||
|
FROM revenue_long s
|
||||||
|
LEFT JOIN summary_categories c USING (item_code)
|
||||||
|
WHERE c.category_type IS DISTINCT FROM 'revenue'"
|
||||||
|
)$item_code
|
||||||
|
expect_length(stray, 0L)
|
||||||
|
|
||||||
|
# No balance stock ever appears in a revenue result (uscogdata#25).
|
||||||
|
balance_n <- DBI::dbGetQuery(con,
|
||||||
|
"SELECT count(*) AS n FROM revenue_long WHERE item_code IN (
|
||||||
|
SELECT item_code FROM summary_categories WHERE category_type = 'balance'
|
||||||
|
)"
|
||||||
|
)$n
|
||||||
|
expect_equal(balance_n, 0)
|
||||||
|
|
||||||
agg_count <- DBI::dbGetQuery(con,
|
agg_count <- DBI::dbGetQuery(con,
|
||||||
"SELECT count(*) AS n FROM revenue_long WHERE is_aggregate"
|
"SELECT count(*) AS n FROM revenue_long WHERE is_aggregate"
|
||||||
@@ -69,13 +448,15 @@ test_that("gov_population_yearly exposes one row per (year, canonical_govid)", {
|
|||||||
WHERE canonical_govid = '121011212191'
|
WHERE canonical_govid = '121011212191'
|
||||||
ORDER BY year"
|
ORDER BY year"
|
||||||
)
|
)
|
||||||
expect_setequal(df$year, c(2019L, 2020L))
|
expect_setequal(df$year, c(2011L, 2012L, 2019L, 2020L))
|
||||||
expect_equal(nrow(df), 2L)
|
expect_equal(nrow(df), 4L)
|
||||||
expect_true(all(!is.na(df$population)))
|
expect_true(all(!is.na(df$population)))
|
||||||
# Hardcoded values are from the bundled fixture (regenerated 2026-07-11
|
# Hardcoded values are from the bundled fixture (regenerated 2026-07-18
|
||||||
# against cog_pipeline publish tree, pipeline_commit 1a00925, Phase P
|
# against cog_pipeline publish tree, pipeline_commit ece9b32, Phase R2
|
||||||
# schema_version 4). Update if the fixture is rebuilt against a
|
# schema_version 5, years 2011/2012/2019/2020). Update if the fixture is
|
||||||
# different source vintage.
|
# rebuilt against a different source vintage.
|
||||||
|
expect_equal(df$population[df$year == 2011L], 1759591L)
|
||||||
|
expect_equal(df$population[df$year == 2012L], 1819773L)
|
||||||
expect_equal(df$population[df$year == 2019L], 1935878L)
|
expect_equal(df$population[df$year == 2019L], 1935878L)
|
||||||
expect_equal(df$population[df$year == 2020L], 1952778L)
|
expect_equal(df$population[df$year == 2020L], 1952778L)
|
||||||
# Uniqueness on (year, canonical_govid) across the whole view.
|
# Uniqueness on (year, canonical_govid) across the whole view.
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ knitr::opts_chunk$set(eval = FALSE, collapse = TRUE, comment = "#>")
|
|||||||
|
|
||||||
# Why per-year population matters
|
# Why per-year population matters
|
||||||
|
|
||||||
|
A note on units first, since every figure below is a rate: the numerator is in **full US dollars**. The raw Census files report **thousands of dollars** and the corpus keeps them that way in its own `amt` column, but `cog_spending()` and `cog_revenue()` multiply by 1000 on the way out, so `amt_per_capita_nominal` is already dollars per person. Do not scale it again.
|
||||||
|
|
||||||
Per-capita finance numbers divide each year's spending or revenue by a population denominator. The choice of denominator is a research decision, not an implementation detail: a 24-year corpus paired with a single 5-year ACS estimate produces biased per-capita values whose magnitude scales with each government's population change.
|
Per-capita finance numbers divide each year's spending or revenue by a population denominator. The choice of denominator is a research decision, not an implementation detail: a 24-year corpus paired with a single 5-year ACS estimate produces biased per-capita values whose magnitude scales with each government's population change.
|
||||||
|
|
||||||
`uscogdata` defaults to the **Census F-33 population value Census itself uses to compute its published per-capita tables.** That value is recorded on every COG row as `population`, with `popyear` indicating the vintage. For a city that grew from 200,000 to 300,000 between 2000 and 2023, this default reproduces the per-capita value Census published. A static ACS denominator would have understated 2000 per-capita by ~33%.
|
`uscogdata` defaults to the **Census F-33 population value Census itself uses to compute its published per-capita tables.** That value is recorded on every COG row as `population`, with `popyear` indicating the vintage. For a city that grew from 200,000 to 300,000 between 2000 and 2023, this default reproduces the per-capita value Census published. A static ACS denominator would have understated 2000 per-capita by ~33%.
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
---
|
||||||
|
title: "Total spending: Primary, Direct, Total, and when each is right"
|
||||||
|
output: rmarkdown::html_vignette
|
||||||
|
vignette: >
|
||||||
|
%\VignetteIndexEntry{Total spending: Primary, Direct, Total, and when each is right}
|
||||||
|
%\VignetteEngine{knitr::rmarkdown}
|
||||||
|
%\VignetteEncoding{UTF-8}
|
||||||
|
---
|
||||||
|
|
||||||
|
```{r setup, include = FALSE}
|
||||||
|
knitr::opts_chunk$set(collapse = TRUE, comment = "#>")
|
||||||
|
```
|
||||||
|
|
||||||
|
# Two questions that sound the same but aren't
|
||||||
|
|
||||||
|
"Total spending" means two different things depending on whether the question
|
||||||
|
is about one government or several:
|
||||||
|
|
||||||
|
1. **"What did my county spend in total, a decade ago vs today?"** — one
|
||||||
|
government, tracked over time. Any concept answers this correctly, as
|
||||||
|
long as the same concept is used for both years.
|
||||||
|
2. **"How do all the counties in my state compare, a decade ago vs today,
|
||||||
|
against the neighboring state?"** — several governments, summed together.
|
||||||
|
Here only a non-intergovernmental concept (`primary` or `direct`) gives
|
||||||
|
the right answer; summing `total` across governments double-counts money
|
||||||
|
that passes between them.
|
||||||
|
|
||||||
|
`cog_spending()`'s `expenditure_concept` argument controls which of these a
|
||||||
|
query answers, via three nested concepts defined as sets of the crosswalk's
|
||||||
|
`spend_subtype` values (never item-code first letters — the letter `Y` alone
|
||||||
|
spans revenue, expenditure, and balance codes):
|
||||||
|
|
||||||
|
- `"primary"` (the default) — the government's own service provision:
|
||||||
|
`operations` + `capital` + `assistance`.
|
||||||
|
- `"direct"` — Census's published Direct Expenditure: `primary` plus
|
||||||
|
`interest` on debt and `insurance_benefits` (e.g. pension payments).
|
||||||
|
- `"total"` — `direct` plus the `intergovernmental` leg.
|
||||||
|
|
||||||
|
This vignette walks through both questions with code that actually runs
|
||||||
|
against the package's bundled fixture corpus, then explains why the second
|
||||||
|
question refuses `"total"` outright.
|
||||||
|
|
||||||
|
Before any of the numbers below: every amount column here — `amt_nominal`,
|
||||||
|
`amt_real`, and their `amt_per_capita_*` counterparts — is in **full US
|
||||||
|
dollars**. The raw Census files report **thousands of dollars** and the
|
||||||
|
corpus preserves that in its own `amt` column, but the verbs multiply by 1000
|
||||||
|
on the way out. So `amt_nominal = 1317000` means $1.317 million, not $1.317
|
||||||
|
billion. Do not scale it again.
|
||||||
|
|
||||||
|
```{r}
|
||||||
|
library(uscogdata)
|
||||||
|
|
||||||
|
# Point at the bundled offline fixture (years 2011, 2012, 2019, 2020, all 50
|
||||||
|
# states) so this vignette knits without network access. In real use,
|
||||||
|
# USCOGDATA_URL is instead set to the published corpus URL -- see README.md.
|
||||||
|
Sys.setenv(USCOGDATA_URL = paste0(
|
||||||
|
system.file("extdata/fixture_corpus", package = "uscogdata"), "/"
|
||||||
|
))
|
||||||
|
```
|
||||||
|
|
||||||
|
The fixture doesn't carry 2017 or the present year, so the examples below use
|
||||||
|
the closest years it does ship -- **2012 and 2020** -- in place of "2017 vs
|
||||||
|
today" / "ten years ago vs today". Point `USCOGDATA_URL` at the published
|
||||||
|
corpus and swap in real years; the mechanics are identical.
|
||||||
|
|
||||||
|
# Archetype 1: one government's own trend
|
||||||
|
|
||||||
|
For a single government, `total` is a legitimate way to describe "everything
|
||||||
|
this government spent, including money it handed to other governments to
|
||||||
|
spend on its behalf":
|
||||||
|
|
||||||
|
```{r}
|
||||||
|
al_total <- cog_spending(
|
||||||
|
"010000226085", # Alabama, the state government
|
||||||
|
years = c(2012, 2020),
|
||||||
|
category = "Highways",
|
||||||
|
expenditure_concept = "total"
|
||||||
|
)
|
||||||
|
al_total
|
||||||
|
```
|
||||||
|
|
||||||
|
The `intergovernmental` rows are what `"total"` adds on top of the
|
||||||
|
non-intergovernmental subtypes (here `capital` + `operations`): Alabama's
|
||||||
|
own payments out to counties and cities for highway work. Because this
|
||||||
|
query only ever concerns Alabama, including that piece is safe -- there's
|
||||||
|
no other government's number it could be double-counted against.
|
||||||
|
|
||||||
|
`"primary"` (the default) answers the same trend question just as validly
|
||||||
|
(for Highways, which maps only to operations/capital codes, `"primary"` and
|
||||||
|
`"direct"` coincide -- there is no highway-specific interest or insurance
|
||||||
|
benefit to add):
|
||||||
|
|
||||||
|
```{r}
|
||||||
|
al_primary <- cog_spending(
|
||||||
|
"010000226085", years = c(2012, 2020), category = "Highways"
|
||||||
|
# expenditure_concept = "primary" is the default; shown here for contrast
|
||||||
|
)
|
||||||
|
al_primary
|
||||||
|
```
|
||||||
|
|
||||||
|
Both are internally consistent series. What breaks the comparison is
|
||||||
|
**switching concepts between the two years being compared** -- e.g. `direct`
|
||||||
|
for 2012 and `total` for 2020 -- which manufactures a trend that isn't
|
||||||
|
really there. Pick one concept for a given question and hold it fixed across
|
||||||
|
every year in the series.
|
||||||
|
|
||||||
|
# Archetype 2: a cross-government rollup
|
||||||
|
|
||||||
|
`cog_geographic_rollup()` sums spending across state/county/city layers for
|
||||||
|
a place. Its default is `"primary"`, and (as shown below) it accepts only
|
||||||
|
the non-intergovernmental concepts, `"primary"` and `"direct"`:
|
||||||
|
|
||||||
|
```{r}
|
||||||
|
fl_rollup <- cog_geographic_rollup(
|
||||||
|
govids = list(
|
||||||
|
state = "120000226351", # Florida
|
||||||
|
county = c("121011212191", "121099101897") # Broward + Palm Beach
|
||||||
|
),
|
||||||
|
category = "Highways",
|
||||||
|
years = c(2012, 2020)
|
||||||
|
)
|
||||||
|
fl_rollup
|
||||||
|
```
|
||||||
|
|
||||||
|
For the neighboring state, the comparison is a single government, so it's a
|
||||||
|
plain `cog_spending()` call rather than a rollup:
|
||||||
|
|
||||||
|
```{r}
|
||||||
|
ga_state <- cog_spending(
|
||||||
|
"130000226087", years = c(2012, 2020), category = "Highways" # Georgia
|
||||||
|
)
|
||||||
|
ga_state
|
||||||
|
```
|
||||||
|
|
||||||
|
Now the same rollup, but asking for `expenditure_concept = "total"`:
|
||||||
|
|
||||||
|
```{r, error = TRUE}
|
||||||
|
cog_geographic_rollup(
|
||||||
|
govids = list(state = "120000226351", county = "121011212191"),
|
||||||
|
category = "Highways",
|
||||||
|
years = 2020,
|
||||||
|
expenditure_concept = "total"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`cog_geographic_rollup()` (and `cog_peer_compare()`, for the same reason)
|
||||||
|
refuses `"total"` outright rather than silently returning an inflated
|
||||||
|
number. The next section is why.
|
||||||
|
|
||||||
|
# The mechanism
|
||||||
|
|
||||||
|
Suppose Alabama gives a county $10M toward a highway project. That $10M
|
||||||
|
shows up **twice** in the underlying corpus:
|
||||||
|
|
||||||
|
- Once on Alabama's own record, coded `M44` ("to local governments,
|
||||||
|
Highways") -- Alabama's intergovernmental leg.
|
||||||
|
- Again on the county's record, coded `E44` / `F44` ("Highways, current
|
||||||
|
operations" / "capital outlay") -- the county's direct spending, because
|
||||||
|
the county is the government that actually lets the contract and pays the
|
||||||
|
paving crew.
|
||||||
|
|
||||||
|
`primary` and `direct` (the crosswalk's non-intergovernmental expenditure
|
||||||
|
subtypes) only ever count the second of those -- the government that
|
||||||
|
actually did the spending. `total` (Direct plus the intergovernmental leg)
|
||||||
|
counts the first one *as well*, which is exactly right for describing
|
||||||
|
Alabama's own budget: Alabama's `total` genuinely includes the $10M it
|
||||||
|
committed to highways, whether it built the road itself or paid the county
|
||||||
|
to. But sum `total` across Alabama **and** the county, and that $10M is
|
||||||
|
counted twice -- once as Alabama's payment out, once as the county's
|
||||||
|
spending in -- reporting $20M of highway work for $10M actually spent.
|
||||||
|
|
||||||
|
This is exactly the shape of query `cog_geographic_rollup()` exists to run
|
||||||
|
(summing across layers of government), so it refuses `"total"` rather than
|
||||||
|
silently overstating every multi-layer figure it produces.
|
||||||
|
|
||||||
|
# How big is the risk in practice
|
||||||
|
|
||||||
|
Intergovernmental transfers aren't evenly distributed by government type.
|
||||||
|
Measured against the bundled fixture corpus (all 50 states, each of its
|
||||||
|
four years -- 2011, 2012, 2019, 2020), intergovernmental spending as a
|
||||||
|
share of a government's own Direct spending is:
|
||||||
|
|
||||||
|
| Government type | Intergovernmental / Direct |
|
||||||
|
|---|---|
|
||||||
|
| State | 33.1%-40.5% (varies by year; 36.2% pooled across all four) |
|
||||||
|
| County | 3.3%-4.8% (varies by year) |
|
||||||
|
| City | 2.4%-2.9% (varies by year) |
|
||||||
|
|
||||||
|
So the Direct/Total choice matters overwhelmingly for **state**
|
||||||
|
governments -- a state's Total genuinely differs from its Direct by more
|
||||||
|
than a third, while for a county or city the two are close. (The state
|
||||||
|
share is much larger than pre-#11 measurements suggested, because the
|
||||||
|
intergovernmental leg now correctly includes the `Q11`/`Q12`/`Q18` state
|
||||||
|
payments to school systems -- for most states the single largest transfer
|
||||||
|
they make.) That's also why the mistake this vignette warns about is easy
|
||||||
|
to make unnoticed at the county/city level and costly at the state level:
|
||||||
|
rolling up every government using `total` instead of `primary`/`direct`
|
||||||
|
overstates the FY2019 figure by 24.1% for Alabama and 23.2% nationally.
|
||||||
|
|
||||||
|
# Why Total = Direct + M + L + Q, not Direct + M
|
||||||
|
|
||||||
|
It's tempting to assume `total` only needs to add `M`. But the
|
||||||
|
intergovernmental leg has three families, all money the queried government
|
||||||
|
itself pays **out** -- they're not different accounts of a receiving
|
||||||
|
government's revenue. `M` is what it pays to other **local** governments
|
||||||
|
(e.g. a county paying a city for a shared paving contract); `L` is what it
|
||||||
|
pays **up** to its **state** government (e.g. a county's contribution to a
|
||||||
|
state-administered program); and `Q11`/`Q12`/`Q18` are a state's payments
|
||||||
|
to **school systems** (K-12 and higher-ed aid -- for most states the
|
||||||
|
single largest transfer they make, and the piece the pre-#11 prefix
|
||||||
|
allowlist silently dropped, finding F-017). A government's Total genuinely
|
||||||
|
includes every leg it pays, because each is its own spending, just routed
|
||||||
|
to a different kind of recipient. On the bundled fixture corpus (all 50
|
||||||
|
states, 2011/2012/2019/2020), `L` is 0 for state governments (a state has
|
||||||
|
no "payments to the state government" leg of its own) but is 43%-51% the
|
||||||
|
size of `M` for counties (varies by year) and 144%-189% the size of `M`
|
||||||
|
for cities (varies by year; 166% pooled across all four) -- so a `total`
|
||||||
|
that omitted `L` would silently undercount Total specifically for local
|
||||||
|
governments, and for cities `L` is often the *larger* of the two legs.
|
||||||
|
`cog_spending(expenditure_concept = "total")` includes every leg
|
||||||
|
(excluding the `L--` family-total rollup row, which would double-count its
|
||||||
|
own components).
|
||||||
|
|
||||||
|
# Composition rules
|
||||||
|
|
||||||
|
- `expenditure_concept` (whose spending counts -- Primary, Direct, or
|
||||||
|
Direct plus intergovernmental) is **orthogonal** to `basis` (which
|
||||||
|
vintage of the item-code space a query resolves against --
|
||||||
|
`"harmonized"` vs `"raw"`).
|
||||||
|
They combine freely: `expenditure_concept = "total", basis = "raw"` is a
|
||||||
|
valid, meaningful query, and so is every other pairing.
|
||||||
|
- `expenditure_concept = "total"` is **mutually exclusive** with `recipe`: a
|
||||||
|
recipe already defines its own component codes (some recipes have their
|
||||||
|
own matching intergovernmental counterpart recipe instead -- see
|
||||||
|
`cog_recipes()` and the "firing suggestion" notes surfaced in
|
||||||
|
`cog_spending()`'s provenance), so layering a second, generic `total`
|
||||||
|
union on top of a recipe query has no well-defined meaning. Passing both
|
||||||
|
together aborts with an error naming the conflict.
|
||||||
|
- `expenditure_concept` is a **spending-only** concept: `cog_revenue()`
|
||||||
|
doesn't expose it (revenue's own intergovernmental codes are a different
|
||||||
|
axis -- see `?cog_revenue`).
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
|
||||||
|
- Comparing one government to itself over time: any concept works -- pick
|
||||||
|
one and hold it fixed across every year compared.
|
||||||
|
- Comparing or summing across governments -- counties within a state, a
|
||||||
|
state against its neighbor, cities against counties: use `"primary"`
|
||||||
|
(the default) or `"direct"`. `cog_geographic_rollup()` and
|
||||||
|
`cog_peer_compare()` enforce this by refusing `"total"`.
|
||||||
|
- `"primary"` = operations + capital + assistance. `"direct"` = primary +
|
||||||
|
interest on debt + insurance trust benefits (Census's published Direct
|
||||||
|
Expenditure). `"total"` = direct + intergovernmental (`M` to local
|
||||||
|
governments, `L` to the state government excluding the `L--`
|
||||||
|
family-total row, and `Q11`/`Q12`/`Q18` state payments to school
|
||||||
|
systems).
|
||||||
Reference in New Issue
Block a user