Compare commits

...
Author SHA1 Message Date
jared 8bd16bb085 docs: re-measure the corpus-access table against the published corpus (#56)
R-CMD-check / check (pull_request) Successful in 3m42s
R-CMD-check / check (push) Successful in 3m39s
#56 step 4 asked for the README table to be re-measured after the pass. The old
figures predate the row-group rechunk (cog_pipeline#93, published 2026-08-09)
and reported the mirrored column as 'local speed' with no number -- hiding the
largest difference available to a user.

Measured 2026-08-10, fresh R session per arm, against the live corpus at
pipeline_commit 3d28ddd. Madison WI, 16-core Linux workstation.

Three findings the old table could not express:

- A local mirror is 60-80x faster. A one-off question is ~12 s end to end
  remotely against ~0.15 s mirrored. Stated outright now, because it is a
  bigger and cheaper win for users than anything in the R code.

- Opening the session is the LARGEST remote cost (~7.5 s), bigger than any
  individual query, and it lands on the user's first query rather than on
  library(). The old table accounted for it nowhere, so every per-query figure
  was quietly missing it.

- The remote cost is round-trips, not scanning: a repeat query over
  already-touched partitions is ~1.5 s against ~4 s cold, and a full-history
  query costs ~7 s whether it runs first or last (verified by running the arms
  in both orders). This is why #93's 1.4-1.7x, measured through cog-api against
  a local mount, does not show up on the remote path -- there, network latency
  swamps scan time.

Corpus size corrected to ~201 MB: row-group chunking added ~3.4%, and 190.6 was
ambiguous between MB and MiB besides. Measured from the manifest and on disk.
The 0.3.0 NEWS section keeps 190.6 -- it was correct for that release.

Also documented HTTP 429: a burst of remote queries gets rate-limited by the
host. Hit while taking these measurements.
2026-08-10 19:16:03 -04:00
jared 9508b98676 Merge pull request 'feat: cohort predicates instead of a 40k-id IN list (#58)' (#61) from feat/cohort-predicates-58 into main
Mirror to GitHub / mirror (push) Successful in 9s
R-CMD-check / check (push) Successful in 3m18s
Reviewed-on: #61
2026-08-09 14:31:53 -04:00
jared 0fbae00e27 feat: name a cohort by state/type predicate instead of a 40k-id IN list
R-CMD-check / check (push) Successful in 4m29s
R-CMD-check / check (pull_request) Successful in 4m19s
cog_spending(), cog_revenue() and cog_balances() gain optional state/type
arguments. Both default to NULL, so every existing govid-based call is
unchanged.

The verbs took a cohort only as a govid vector, which .sql_lit_chr()
rendered into a quoted IN list and .verb_spendrev() embedded into 5-8
separate statements per call: the scope check, the main aggregate, the
per-capita join, the harmonization block, and the suggestion and
suppression queries. For type = "city" that list is 301,589 characters,
parsed and planned from scratch every time it appears.

Passing state/type instead expresses the cohort as a subquery against
canonical_fips_xwalk, so its size never enters the SQL string at all.

Measured on the production corpus, same FY2022 aggregate over the
20,106-government city cohort, DUCKDB_THREADS=2, median of 5:

  IN (20,106 literals) -- 0.3.0            432 ms
  join against a temp cohort table         132 ms
  predicate on canonical_fips_xwalk         102 ms
  no cohort filter at all (the floor)      105 ms

The predicate reaches the no-filter floor: the cohort restriction is
now free. End to end through cog_spending(category = "Police"),
1080 ms -> 271 ms, 3.99x -- larger than the single-query saving,
because the repetition across statements is what actually cost.

Design decisions, both made explicitly rather than left implicit:

  - govid AND state/type INTERSECT. "These ids, narrowed to that
    state/type" is a real query, and an error here could never be
    relaxed later without breaking callers.
  - A predicate cohort has no id list to report, so
    provenance$scope$govids_found/govids_missing stay empty and a new
    scope$cohort block carries state, type and n_governments. Resolving
    the ids just to report them would put 20,000 govids in every
    fleet-scale response body -- the cost this change removes. A
    govid-named cohort's provenance is untouched.

state/type are coerced with .coerce_state_to_fips()/.coerce_type(), the
same helpers cog_gov_search() uses. That is load-bearing: the argument
is a postal abbreviation ("WI") while fips_state holds a FIPS code
("55"), and a predicate on the raw parameter matches nothing and returns
an empty result indistinguishable from "reported nothing". cog-api hit
exactly this trap optimizing the same path.

.attach_per_capita() now keys its population lookup on the govids present
in the result rather than the requested cohort. Those are the only ones
its LEFT JOIN can match, so the output is identical -- but it needs no id
list, and on a paginated call it looks up one page instead of the fleet.

Fixes uscogdata#58.
2026-08-09 14:15:21 -04:00
jared 698812a25c Merge pull request 'fix: local corpus paths were unreadable on Windows (backslashes eaten)' (#55) from fix/windows-backslash-paths into main
Mirror to GitHub / mirror (push) Failing after 8s
R-CMD-check / check (push) Successful in 3m34s
2026-08-09 09:08:41 -04:00
jared d74ecdd4a5 Merge pull request 'ci: mirror main and tags to the GitHub mirror' (#54) from ci/mirror-to-github into main
R-CMD-check / check (push) Successful in 3m29s
Mirror to GitHub / mirror (push) Failing after 11s
2026-08-09 09:04:28 -04:00
jared 28c500f47a ci: mirror main and tags to the GitHub mirror
R-CMD-check / check (pull_request) Successful in 3m23s
R-CMD-check / check (push) Successful in 4m2s
A plain non-force git push rather than Gitea's built-in push mirror. A
push mirror force-updates the refs it owns, so a Merge clicked on a GitHub
PR would be silently overwritten on the next sync -- the PR still reading
'Merged' while its commit became unreachable. A non-force push is rejected
instead, which turns that into a red CI run.

Secret is PAT_GH, not GITHUB_MIRROR_PAT: Gitea reserves the GITHUB_ and
GITEA_ prefixes for its own injected variables and refuses secrets using
them.
2026-08-08 20:20:56 -04:00
21 changed files with 810 additions and 80 deletions
+43
View File
@@ -0,0 +1,43 @@
# Mirror the canonical Gitea repo to the public GitHub mirror.
#
# Deliberately a plain `git push`, NOT Gitea's built-in push mirror. A push
# mirror force-updates the refs it owns: if anyone ever clicks Merge on a
# GitHub PR, the next sync silently overwrites main, the PR still displays
# "Merged", the commit becomes unreachable, and nothing anywhere says so.
# A non-force push is REJECTED as non-fast-forward the moment that happens,
# turning a silent data-loss trap into a red CI run in a place we already look.
#
# Do NOT add --force here, and do NOT add GitHub branch protection to the
# mirror: protection rules block the mirror's legitimate pushes too, breaking
# normal syncing to catch an abnormal case.
#
# PAT_GH is a GitHub personal access token (repo + workflow scope; workflow is
# required because this pushes .github/workflows/). It is stored as a Gitea
# Actions secret. The name cannot begin with GITHUB_ or GITEA_ -- Gitea
# reserves both prefixes for its own injected variables and rejects the secret.
name: Mirror to GitHub
on:
push:
branches: [main]
tags: ['v*']
jobs:
mirror:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Push main and tags to the GitHub mirror
env:
PAT_GH: ${{ secrets.PAT_GH }}
run: |
set -eu
if [ -z "${PAT_GH:-}" ]; then
echo "PAT_GH is unset -- add it under Settings > Actions > Secrets." >&2
exit 1
fi
git push "https://x-access-token:${PAT_GH}@github.com/civilytics/uscogdata.git" \
HEAD:refs/heads/main --tags
+1 -1
View File
@@ -1,7 +1,7 @@
Package: uscogdata Package: uscogdata
Type: Package Type: Package
Title: Curated Reader for the Civilytics US Census of Governments Finance Corpus Title: Curated Reader for the Civilytics US Census of Governments Finance Corpus
Version: 0.3.0 Version: 0.4.0
Authors@R: c( Authors@R: c(
person(c("Jared", "E."), "Knowles", person(c("Jared", "E."), "Knowles",
email = "jared@civilytics.com", email = "jared@civilytics.com",
+80
View File
@@ -1,3 +1,83 @@
# uscogdata 0.4.0
## Documentation: the corpus-access table is re-measured and honest
The README's "two ways to read the corpus" table carried figures taken before
the corpus was re-chunked into row groups (cog_pipeline#93, published
2026-08-09) and reported the mirrored column as "local speed" with no number at
all. Re-measured 2026-08-10 against the published corpus (`pipeline_commit
3d28ddd`), fresh R session per arm:
* **A local mirror is roughly 60-80x faster.** A one-off question costs ~12 s
end to end remotely against ~0.15 s mirrored. That is the largest single
difference available to a user and it is now stated outright rather than left
as "local speed".
* **Opening the session is the largest remote cost** (~7.5 s -- manifest fetch
plus 23 view registrations over HTTPS), larger than any individual query, and
it lands on the first query rather than on `library(uscogdata)`. The old table
did not account for it anywhere.
* **The remote cost is round-trips, not scanning.** A repeat query over
already-touched partitions is ~1.5 s against ~4 s cold, and a full-history
query costs ~7 s whether it runs first or last.
* The corpus size is **~201 MB**, not 190.6 MB -- row-group chunking added ~3.4%
and the old figure was ambiguous between MB and MiB besides.
* Documented that a burst of remote queries can be rate-limited by the host
(`HTTP 429`), which is another reason to mirror for real work.
## Cohorts can be named by predicate, not just by id
`cog_spending()`, `cog_revenue()` and `cog_balances()` gain optional `state`
and `type` arguments. Both default to `NULL`, so every existing call behaves
exactly as before.
Passing them expresses the cohort as a subquery against `canonical_fips_xwalk`
inside each statement, instead of round-tripping the ids through R and
rendering them back into a literal `IN` list:
```r
# before: resolve 20,106 ids in R, then embed them in every statement
ids <- cog_gov_search(NULL, state = "CA", type = "city")$canonical_govid
cog_spending(ids, years = 2022)
# now: the cohort never leaves the database
cog_spending(years = 2022, state = "CA", type = "city")
```
Measured against the production corpus, same FY2022 aggregate over the
20,106-government `type = "city"` cohort:
| cohort expressed as | time |
|---|---:|
| `IN (20,106 literals)` | 449 ms |
| join against a temp cohort table | 99 ms |
| predicate on `canonical_fips_xwalk` | **94 ms** |
| no cohort filter at all (the floor) | 88 ms |
**4.8x, within 7% of the floor.** The rendered `IN` list was 301,591
characters and was re-parsed in 5-8 separate statements per call, so the cost
was paid repeatedly; the predicate's size is constant in the cohort.
`state` and `type` use the same vocabulary and the same internal coercion as
`cog_gov_search()` -- `state` is a postal abbreviation (`"WI"`) even though the
crosswalk column holds a FIPS code (`"55"`).
Supplying `govid` **and** `state`/`type` intersects them: the governments in
`govid` that also match the predicate. Naming no cohort at all now aborts with
class `uscogdata_no_cohort` rather than R's "argument is missing" error.
When the cohort is named by predicate there is no id list to report, so
`provenance$scope$govids_found`/`govids_missing` are empty and
`provenance$scope$cohort` carries `state`, `type` and `n_governments` instead.
A `govid`-named cohort's provenance is unchanged.
## Fixes
* An unknown `state` abbreviation now aborts with "Unknown state abbreviation"
(class `uscogdata_unknown_state`) instead of base R's "subscript out of
bounds". `.state_abbrev_to_fips` is a named character vector, so `[[` on an
absent name threw before the curated message could be reached -- making that
message unreachable dead code in every verb that takes a `state`.
# uscogdata 0.3.0 # uscogdata 0.3.0
First public release. First public release.
+13 -7
View File
@@ -18,7 +18,9 @@
#' comparable to a GAAP fund balance from an ACFR. #' comparable to a GAAP fund balance from an ACFR.
#' #'
#' @param govid Canonical govid(s): a character vector, or a data frame with a #' @param govid Canonical govid(s): a character vector, or a data frame with a
#' `canonical_govid` column (e.g. from [cog_gov_search()]). #' `canonical_govid` column (e.g. from [cog_gov_search()]). `NULL` to name
#' the cohort by `state`/`type` instead.
#' @inheritParams cog_spending
#' @param years Integer vector of fiscal years. #' @param years Integer vector of fiscal years.
#' @param category Optional character vector of categories to keep. One of #' @param category Optional character vector of categories to keep. One of
#' `"Fund Balances"`, `"Insurance Trust Balances"`, #' `"Fund Balances"`, `"Insurance Trust Balances"`,
@@ -63,15 +65,16 @@
#' requested years). `expenditure_concept`/`revenue_concept` are `NA` -- #' requested years). `expenditure_concept`/`revenue_concept` are `NA` --
#' holdings are a stock, not a flow, so neither concept vocabulary applies. #' holdings are a stock, not a flow, so neither concept vocabulary applies.
#' @export #' @export
cog_balances <- function(govid, years, category = NULL, cog_balances <- function(govid = NULL, years, category = NULL,
per_capita = FALSE, adjust_to_year = NULL, per_capita = FALSE, adjust_to_year = NULL,
basis = c("harmonized", "raw"), recipe = NULL) { basis = c("harmonized", "raw"), recipe = NULL,
state = NULL, type = NULL) {
call <- match.call() call <- match.call()
basis <- match.arg(basis, c("harmonized", "raw")) basis <- match.arg(basis, c("harmonized", "raw"))
# Coerce FIRST, validate second: .validate_verb_inputs() asserts # Coerce FIRST, validate second: .validate_verb_inputs() asserts
# is.character(govid), and a data-frame govid (cog_gov_search() output) has # is.character(govid), and a data-frame govid (cog_gov_search() output) has
# not been unwrapped yet at this point. # not been unwrapped yet at this point.
govid <- .coerce_govid_input(govid) govid <- if (is.null(govid)) NULL else .coerce_govid_input(govid)
# The money verbs' validator, reused rather than re-implemented (R/spending.R). # The money verbs' validator, reused rather than re-implemented (R/spending.R).
# It covers the exact superset cog_balances() needs -- including the # It covers the exact superset cog_balances() needs -- including the
# recipe/category mutual-exclusivity guard -- so a second local copy would # recipe/category mutual-exclusivity guard -- so a second local copy would
@@ -91,6 +94,8 @@ cog_balances <- function(govid, years, category = NULL,
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)
cohort <- .make_cohort(govid, state, type)
con <- .ensure_session() con <- .ensure_session()
.require_balance_support(con) .require_balance_support(con)
scope <- .check_govids_in_scope(govid) scope <- .check_govids_in_scope(govid)
@@ -109,7 +114,7 @@ cog_balances <- function(govid, years, category = NULL,
.validate_recipe_id(con, recipe) .validate_recipe_id(con, recipe)
comps <- .recipe_components(con, recipe) comps <- .recipe_components(con, recipe)
recipe_label <- comps$label[[1]] recipe_label <- comps$label[[1]]
result <- .run_recipe(con, recipe, govid, years) result <- .run_recipe(con, recipe, cohort, years)
sql <- attr(result, "sql_query") sql <- attr(result, "sql_query")
result <- .shape_recipe_result(result, "balance_subtype", recipe_label) result <- .shape_recipe_result(result, "balance_subtype", recipe_label)
recipe_block <- list( recipe_block <- list(
@@ -119,7 +124,7 @@ cog_balances <- function(govid, years, category = NULL,
category_for_prov <- recipe_label category_for_prov <- recipe_label
} else { } else {
sql <- .build_verb_sql("balance_annotated", "balance_subtype", sql <- .build_verb_sql("balance_annotated", "balance_subtype",
govid, years, category, cohort, years, category,
ig_view = NULL, subtype_scope = NULL) ig_view = NULL, subtype_scope = NULL)
result <- tibble::as_tibble(DBI::dbGetQuery(con, sql)) result <- tibble::as_tibble(DBI::dbGetQuery(con, sql))
} }
@@ -127,7 +132,7 @@ cog_balances <- function(govid, years, category = NULL,
# Order matters (matches .verb_spendrev()): per-capita first, so # Order matters (matches .verb_spendrev()): per-capita first, so
# .attach_real_dollars() deflates the nominal per-capita column into # .attach_real_dollars() deflates the nominal per-capita column into
# amt_per_capita_real rather than needing amt_per_capita_nominal recomputed. # amt_per_capita_real rather than needing amt_per_capita_nominal recomputed.
if (isTRUE(per_capita)) result <- .attach_per_capita(result, con, govid) if (isTRUE(per_capita)) result <- .attach_per_capita(result, con)
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)
} }
@@ -145,6 +150,7 @@ cog_balances <- function(govid, years, category = NULL,
) )
prov$scope$govids_found <- scope$found prov$scope$govids_found <- scope$found
prov$scope$govids_missing <- scope$missing prov$scope$govids_missing <- scope$missing
prov$scope$cohort <- .cohort_provenance(con, cohort)
prov$balance_caveats <- .balance_caveats( prov$balance_caveats <- .balance_caveats(
con, prov$codes_summed$observed, years con, prov$codes_summed$observed, years
+3 -3
View File
@@ -54,7 +54,7 @@
#' expenditure_concept = "total": ig_long_harmonized COALESCEs rather than #' expenditure_concept = "total": ig_long_harmonized COALESCEs rather than
#' drops NULL-harmonized rows, so harmonization never excludes an IG row. #' drops NULL-harmonized rows, so harmonization never excludes an IG row.
#' @noRd #' @noRd
.build_harmonization_block <- function(con, govid, years, resolved, .build_harmonization_block <- function(con, cohort, years, resolved,
subtype_col, subtype_scope) { subtype_col, subtype_scope) {
if (!identical(resolved$basis, "harmonized")) { if (!identical(resolved$basis, "harmonized")) {
return(list( return(list(
@@ -68,12 +68,12 @@
sql <- sprintf( sql <- sprintf(
"SELECT COUNT(*) AS n, COALESCE(SUM(amt), 0) * 1000.0 AS amt "SELECT COUNT(*) AS n, COALESCE(SUM(amt), 0) * 1000.0 AS amt
FROM long FROM long
WHERE canonical_govid IN (%s) AND year IN (%s) WHERE %s AND year IN (%s)
AND NOT is_aggregate AND harmonized_code IS NULL AND NOT is_aggregate AND harmonized_code IS NULL
AND item_code IN ( AND item_code IN (
SELECT item_code FROM summary_categories WHERE %s IN (%s) SELECT item_code FROM summary_categories WHERE %s IN (%s)
)", )",
.sql_lit_chr(govid), paste(as.integer(years), collapse = ","), .cohort_sql(cohort), paste(as.integer(years), collapse = ","),
subtype_col, .sql_lit_chr(subtype_scope) subtype_col, .sql_lit_chr(subtype_scope)
) )
na <- DBI::dbGetQuery(con, sql) na <- DBI::dbGetQuery(con, sql)
+127
View File
@@ -0,0 +1,127 @@
# How a verb names the set of governments it queries.
#
# Historically there was one way: a `govid` character vector, rendered by
# .sql_lit_chr() into a quoted IN list. That is fine for a handful of
# governments and pathological for a fleet. Measured against the production
# corpus, the same FY2022 aggregate over the 20,106-government `type = "city"`
# cohort:
#
# cohort expressed as time
# IN (20,106 literals) 449 ms
# join against a temp cohort table 99 ms
# predicate on canonical_fips_xwalk 94 ms
# no cohort filter at all (the floor) 88 ms
#
# 4.8x, and within 7% of the no-filter floor. The rendered IN list is 301,591
# characters and .verb_spendrev() embeds it in 5-8 separate statements per
# call, so the parse-and-plan cost is paid over and over (uscogdata#58).
#
# A cohort therefore has two independent halves, and a query can carry either
# or both:
#
# ids an explicit canonical_govid vector -> literal IN list
# predicate state/type over canonical_fips_xwalk -> IN (SELECT ...)
#
# Both together is an INTERSECTION -- "these ids, narrowed to that state/type"
# -- never a precedence rule where one silently wins.
#' Build the internal cohort object shared by every query verb.
#'
#' `state` and `type` are coerced with the SAME helpers `cog_gov_search()`
#' uses. That is load-bearing, not tidiness: the public argument is a postal
#' abbreviation (`"WI"`) while `canonical_fips_xwalk.fips_state` holds a FIPS
#' code (`"55"`), and `type` is a label (`"city"`) against an integer
#' `govs_type`. A predicate written against the raw parameter matches nothing
#' and returns an empty result indistinguishable from "this government
#' reported nothing" -- cog-api hit exactly that trap optimizing this path.
#' One definition of the translation, not two.
#'
#' @param govid Already-coerced character vector of canonical_govids, or NULL.
#' @param state Postal abbreviation or FIPS code, or NULL.
#' @param type Type label or integer code, or NULL.
#' @noRd
.make_cohort <- function(govid = NULL, state = NULL, type = NULL) {
if (is.null(govid) && is.null(state) && is.null(type)) {
cli::cli_abort(c(
"A cohort must be named.",
"*" = "Pass {.arg govid} for specific governments, or {.arg state}/{.arg type} for every government matching a predicate.",
"i" = "Passing both intersects them: the governments in {.arg govid} that also match {.arg state}/{.arg type}."
), class = "uscogdata_no_cohort")
}
structure(
list(
ids = govid,
state = state,
type = type,
state_fips = if (is.null(state)) NULL else .coerce_state_to_fips(state),
type_int = if (is.null(type)) NULL else .coerce_type(type)
),
class = "uscogdata_cohort"
)
}
#' Is any part of this cohort expressed as an xwalk predicate?
#' @noRd
.cohort_by_predicate <- function(cohort) {
!is.null(cohort$state_fips) || !is.null(cohort$type_int)
}
#' Render the cohort as a SQL boolean expression over `col`.
#'
#' `col` may be qualified (`"l.canonical_govid"`, `"x.canonical_govid"`) --
#' several call sites join the xwalk under an alias. The subquery's own
#' projected column stays unqualified: it selects from canonical_fips_xwalk,
#' not from the outer relation.
#' @noRd
.cohort_sql <- function(cohort, col = "canonical_govid") {
preds <- character(0)
if (!is.null(cohort$ids)) {
preds <- c(preds, sprintf("%s IN (%s)", col, .sql_lit_chr(cohort$ids)))
}
if (.cohort_by_predicate(cohort)) {
xwalk_preds <- character(0)
if (!is.null(cohort$state_fips)) {
xwalk_preds <- c(xwalk_preds,
sprintf("fips_state = %s", .sql_lit_chr(cohort$state_fips)))
}
if (!is.null(cohort$type_int)) {
xwalk_preds <- c(xwalk_preds, sprintf("govs_type = %d", cohort$type_int))
}
preds <- c(preds, sprintf(
"%s IN (SELECT canonical_govid FROM canonical_fips_xwalk WHERE %s)",
col, paste(xwalk_preds, collapse = " AND ")
))
}
paste(preds, collapse = " AND ")
}
#' How many governments the cohort covers.
#'
#' One COUNT against the crosswalk, used only to populate the provenance
#' `scope$cohort` block. Deliberately a count rather than the id list: a
#' fleet-scale cohort would otherwise put 20,000 ids into every response body,
#' which is the cost this issue exists to remove.
#' @noRd
.cohort_count <- function(con, cohort) {
sql <- sprintf(
"SELECT COUNT(*) AS n FROM canonical_fips_xwalk WHERE %s",
.cohort_sql(cohort)
)
as.integer(DBI::dbGetQuery(con, sql)$n[[1]])
}
#' The provenance `scope$cohort` block for a predicate cohort, or NULL when
#' the cohort was named by id alone (in which case `govids_found`/
#' `govids_missing` already describe it exactly).
#' @noRd
.cohort_provenance <- function(con, cohort) {
if (!.cohort_by_predicate(cohort)) return(NULL)
list(
state = if (is.null(cohort$state)) NA_character_ else as.character(cohort$state),
type = if (is.null(cohort$type)) NA_character_ else as.character(cohort$type),
n_governments = .cohort_count(con, cohort)
)
}
+5 -5
View File
@@ -57,7 +57,7 @@
#' aggregate rows. Without it the grid would offer cells the verb structurally #' 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. #' never returns, so every one of them would fill as a phantom $0.
#' @noRd #' @noRd
.completion_grid_sql <- function(subtype_col, govid, years, category, .completion_grid_sql <- function(subtype_col, cohort, years, category,
subtype_scope) { subtype_scope) {
category_pred <- if (is.null(category)) { category_pred <- if (is.null(category)) {
"" ""
@@ -76,13 +76,13 @@
JOIN canonical_fips_xwalk x ON x.govs_type = cs.type JOIN canonical_fips_xwalk x ON x.govs_type = cs.type
JOIN summary_categories c ON c.item_code = cs.item_code JOIN summary_categories c ON c.item_code = cs.item_code
JOIN representation r ON r.year = cs.year JOIN representation r ON r.year = cs.year
WHERE x.canonical_govid IN (%2$s) WHERE %2$s
AND cs.year IN (%3$s) AND cs.year IN (%3$s)
AND NOT cs.is_aggregate AND NOT cs.is_aggregate
AND c.category IS NOT NULL AND c.category IS NOT NULL
AND c.%1$s IN (%4$s) AND c.%1$s IN (%4$s)
%5$s", %5$s",
subtype_col, .sql_lit_chr(govid), subtype_col, .cohort_sql(cohort, "x.canonical_govid"),
paste(as.integer(years), collapse = ","), paste(as.integer(years), collapse = ","),
.sql_lit_chr(subtype_scope), category_pred .sql_lit_chr(subtype_scope), category_pred
) )
@@ -94,10 +94,10 @@
#' provenance block. Reported rows are passed through untouched -- filling #' provenance block. Reported rows are passed through untouched -- filling
#' must never alter or drop what the corpus actually published. #' must never alter or drop what the corpus actually published.
#' @noRd #' @noRd
.complete_result <- function(result, con, subtype_col, govid, years, category, .complete_result <- function(result, con, subtype_col, cohort, years, category,
subtype_scope) { subtype_scope) {
grid <- tibble::as_tibble(DBI::dbGetQuery( grid <- tibble::as_tibble(DBI::dbGetQuery(
con, .completion_grid_sql(subtype_col, govid, years, category, subtype_scope) con, .completion_grid_sql(subtype_col, cohort, years, category, subtype_scope)
)) ))
result$value_source <- rep("reported", nrow(result)) result$value_source <- rep("reported", nrow(result))
+3 -3
View File
@@ -113,7 +113,7 @@ cog_recipes <- function(pattern = NULL) {
#' `year_min`/`year_max` -- so there is no double-counting. (Checkpoint #' `year_min`/`year_max` -- so there is no double-counting. (Checkpoint
#' review docs/phase_r_harmonization_review.md § 0.2.) #' review docs/phase_r_harmonization_review.md § 0.2.)
#' @noRd #' @noRd
.run_recipe <- function(con, recipe_id, govid, years) { .run_recipe <- function(con, recipe_id, cohort, years) {
sql <- sprintf( sql <- sprintf(
"SELECT l.year, l.canonical_govid, "SELECT l.year, l.canonical_govid,
COALESCE(x.gov_name, l.gov_name) AS gov_name, COALESCE(x.gov_name, l.gov_name) AS gov_name,
@@ -128,11 +128,11 @@ cog_recipes <- function(pattern = NULL) {
OR (r.gov_type_scope = 'local' AND l.type BETWEEN 1 AND 3)) OR (r.gov_type_scope = 'local' AND l.type BETWEEN 1 AND 3))
LEFT JOIN canonical_fips_xwalk x USING (canonical_govid) LEFT JOIN canonical_fips_xwalk x USING (canonical_govid)
WHERE r.recipe_id = %1$s WHERE r.recipe_id = %1$s
AND l.canonical_govid IN (%2$s) AND %2$s
AND l.year IN (%3$s) AND l.year IN (%3$s)
GROUP BY 1, 2, 3 GROUP BY 1, 2, 3
ORDER BY 1, 2", ORDER BY 1, 2",
.sql_lit_chr(recipe_id), .sql_lit_chr(govid), .sql_lit_chr(recipe_id), .cohort_sql(cohort, "l.canonical_govid"),
paste(as.integer(years), collapse = ",") paste(as.integer(years), collapse = ",")
) )
result <- tibble::as_tibble(DBI::dbGetQuery(con, sql)) result <- tibble::as_tibble(DBI::dbGetQuery(con, sql))
+6 -3
View File
@@ -50,11 +50,12 @@
#' optional `pop_source`, `codes_included`, `aggregate_fallback`, `notes`, #' optional `pop_source`, `codes_included`, `aggregate_fallback`, `notes`,
#' and `value_source` when `complete = TRUE`. #' and `value_source` when `complete = TRUE`.
#' @export #' @export
cog_revenue <- function(govid, years, category = NULL, cog_revenue <- function(govid = NULL, years, category = NULL,
per_capita = FALSE, adjust_to_year = NULL, per_capita = FALSE, adjust_to_year = NULL,
basis = c("harmonized", "raw"), recipe = NULL, basis = c("harmonized", "raw"), recipe = NULL,
revenue_concept = c("general", "total"), revenue_concept = c("general", "total"),
complete = FALSE, limit = NULL, offset = NULL) { complete = FALSE, limit = NULL, offset = NULL,
state = NULL, type = NULL) {
# flow_prefixes no longer classifies rows (crosswalk revenue_subtype # flow_prefixes no longer classifies rows (crosswalk revenue_subtype
# membership does -- General Revenue, i.e. everything except # membership does -- General Revenue, i.e. everything except
# insurance_trust) -- it only scopes the recipe-suggestion machinery to # insurance_trust) -- it only scopes the recipe-suggestion machinery to
@@ -75,6 +76,8 @@ cog_revenue <- function(govid, years, category = NULL,
revenue_concept = revenue_concept, revenue_concept = revenue_concept,
complete = complete, complete = complete,
limit = limit, limit = limit,
offset = offset offset = offset,
state = state,
type = type
) )
} }
+11 -4
View File
@@ -184,11 +184,18 @@ cog_gov_search <- function(name = NULL, state = NULL, type = NULL) {
if (!is.character(state) || length(state) != 1L) { if (!is.character(state) || length(state) != 1L) {
cli::cli_abort("`state` must be a 2-letter USPS abbrev or a FIPS integer.") cli::cli_abort("`state` must be a 2-letter USPS abbrev or a FIPS integer.")
} }
fips <- .state_abbrev_to_fips[[toupper(state)]] # Membership tested before the lookup, not after: `.state_abbrev_to_fips` is
if (is.null(fips)) { # a named CHARACTER vector, and `[[` on a name it does not carry throws
cli::cli_abort("Unknown state abbreviation: {state}.") # base R's "subscript out of bounds" rather than returning NULL -- which
# made the curated message below unreachable dead code. Reported as a bare
# subscript error, `cog_gov_search(state = "ZZ")` gave no hint that the
# argument wants a postal abbreviation.
key <- toupper(state)
if (!key %in% names(.state_abbrev_to_fips)) {
cli::cli_abort("Unknown state abbreviation: {state}.",
class = "uscogdata_unknown_state")
} }
fips .state_abbrev_to_fips[[key]]
} }
# USPS state / territory abbreviation -> 2-digit FIPS code. # USPS state / territory abbreviation -> 2-digit FIPS code.
+74 -20
View File
@@ -68,7 +68,9 @@
#' millions/billions). The conversion is recorded in the provenance attribute #' millions/billions). The conversion is recorded in the provenance attribute
#' under `transformations$units_conversion`. #' under `transformations$units_conversion`.
#' #'
#' @param govid Character vector of `canonical_govid` values. #' @param govid Character vector of `canonical_govid` values, or `NULL` to name
#' the cohort by `state`/`type` instead. One of `govid`, `state`, or `type`
#' is required.
#' @param years Integer vector of years. #' @param years Integer vector of years.
#' @param category Character vector of category names (from #' @param category Character vector of category names (from
#' `summary_categories.category`), or `NULL` for all categories broken out #' `summary_categories.category`), or `NULL` for all categories broken out
@@ -185,6 +187,29 @@
#' `recipe` and with `complete = TRUE` -- see `offset` and `total_rows`. #' `recipe` and with `complete = TRUE` -- see `offset` and `total_rows`.
#' @param offset Rows to skip before `limit` starts counting (0-based). #' @param offset Rows to skip before `limit` starts counting (0-based).
#' Ignored if `limit` is `NULL`; defaults to `0L` when `limit` is set. #' Ignored if `limit` is `NULL`; defaults to `0L` when `limit` is set.
#' @param state,type Name the cohort by predicate instead of by id: `state` is
#' a 2-letter USPS abbreviation (or a FIPS code) and `type` is one of
#' `"state"`, `"county"`, `"city"`, `"township"` (or the integer `0:3`) --
#' the same vocabulary, and the same internal coercion, as
#' [cog_gov_search()]. Both default to `NULL`.
#'
#' The cohort is then expressed as a subquery against `canonical_fips_xwalk`
#' inside each statement rather than round-tripped through R as a literal id
#' list. For a fleet-scale cohort that is the difference between a
#' 301,591-character `IN` list re-parsed in 5--8 statements per call and a
#' constant-size predicate: measured at **94 ms versus 449 ms** for the same
#' FY2022 aggregate over the 20,106-government `type = "city"` cohort, within
#' 7% of the no-filter floor.
#'
#' Supplying `govid` **and** `state`/`type` INTERSECTS them -- the
#' governments in `govid` that also match the predicate -- rather than one
#' silently taking precedence. Naming no cohort at all (`govid`, `state` and
#' `type` all `NULL`) aborts with class `uscogdata_no_cohort`.
#'
#' When the cohort is named by predicate, `provenance$scope$govids_found`
#' and `govids_missing` are empty -- there is no id list to report against --
#' and `provenance$scope$cohort` carries `state`, `type` and
#' `n_governments` instead. A `govid`-named cohort reports exactly as before.
#' @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`,
@@ -198,11 +223,12 @@
#' round trip -- so a caller walking pages never has to ask "how many are #' round trip -- so a caller walking pages never has to ask "how many are
#' there" separately. #' there" separately.
#' @export #' @export
cog_spending <- function(govid, years, category = NULL, cog_spending <- function(govid = NULL, years, category = NULL,
per_capita = FALSE, adjust_to_year = NULL, per_capita = FALSE, adjust_to_year = NULL,
basis = c("harmonized", "raw"), recipe = NULL, basis = c("harmonized", "raw"), recipe = NULL,
expenditure_concept = c("primary", "direct", "total"), expenditure_concept = c("primary", "direct", "total"),
complete = FALSE, limit = NULL, offset = NULL) { complete = FALSE, limit = NULL, offset = NULL,
state = NULL, type = NULL) {
# flow_prefixes no longer classifies rows (crosswalk subtype membership # flow_prefixes no longer classifies rows (crosswalk subtype membership
# does, per expenditure_concept) -- it only scopes the recipe-suggestion # does, per expenditure_concept) -- it only scopes the recipe-suggestion
# machinery to this verb's recipe families (see R/suggestions.R; the # machinery to this verb's recipe families (see R/suggestions.R; the
@@ -223,7 +249,9 @@ cog_spending <- function(govid, years, category = NULL,
expenditure_concept = expenditure_concept, expenditure_concept = expenditure_concept,
complete = complete, complete = complete,
limit = limit, limit = limit,
offset = offset offset = offset,
state = state,
type = type
) )
} }
@@ -251,7 +279,8 @@ cog_spending <- function(govid, years, category = NULL,
basis = c("harmonized", "raw"), recipe = NULL, basis = c("harmonized", "raw"), recipe = NULL,
expenditure_concept = c("primary", "direct", "total"), expenditure_concept = c("primary", "direct", "total"),
revenue_concept = c("general", "total"), revenue_concept = c("general", "total"),
complete = FALSE, limit = NULL, offset = NULL) { complete = FALSE, limit = NULL, offset = NULL,
state = NULL, type = NULL) {
basis_explicit <- length(basis) == 1L basis_explicit <- length(basis) == 1L
basis <- match.arg(basis, c("harmonized", "raw")) basis <- match.arg(basis, c("harmonized", "raw"))
# match.arg() itself throws a base `simpleError`, not an rlang-classed # match.arg() itself throws a base `simpleError`, not an rlang-classed
@@ -291,7 +320,10 @@ cog_spending <- function(govid, years, category = NULL,
.revenue_concept_subtypes(revenue_concept) .revenue_concept_subtypes(revenue_concept)
} }
govid <- .coerce_govid_input(govid, arg = "govid") # NULL `govid` means "the cohort is named by predicate"; anything else is
# coerced and validated exactly as before, so an empty or wrong-typed vector
# still fails with its original message rather than being read as absent.
govid <- if (is.null(govid)) NULL else .coerce_govid_input(govid, arg = "govid")
# allow_all_categories = TRUE: cog_spending()/cog_revenue() are the two # allow_all_categories = TRUE: cog_spending()/cog_revenue() are the two
# verbs the reserved pseudo-category is defined for. cog_balances() shares # verbs the reserved pseudo-category is defined for. cog_balances() shares
# this validator but leaves the argument at its FALSE default, so it # this validator but leaves the argument at its FALSE default, so it
@@ -300,6 +332,10 @@ cog_spending <- function(govid, years, category = NULL,
.validate_verb_inputs(govid, years, category, per_capita, adjust_to_year, .validate_verb_inputs(govid, years, category, per_capita, adjust_to_year,
recipe, allow_all_categories = TRUE) recipe, allow_all_categories = TRUE)
# Built after validation so the argument-shape errors above keep firing
# first, and before .ensure_session() so a bad state/type costs no I/O.
cohort <- .make_cohort(govid, state, type)
# Recognize the reserved pseudo-category. Detected after type validation so a # Recognize the reserved pseudo-category. Detected after type validation so a
# non-character `category` still fails with the ordinary type error. # non-character `category` still fails with the ordinary type error.
all_categories <- !is.null(category) && .ALL_CATEGORIES %in% category all_categories <- !is.null(category) && .ALL_CATEGORIES %in% category
@@ -407,7 +443,7 @@ cog_spending <- function(govid, years, category = NULL,
.validate_recipe_id(con, recipe) .validate_recipe_id(con, recipe)
comps <- .recipe_components(con, recipe) comps <- .recipe_components(con, recipe)
recipe_label <- comps$label[[1]] recipe_label <- comps$label[[1]]
result <- .run_recipe(con, recipe, govid, years) result <- .run_recipe(con, recipe, cohort, years)
sql <- attr(result, "sql_query") sql <- attr(result, "sql_query")
result <- .shape_recipe_result(result, subtype_col, recipe_label) result <- .shape_recipe_result(result, subtype_col, recipe_label)
recipe_block <- list( recipe_block <- list(
@@ -423,7 +459,7 @@ cog_spending <- function(govid, years, category = NULL,
} else { } else {
NULL NULL
} }
sql <- .build_verb_sql(view, subtype_col, govid, years, sql <- .build_verb_sql(view, subtype_col, cohort, years,
if (all_categories) NULL else category, if (all_categories) NULL else category,
ig_view, subtype_scope, ig_view, subtype_scope,
all_categories = all_categories, all_categories = all_categories,
@@ -441,7 +477,7 @@ cog_spending <- function(govid, years, category = NULL,
} else { } else {
count_sql <- sprintf( count_sql <- sprintf(
"SELECT COUNT(*) AS n FROM (%s) AS _uncounted", "SELECT COUNT(*) AS n FROM (%s) AS _uncounted",
.build_verb_sql(view, subtype_col, govid, years, .build_verb_sql(view, subtype_col, cohort, years,
if (all_categories) NULL else category, if (all_categories) NULL else category,
ig_view, subtype_scope, ig_view, subtype_scope,
all_categories = all_categories) all_categories = all_categories)
@@ -457,13 +493,13 @@ cog_spending <- function(govid, years, category = NULL,
# spurious 0. # spurious 0.
completion <- list(applied = FALSE, rows_filled = 0L, absence_means = list()) completion <- list(applied = FALSE, rows_filled = 0L, absence_means = list())
if (complete) { if (complete) {
result <- .complete_result(result, con, subtype_col, govid, years, result <- .complete_result(result, con, subtype_col, cohort, years,
category, subtype_scope) category, subtype_scope)
completion <- attr(result, ".completion") completion <- attr(result, ".completion")
attr(result, ".completion") <- NULL attr(result, ".completion") <- NULL
} }
if (per_capita) result <- .attach_per_capita(result, con, govid) if (per_capita) result <- .attach_per_capita(result, con)
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)
} }
@@ -491,7 +527,7 @@ cog_spending <- function(govid, years, category = NULL,
basis_for_prov <- resolved$basis basis_for_prov <- resolved$basis
basis_note_for_prov <- resolved$note basis_note_for_prov <- resolved$note
harmonization <- .build_harmonization_block( harmonization <- .build_harmonization_block(
con, govid, years, resolved, subtype_col, subtype_scope con, cohort, years, resolved, subtype_col, subtype_scope
) )
# C1(a): gap detection must run against the Direct leg alone. `result` # C1(a): gap detection must run against the Direct leg alone. `result`
# can also carry UNION'd intergovernmental rows (expenditure_concept = # can also carry UNION'd intergovernmental rows (expenditure_concept =
@@ -506,7 +542,7 @@ cog_spending <- function(govid, years, category = NULL,
} else { } else {
result result
} }
suggestions <- .build_suggestions(con, govid, years, category, suggestions <- .build_suggestions(con, cohort, years, category,
direct_leg_result, direct_leg_result,
resolved$basis, flow_prefixes, resolved$basis, flow_prefixes,
.select_long_view(view_base, resolved$basis), .select_long_view(view_base, resolved$basis),
@@ -611,6 +647,12 @@ cog_spending <- function(govid, years, category = NULL,
) )
prov$scope$govids_found <- scope$found prov$scope$govids_found <- scope$found
prov$scope$govids_missing <- scope$missing prov$scope$govids_missing <- scope$missing
# A predicate-named cohort has no id list to report found/missing against
# (both stay empty), so it describes itself instead. Deliberately a COUNT
# rather than the resolved ids: enumerating them would put 20,000 govids in
# every fleet-scale response body, which is the cost this path exists to
# remove. NULL for a govid-named cohort, so that output is untouched.
prov$scope$cohort <- .cohort_provenance(con, cohort)
attr(result, "provenance") <- prov attr(result, "provenance") <- prov
attr(result, ".popyear_range") <- NULL attr(result, ".popyear_range") <- NULL
# Attached here, after every downstream transform (per_capita/real-dollar # Attached here, after every downstream transform (per_capita/real-dollar
@@ -639,7 +681,11 @@ cog_spending <- function(govid, years, category = NULL,
.validate_verb_inputs <- function(govid, years, category, .validate_verb_inputs <- function(govid, years, category,
per_capita, adjust_to_year, recipe = NULL, per_capita, adjust_to_year, recipe = NULL,
allow_all_categories = FALSE) { allow_all_categories = FALSE) {
if (!is.character(govid) || length(govid) == 0L) { # NULL is allowed only because the caller has already established that the
# cohort is named some other way (`state`/`type`); .make_cohort() is what
# refuses a call that names no cohort at all. A supplied-but-empty `govid`
# still fails here, exactly as before.
if (!is.null(govid) && (!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.")
} }
if (!(is.integer(years) || is.numeric(years)) || length(years) == 0L) { if (!(is.integer(years) || is.numeric(years)) || length(years) == 0L) {
@@ -740,10 +786,10 @@ 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, cohort, years, category,
ig_view = NULL, subtype_scope = NULL, ig_view = NULL, subtype_scope = NULL,
all_categories = FALSE, limit = NULL, offset = NULL) { all_categories = FALSE, limit = NULL, offset = NULL) {
govid_lit <- .sql_lit_chr(govid) cohort_pred <- .cohort_sql(cohort)
years_lit <- paste(as.integer(years), collapse = ",") years_lit <- paste(as.integer(years), collapse = ",")
# In all-categories mode there is no category filter: the sum is defined by # In all-categories mode there is no category filter: the sum is defined by
# the concept's SUBTYPE allowlist (subtype_pred below), which is the real # the concept's SUBTYPE allowlist (subtype_pred below), which is the real
@@ -813,13 +859,13 @@ cog_spending <- function(govid, years, category = NULL,
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_or(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 %3$s
AND year IN (%4$s) AND year IN (%4$s)
%5$s %5$s
%6$s %6$s
GROUP BY year, canonical_govid, gov_name, xwalk_gov_name, %1$s%8$s GROUP BY year, canonical_govid, gov_name, xwalk_gov_name, %1$s%8$s
ORDER BY year, canonical_govid, %1$s%8$s", ORDER BY year, canonical_govid, %1$s%8$s",
subtype_col, source_expr, govid_lit, years_lit, category_pred, subtype_pred, subtype_col, source_expr, cohort_pred, years_lit, category_pred, subtype_pred,
category_select, category_group category_select, category_group
) )
@@ -845,8 +891,16 @@ cog_spending <- function(govid, years, category = NULL,
} }
} }
#' Join population onto a result and derive the per-capita columns.
#'
#' The population lookup is keyed on the govids PRESENT IN `result`, not on the
#' cohort that produced it. Those are the only ones the LEFT JOIN below can
#' match, so the joined output is identical either way -- but it means this
#' works unchanged for a cohort named by predicate (where no id list exists in
#' R at all), and on a paginated call it looks up one page's governments
#' instead of the whole fleet's.
#' @noRd #' @noRd
.attach_per_capita <- function(result, con, govid) { .attach_per_capita <- function(result, con) {
if (nrow(result) == 0L) { if (nrow(result) == 0L) {
result$amt_per_capita_nominal <- numeric(0) result$amt_per_capita_nominal <- numeric(0)
result$pop_source <- character(0) result$pop_source <- character(0)
@@ -859,7 +913,7 @@ cog_spending <- function(govid, years, category = NULL,
FROM gov_population_yearly FROM gov_population_yearly
WHERE canonical_govid IN (%s) WHERE canonical_govid IN (%s)
AND year IN (%s)", AND year IN (%s)",
.sql_lit_chr(govid), years_lit .sql_lit_chr(unique(result$canonical_govid)), years_lit
) )
pops <- tibble::as_tibble(DBI::dbGetQuery(con, sql)) pops <- tibble::as_tibble(DBI::dbGetQuery(con, sql))
result <- dplyr::left_join(result, pops, result <- dplyr::left_join(result, pops,
+6 -6
View File
@@ -42,8 +42,8 @@
#' verb call: recipes whose generic join would fill a real gap in `result`. #' verb call: recipes whose generic join would fill a real gap in `result`.
#' #'
#' @param con Active DuckDB connection. #' @param con Active DuckDB connection.
#' @param govid Character vector of canonical_govid values (the verb's raw #' @param cohort The verb's cohort object (see `.make_cohort()`), naming the
#' `govid`). #' governments by id, by state/type predicate, or both.
#' @param years Integer vector of requested years. #' @param years Integer vector of requested years.
#' @param category `category` argument as passed to the verb (character #' @param category `category` argument as passed to the verb (character
#' vector or `NULL`; suggestions are only computed when non-NULL). #' vector or `NULL`; suggestions are only computed when non-NULL).
@@ -85,7 +85,7 @@
#' ig_recipe_id, trigger, suppressed_amount, suppressed_years, #' ig_recipe_id, trigger, suppressed_amount, suppressed_years,
#' suppressed_codes)`, possibly empty. #' suppressed_codes)`, possibly empty.
#' @noRd #' @noRd
.build_suggestions <- function(con, govid, years, category, result, basis, .build_suggestions <- function(con, cohort, years, category, result, basis,
flow_prefixes, long_view, flow_prefixes, long_view,
all_categories = FALSE, all_categories = FALSE,
subtype_col = NULL, subtype_scope = NULL) { subtype_col = NULL, subtype_scope = NULL) {
@@ -160,7 +160,7 @@
# violation would kill signposting, the exact failure class uscogdata#9 # violation would kill signposting, the exact failure class uscogdata#9
# exists to prevent. Owner's call: keep this simple; a batch-aware # exists to prevent. Owner's call: keep this simple; a batch-aware
# optimization, if one is worth building, is a separate issue. # optimization, if one is worth building, is a separate issue.
supp <- .suppressed_components(con, candidates, govid, years, long_view, flow_prefixes) supp <- .suppressed_components(con, candidates, cohort, years, long_view, flow_prefixes)
if (length(gap_years) == 0L && nrow(supp) == 0L) return(list()) if (length(gap_years) == 0L && nrow(supp) == 0L) return(list())
@@ -188,9 +188,9 @@
OR (r.gov_type_scope = 'state' AND l.type = 0) OR (r.gov_type_scope = 'state' AND l.type = 0)
OR (r.gov_type_scope = 'local' AND l.type BETWEEN 1 AND 3)) OR (r.gov_type_scope = 'local' AND l.type BETWEEN 1 AND 3))
WHERE r.recipe_id IN (%s) WHERE r.recipe_id IN (%s)
AND l.canonical_govid IN (%s) AND %s
AND l.year IN (%s)", AND l.year IN (%s)",
.sql_lit_chr(candidates), .sql_lit_chr(govid), .sql_lit_chr(candidates), .cohort_sql(cohort, "l.canonical_govid"),
paste(gap_years, collapse = ",") paste(gap_years, collapse = ",")
)) ))
} }
+8 -6
View File
@@ -50,7 +50,9 @@
#' #'
#' @param con Active DuckDB connection. #' @param con Active DuckDB connection.
#' @param candidates Character vector of recipe ids to measure. #' @param candidates Character vector of recipe ids to measure.
#' @param govid Character vector of canonical_govid values. #' @param cohort The verb's cohort object (see `.make_cohort()`), rendered
#' into the govid predicate on both the outer scan and the restated
#' NOT EXISTS filter.
#' @param years Integer vector of requested years. #' @param years Integer vector of requested years.
#' @param long_view Name of the verb's long view, from `.select_long_view()`. #' @param long_view Name of the verb's long view, from `.select_long_view()`.
#' @param flow_prefixes The calling verb's own flow-type prefixes (see #' @param flow_prefixes The calling verb's own flow-type prefixes (see
@@ -60,7 +62,7 @@
#' dollars), `suppressed_codes` (comma-joined, sorted). Zero rows when #' dollars), `suppressed_codes` (comma-joined, sorted). Zero rows when
#' nothing is suppressed. #' nothing is suppressed.
#' @noRd #' @noRd
.suppressed_components <- function(con, candidates, govid, years, long_view, .suppressed_components <- function(con, candidates, cohort, years, long_view,
flow_prefixes) { flow_prefixes) {
empty <- tibble::tibble( empty <- tibble::tibble(
recipe_id = character(0), year = numeric(0), recipe_id = character(0), year = numeric(0),
@@ -93,7 +95,7 @@
OR (r.gov_type_scope = 'state' AND l.type = 0) OR (r.gov_type_scope = 'state' AND l.type = 0)
OR (r.gov_type_scope = 'local' AND l.type BETWEEN 1 AND 3)) OR (r.gov_type_scope = 'local' AND l.type BETWEEN 1 AND 3))
WHERE r.recipe_id IN (%1$s) WHERE r.recipe_id IN (%1$s)
AND l.canonical_govid IN (%2$s) AND %2$s
AND l.year IN (%3$s) AND l.year IN (%3$s)
AND l.amt <> 0 AND l.amt <> 0
AND LEFT(r.component_code, 1) IN (%5$s) AND LEFT(r.component_code, 1) IN (%5$s)
@@ -103,13 +105,13 @@
AND v.year = l.year AND v.year = l.year
AND v.item_code = l.item_code AND v.item_code = l.item_code
AND v.year IN (%3$s) -- restated: enables partition pruning (I3a) AND v.year IN (%3$s) -- restated: enables partition pruning (I3a)
AND v.canonical_govid IN (%2$s) -- restated: pushes the govid filter (I3a) AND %6$s -- restated: pushes the cohort filter (I3a)
) )
GROUP BY 1, 2 GROUP BY 1, 2
ORDER BY 1, 2", ORDER BY 1, 2",
.sql_lit_chr(candidates), .sql_lit_chr(govid), .sql_lit_chr(candidates), .cohort_sql(cohort, "l.canonical_govid"),
paste(as.integer(years), collapse = ","), long_view, paste(as.integer(years), collapse = ","), long_view,
.sql_lit_chr(flow_prefixes) .sql_lit_chr(flow_prefixes), .cohort_sql(cohort, "v.canonical_govid")
) )
tibble::as_tibble(DBI::dbGetQuery(con, sql)) tibble::as_tibble(DBI::dbGetQuery(con, sql))
} }
+29 -5
View File
@@ -18,7 +18,7 @@ carries provenance describing what was converted, what was aggregated, and
which known series breaks intersect your query. which known series breaks intersect your query.
**Scope:** government types 0–3 (state, county, municipality, township). **Scope:** government types 0–3 (state, county, municipality, township).
56 fiscal years, 46,148,034 rows, 190.6 MB. There is no source data for FY1968 56 fiscal years, 46,148,034 rows, ~201 MB. There is no source data for FY1968
or FY1969. Special districts (type 4) and school districts (type 5) are or FY1969. Special districts (type 4) and school districts (type 5) are
excluded pending validation. excluded pending validation.
@@ -85,14 +85,38 @@ cog_explain(spend)
| | Remote (default) | Mirrored | | | Remote (default) | Mirrored |
|---|---|---| |---|---|---|
| Setup | none | `cog_mirror(dest)`, 190.6 MB once | | Setup | none | `cog_mirror(dest)`, ~201 MB once |
| Disk used | **0 MB** — HTTP range requests only | 190.6 MB | | Disk used | **0 MB** — HTTP range requests only | ~201 MB |
| Per query | ~4 s (one government, one year)<br>~6 s (one government, 23 years) | local speed | | Opening a session | ~7.5 s | ~0.1 s |
| One government, one year | ~4 s | ~0.05 s |
| One government, full history | ~7 s | ~0.1 s |
| Later queries, same session | ~1.5 s | ~0.05 s |
| Good for | trying it out, teaching, one-off questions | repeated analysis, offline work, reproducibility | | Good for | trying it out, teaching, one-off questions | repeated analysis, offline work, reproducibility |
**A local mirror is roughly 60–80x faster, and it is one function call.** That is
by far the largest difference any of these settings makes. If you are going to
ask more than a handful of questions, mirror first.
Measured 2026-08-10 on a 16-core Linux workstation against the published corpus
(schema v7, `pipeline_commit 3d28ddd`), fresh R session per arm. A one-off
question costs about **12 seconds end to end remotely and 0.15 seconds
mirrored**, session setup included.
Two things the per-query rows hide:
- **Opening the session is the single largest remote cost** — larger than any
one query. It fetches the manifest and registers 23 SQL views over HTTPS, and
it lands on your first query, not on `library(uscogdata)`.
- **The cost is network round-trips, not scanning.** A repeat query against
partitions this session has already touched is ~1.5 s rather than ~4 s, and a
full-history query costs ~7 s whether it runs first or last. What you are
paying for is reaching each of the 56 yearly files over HTTPS the first time.
Nothing is written to disk in remote mode: DuckDB fetches the parquet footer, Nothing is written to disk in remote mode: DuckDB fetches the parquet footer,
works out which row groups it needs, and reads only those. Nothing is cached works out which row groups it needs, and reads only those. Nothing is cached
between sessions either, so every query goes back to the network. between sessions either, so every query goes back to the network — and a session
that issues many remote queries in quick succession can be rate-limited by the
host (`HTTP Error: ... 429`). Both are further reasons to mirror for real work.
The default points at a public HuggingFace mirror of the corpus. If you would The default points at a public HuggingFace mirror of the corpus. If you would
rather not depend on a third party — for reproducibility, for an air-gapped rather not depend on a third party — for reproducibility, for an air-gapped
+30 -3
View File
@@ -5,18 +5,21 @@
\title{Cash and security holdings for one or more governments} \title{Cash and security holdings for one or more governments}
\usage{ \usage{
cog_balances( cog_balances(
govid, govid = NULL,
years, years,
category = NULL, category = NULL,
per_capita = FALSE, per_capita = FALSE,
adjust_to_year = NULL, adjust_to_year = NULL,
basis = c("harmonized", "raw"), basis = c("harmonized", "raw"),
recipe = NULL recipe = NULL,
state = NULL,
type = NULL
) )
} }
\arguments{ \arguments{
\item{govid}{Canonical govid(s): a character vector, or a data frame with a \item{govid}{Canonical govid(s): a character vector, or a data frame with a
`canonical_govid` column (e.g. from [cog_gov_search()]).} `canonical_govid` column (e.g. from [cog_gov_search()]). `NULL` to name
the cohort by `state`/`type` instead.}
\item{years}{Integer vector of fiscal years.} \item{years}{Integer vector of fiscal years.}
@@ -49,6 +52,30 @@ and raw space are identical for holdings. Reported in
\item{recipe}{Optional harmonization recipe id (see [cog_recipes()]). \item{recipe}{Optional harmonization recipe id (see [cog_recipes()]).
`"cash_securities_z77_wide"` and `"cash_securities_z78_wide"` bridge the `"cash_securities_z77_wide"` and `"cash_securities_z78_wide"` bridge the
wide era to the modern one.} wide era to the modern one.}
\item{state, type}{Name the cohort by predicate instead of by id: `state` is
a 2-letter USPS abbreviation (or a FIPS code) and `type` is one of
`"state"`, `"county"`, `"city"`, `"township"` (or the integer `0:3`) --
the same vocabulary, and the same internal coercion, as
[cog_gov_search()]. Both default to `NULL`.
The cohort is then expressed as a subquery against `canonical_fips_xwalk`
inside each statement rather than round-tripped through R as a literal id
list. For a fleet-scale cohort that is the difference between a
301,591-character `IN` list re-parsed in 5--8 statements per call and a
constant-size predicate: measured at **94 ms versus 449 ms** for the same
FY2022 aggregate over the 20,106-government `type = "city"` cohort, within
7% of the no-filter floor.
Supplying `govid` **and** `state`/`type` INTERSECTS them -- the
governments in `govid` that also match the predicate -- rather than one
silently taking precedence. Naming no cohort at all (`govid`, `state` and
`type` all `NULL`) aborts with class `uscogdata_no_cohort`.
When the cohort is named by predicate, `provenance$scope$govids_found`
and `govids_missing` are empty -- there is no id list to report against --
and `provenance$scope$cohort` carries `state`, `type` and
`n_governments` instead. A `govid`-named cohort reports exactly as before.}
} }
\value{ \value{
Tibble with columns `year`, `canonical_govid`, `gov_name`, Tibble with columns `year`, `canonical_govid`, `gov_name`,
+31 -3
View File
@@ -5,7 +5,7 @@
\title{Summarized revenue by category} \title{Summarized revenue by category}
\usage{ \usage{
cog_revenue( cog_revenue(
govid, govid = NULL,
years, years,
category = NULL, category = NULL,
per_capita = FALSE, per_capita = FALSE,
@@ -15,11 +15,15 @@ cog_revenue(
revenue_concept = c("general", "total"), revenue_concept = c("general", "total"),
complete = FALSE, complete = FALSE,
limit = NULL, limit = NULL,
offset = NULL offset = NULL,
state = NULL,
type = NULL
) )
} }
\arguments{ \arguments{
\item{govid}{Character vector of `canonical_govid` values.} \item{govid}{Character vector of `canonical_govid` values, or `NULL` to name
the cohort by `state`/`type` instead. One of `govid`, `state`, or `type`
is required.}
\item{years}{Integer vector of years.} \item{years}{Integer vector of years.}
@@ -126,6 +130,30 @@ exactly as before this parameter existed. Mutually exclusive with
\item{offset}{Rows to skip before `limit` starts counting (0-based). \item{offset}{Rows to skip before `limit` starts counting (0-based).
Ignored if `limit` is `NULL`; defaults to `0L` when `limit` is set.} Ignored if `limit` is `NULL`; defaults to `0L` when `limit` is set.}
\item{state, type}{Name the cohort by predicate instead of by id: `state` is
a 2-letter USPS abbreviation (or a FIPS code) and `type` is one of
`"state"`, `"county"`, `"city"`, `"township"` (or the integer `0:3`) --
the same vocabulary, and the same internal coercion, as
[cog_gov_search()]. Both default to `NULL`.
The cohort is then expressed as a subquery against `canonical_fips_xwalk`
inside each statement rather than round-tripped through R as a literal id
list. For a fleet-scale cohort that is the difference between a
301,591-character `IN` list re-parsed in 5--8 statements per call and a
constant-size predicate: measured at **94 ms versus 449 ms** for the same
FY2022 aggregate over the 20,106-government `type = "city"` cohort, within
7% of the no-filter floor.
Supplying `govid` **and** `state`/`type` INTERSECTS them -- the
governments in `govid` that also match the predicate -- rather than one
silently taking precedence. Naming no cohort at all (`govid`, `state` and
`type` all `NULL`) aborts with class `uscogdata_no_cohort`.
When the cohort is named by predicate, `provenance$scope$govids_found`
and `govids_missing` are empty -- there is no id list to report against --
and `provenance$scope$cohort` carries `state`, `type` and
`n_governments` instead. A `govid`-named cohort reports exactly as before.}
} }
\value{ \value{
Tibble with columns `year`, `canonical_govid`, `gov_name`, Tibble with columns `year`, `canonical_govid`, `gov_name`,
+31 -3
View File
@@ -5,7 +5,7 @@
\title{Summarized spending by category} \title{Summarized spending by category}
\usage{ \usage{
cog_spending( cog_spending(
govid, govid = NULL,
years, years,
category = NULL, category = NULL,
per_capita = FALSE, per_capita = FALSE,
@@ -15,11 +15,15 @@ cog_spending(
expenditure_concept = c("primary", "direct", "total"), expenditure_concept = c("primary", "direct", "total"),
complete = FALSE, complete = FALSE,
limit = NULL, limit = NULL,
offset = NULL offset = NULL,
state = NULL,
type = NULL
) )
} }
\arguments{ \arguments{
\item{govid}{Character vector of `canonical_govid` values.} \item{govid}{Character vector of `canonical_govid` values, or `NULL` to name
the cohort by `state`/`type` instead. One of `govid`, `state`, or `type`
is required.}
\item{years}{Integer vector of years.} \item{years}{Integer vector of years.}
@@ -146,6 +150,30 @@ exactly as before this parameter existed. Mutually exclusive with
\item{offset}{Rows to skip before `limit` starts counting (0-based). \item{offset}{Rows to skip before `limit` starts counting (0-based).
Ignored if `limit` is `NULL`; defaults to `0L` when `limit` is set.} Ignored if `limit` is `NULL`; defaults to `0L` when `limit` is set.}
\item{state, type}{Name the cohort by predicate instead of by id: `state` is
a 2-letter USPS abbreviation (or a FIPS code) and `type` is one of
`"state"`, `"county"`, `"city"`, `"township"` (or the integer `0:3`) --
the same vocabulary, and the same internal coercion, as
[cog_gov_search()]. Both default to `NULL`.
The cohort is then expressed as a subquery against `canonical_fips_xwalk`
inside each statement rather than round-tripped through R as a literal id
list. For a fleet-scale cohort that is the difference between a
301,591-character `IN` list re-parsed in 5--8 statements per call and a
constant-size predicate: measured at **94 ms versus 449 ms** for the same
FY2022 aggregate over the 20,106-government `type = "city"` cohort, within
7% of the no-filter floor.
Supplying `govid` **and** `state`/`type` INTERSECTS them -- the
governments in `govid` that also match the predicate -- rather than one
silently taking precedence. Naming no cohort at all (`govid`, `state` and
`type` all `NULL`) aborts with class `uscogdata_no_cohort`.
When the cohort is named by predicate, `provenance$scope$govids_found`
and `govids_missing` are empty -- there is no id list to report against --
and `provenance$scope$cohort` carries `state`, `type` and
`n_governments` instead. A `govid`-named cohort reports exactly as before.}
} }
\value{ \value{
Tibble with columns `year`, `canonical_govid`, `gov_name`, Tibble with columns `year`, `canonical_govid`, `gov_name`,
+4 -4
View File
@@ -4,7 +4,7 @@ test_that(".build_verb_sql emits a literal category and no category filter in al
sql <- uscogdata:::.build_verb_sql( sql <- uscogdata:::.build_verb_sql(
view = "spending_annotated", view = "spending_annotated",
subtype_col = "spend_subtype", subtype_col = "spend_subtype",
govid = "552025209777", cohort = uscogdata:::.make_cohort("552025209777"),
years = 2019L, years = 2019L,
category = NULL, category = NULL,
subtype_scope = c("operations", "capital"), subtype_scope = c("operations", "capital"),
@@ -24,7 +24,7 @@ test_that(".build_verb_sql emits a literal category and no category filter in al
test_that(".build_verb_sql is unchanged when all_categories is FALSE", { test_that(".build_verb_sql is unchanged when all_categories is FALSE", {
args <- list( args <- list(
view = "spending_annotated", subtype_col = "spend_subtype", view = "spending_annotated", subtype_col = "spend_subtype",
govid = "552025209777", years = 2019L, category = NULL, cohort = uscogdata:::.make_cohort("552025209777"), years = 2019L, category = NULL,
subtype_scope = c("operations", "capital") subtype_scope = c("operations", "capital")
) )
old <- do.call(uscogdata:::.build_verb_sql, args) old <- do.call(uscogdata:::.build_verb_sql, args)
@@ -244,7 +244,7 @@ test_that('"All Categories" candidate scoping is symmetric with .build_verb_sql(
con <- uscogdata:::.ensure_session() con <- uscogdata:::.ensure_session()
none <- uscogdata:::.build_suggestions( none <- uscogdata:::.build_suggestions(
con, govid = "010000226085", years = 2011L, con, cohort = uscogdata:::.make_cohort("010000226085"), years = 2011L,
category = "All Categories", result = NULL, basis = "harmonized", category = "All Categories", result = NULL, basis = "harmonized",
flow_prefixes = c("E", "F", "G"), flow_prefixes = c("E", "F", "G"),
long_view = "spending_long_harmonized", long_view = "spending_long_harmonized",
@@ -255,7 +255,7 @@ test_that('"All Categories" candidate scoping is symmetric with .build_verb_sql(
expect_length(none, 0L) expect_length(none, 0L)
scoped <- uscogdata:::.build_suggestions( scoped <- uscogdata:::.build_suggestions(
con, govid = "010000226085", years = 2011L, con, cohort = uscogdata:::.make_cohort("010000226085"), years = 2011L,
category = "All Categories", result = NULL, basis = "harmonized", category = "All Categories", result = NULL, basis = "harmonized",
flow_prefixes = c("E", "F", "G"), flow_prefixes = c("E", "F", "G"),
long_view = "spending_long_harmonized", long_view = "spending_long_harmonized",
+218
View File
@@ -0,0 +1,218 @@
# The money verbs accepting a cohort by predicate (uscogdata#58).
#
# The load-bearing property is EQUIVALENCE: naming the same set of governments
# by id and by state/type must return the same rows. Everything else here is
# about the ways that equivalence could silently break -- the postal/FIPS
# translation, the intersection rule, and provenance no longer having an id
# list to describe.
#
# Fixture cohorts used: RI (fips 44) cities = 8 governments, DE (fips 10)
# counties = 3. Small on purpose; the size of the win is measured against the
# production corpus, not here.
# --- equivalence -----------------------------------------------------------
test_that("a predicate cohort returns exactly what the same ids return", {
skip_if_no_corpus()
with_fixture_corpus({
ids <- cog_gov_search(NULL, state = "RI", type = "city")$canonical_govid
expect_gt(length(ids), 1L)
by_id <- cog_spending(govid = ids, years = 2019)
by_pred <- cog_spending(years = 2019, state = "RI", type = "city")
# Compare the data itself, ignoring the provenance attribute -- which is
# SUPPOSED to differ (see the scope tests below).
expect_equal(
as.data.frame(by_id[order(by_id$canonical_govid, by_id$category), ]),
as.data.frame(by_pred[order(by_pred$canonical_govid, by_pred$category), ]),
ignore_attr = TRUE
)
expect_gt(nrow(by_pred), 0L)
})
})
test_that("equivalence holds for cog_revenue()", {
skip_if_no_corpus()
with_fixture_corpus({
ids <- cog_gov_search(NULL, state = "DE", type = "county")$canonical_govid
by_id <- cog_revenue(govid = ids, years = 2019)
by_pred <- cog_revenue(years = 2019, state = "DE", type = "county")
expect_equal(nrow(by_id), nrow(by_pred))
expect_equal(sum(by_id$amt_nominal), sum(by_pred$amt_nominal))
})
})
test_that("equivalence holds for cog_balances()", {
skip_if_no_corpus()
with_fixture_corpus({
ids <- cog_gov_search(NULL, state = "DE", type = "county")$canonical_govid
by_id <- cog_balances(govid = ids, years = 2019)
by_pred <- cog_balances(years = 2019, state = "DE", type = "county")
expect_equal(nrow(by_id), nrow(by_pred))
expect_equal(sum(by_id$amt_nominal), sum(by_pred$amt_nominal))
})
})
test_that("equivalence survives per_capita, adjust_to_year and pagination", {
skip_if_no_corpus()
with_fixture_corpus({
ids <- cog_gov_search(NULL, state = "RI", type = "city")$canonical_govid
# per_capita now keys its population lookup on the rows in the result
# rather than on the requested cohort; these must stay identical.
by_id <- cog_spending(govid = ids, years = 2019, per_capita = TRUE,
adjust_to_year = 2020)
by_pred <- cog_spending(years = 2019, state = "RI", type = "city",
per_capita = TRUE, adjust_to_year = 2020)
expect_equal(by_id$amt_per_capita_nominal, by_pred$amt_per_capita_nominal)
expect_equal(by_id$amt_per_capita_real, by_pred$amt_per_capita_real)
expect_equal(by_id$pop_source, by_pred$pop_source)
paged_id <- cog_spending(govid = ids, years = 2019, limit = 5, offset = 5)
paged_pred <- cog_spending(years = 2019, state = "RI", type = "city",
limit = 5, offset = 5)
expect_equal(as.data.frame(paged_id), as.data.frame(paged_pred),
ignore_attr = TRUE)
expect_identical(attr(paged_id, "total_rows"), attr(paged_pred, "total_rows"))
})
})
test_that("a state-only predicate spans every type in that state", {
skip_if_no_corpus()
with_fixture_corpus({
ids <- cog_gov_search(NULL, state = "DE", type = NULL)$canonical_govid
by_id <- cog_spending(govid = ids, years = 2019)
by_pred <- cog_spending(years = 2019, state = "DE")
expect_equal(nrow(by_id), nrow(by_pred))
})
})
# --- the postal/FIPS trap --------------------------------------------------
test_that("a postal abbreviation resolves to rows, not to silence", {
skip_if_no_corpus()
with_fixture_corpus({
# canonical_fips_xwalk.fips_state holds "44", not "RI". A predicate built
# from the raw parameter matches nothing and returns an empty result that
# reads as "these governments reported nothing" -- the exact trap cog-api
# hit. A zero-row result here is the regression.
r <- cog_spending(years = 2019, state = "RI", type = "city")
expect_gt(nrow(r), 0L)
# And the FIPS form is accepted as the same cohort.
expect_equal(nrow(cog_spending(years = 2019, state = "44", type = "city")),
nrow(r))
})
})
test_that("an unknown state abbreviation aborts with a message that names the problem", {
skip_if_no_corpus()
with_fixture_corpus({
# Regression: `.state_abbrev_to_fips` is a named character vector, so `[[`
# on an absent name threw base R's "subscript out of bounds" and the
# curated message was unreachable.
expect_error(cog_spending(years = 2019, state = "ZZ"),
class = "uscogdata_unknown_state")
expect_error(cog_spending(years = 2019, state = "ZZ"),
"Unknown state abbreviation")
})
})
# --- naming the cohort -----------------------------------------------------
test_that("naming no cohort at all is refused", {
skip_if_no_corpus()
with_fixture_corpus({
expect_error(cog_spending(years = 2019), class = "uscogdata_no_cohort")
expect_error(cog_revenue(years = 2019), class = "uscogdata_no_cohort")
expect_error(cog_balances(years = 2019), class = "uscogdata_no_cohort")
})
})
test_that("an empty govid vector still fails as it always did", {
skip_if_no_corpus()
with_fixture_corpus({
# Supplied-but-empty is a caller error, not "cohort named some other way".
expect_error(cog_spending(character(0), 2019),
"must be a non-empty character vector")
})
})
test_that("govid and state/type together intersect", {
skip_if_no_corpus()
with_fixture_corpus({
cities <- cog_gov_search(NULL, state = "RI", type = "city")$canonical_govid
counties <- cog_gov_search(NULL, state = "DE", type = "county")$canonical_govid
# The documented rule: the governments in `govid` that ALSO match the
# predicate -- never one silently taking precedence over the other.
both <- cog_spending(govid = c(cities, counties), years = 2019,
state = "RI", type = "city")
only <- cog_spending(govid = cities, years = 2019)
expect_equal(as.data.frame(both), as.data.frame(only), ignore_attr = TRUE)
# A disjoint intersection is empty, not "whichever one won".
none <- cog_spending(govid = counties, years = 2019,
state = "RI", type = "city")
expect_equal(nrow(none), 0L)
})
})
# --- provenance ------------------------------------------------------------
test_that("a govid cohort's provenance scope is untouched", {
skip_if_no_corpus()
with_fixture_corpus({
ids <- cog_gov_search(NULL, state = "DE", type = "county")$canonical_govid
prov <- attr(cog_spending(govid = ids, years = 2019), "provenance")
expect_setequal(prov$scope$govids_found, ids)
expect_length(prov$scope$govids_missing, 0L)
# No cohort block: govids_found already describes this cohort exactly.
expect_null(prov$scope$cohort)
})
})
test_that("a predicate cohort describes itself instead of listing ids", {
skip_if_no_corpus()
with_fixture_corpus({
prov <- attr(cog_spending(years = 2019, state = "DE", type = "county"),
"provenance")
# Deliberately NOT the resolved id list: a fleet-scale cohort would put
# 20,000 govids into every response body.
expect_length(prov$scope$govids_found, 0L)
expect_length(prov$scope$govids_missing, 0L)
expect_identical(prov$scope$cohort$state, "DE")
expect_identical(prov$scope$cohort$type, "county")
expect_identical(prov$scope$cohort$n_governments, 3L)
})
})
test_that("the cohort block counts the intersection, not the predicate alone", {
skip_if_no_corpus()
with_fixture_corpus({
counties <- cog_gov_search(NULL, state = "DE", type = "county")$canonical_govid
prov <- attr(
cog_spending(govid = counties[1], years = 2019, state = "DE", type = "county"),
"provenance"
)
expect_identical(prov$scope$cohort$n_governments, 1L)
})
})
# --- the SQL actually changed ----------------------------------------------
test_that("a predicate cohort never renders the ids into the query", {
skip_if_no_corpus()
with_fixture_corpus({
ids <- cog_gov_search(NULL, state = "RI", type = "city")$canonical_govid
prov <- attr(cog_spending(years = 2019, state = "RI", type = "city"),
"provenance")
sql <- prov$sql_query %||% prov$sql
skip_if(is.null(sql), "provenance carries no SQL for this verb")
# The whole point: cohort size does not enter the SQL string.
for (id in ids) expect_false(grepl(id, sql, fixed = TRUE))
expect_match(sql, "SELECT canonical_govid FROM canonical_fips_xwalk",
fixed = TRUE)
})
})
+83
View File
@@ -0,0 +1,83 @@
# A cohort is how every verb names the set of governments it queries. It can be
# named by explicit id, by a predicate over canonical_fips_xwalk, or by both
# (intersection). These tests cover the SQL construction itself -- pure string
# building, no corpus needed -- because that is where the postal/FIPS trap and
# the 40k-literal blowup both live.
test_that(".make_cohort() keeps an explicit id vector as ids", {
ch <- .make_cohort(govid = c("550000227544", "060000000001"))
expect_identical(ch$ids, c("550000227544", "060000000001"))
expect_null(ch$state_fips)
expect_null(ch$type_int)
expect_false(.cohort_by_predicate(ch))
})
test_that(".make_cohort() translates a postal abbreviation to FIPS", {
# The trap this whole issue exists to avoid: canonical_fips_xwalk.fips_state
# holds "55", not "WI". A predicate written against the raw parameter matches
# nothing and returns an empty result that reads as "reported nothing".
ch <- .make_cohort(state = "WI")
expect_identical(ch$state_fips, "55")
expect_true(.cohort_by_predicate(ch))
})
test_that(".make_cohort() translates a type label to its integer code", {
expect_identical(.make_cohort(type = "city")$type_int, 2L)
expect_identical(.make_cohort(type = "state")$type_int, 0L)
expect_identical(.make_cohort(type = 1)$type_int, 1L)
})
test_that(".make_cohort() reuses the search verb's coercers for invalid input", {
expect_error(.make_cohort(state = "ZZ"), "Unknown state abbreviation")
expect_error(.make_cohort(type = "special_district"), "Unknown type")
# Out-of-scope types (4 = special district, 5 = school district) are refused
# by the numeric branch, with the v0.1-scope message.
expect_error(.make_cohort(type = 4), "type must be 0, 1, 2, or 3")
})
test_that(".make_cohort() rejects naming no cohort at all", {
expect_error(.make_cohort(), class = "uscogdata_no_cohort")
})
test_that(".cohort_sql() renders an id cohort as a literal IN list", {
sql <- .cohort_sql(.make_cohort(govid = c("a", "b")))
expect_identical(sql, "canonical_govid IN ('a','b')")
})
test_that(".cohort_sql() renders a predicate cohort as an xwalk subquery", {
# The point of the issue: the cohort never becomes a literal list, so its
# size does not enter the SQL string at all.
sql <- .cohort_sql(.make_cohort(state = "WI", type = "city"))
expect_match(sql, "SELECT canonical_govid FROM canonical_fips_xwalk", fixed = TRUE)
expect_match(sql, "fips_state = '55'", fixed = TRUE)
expect_match(sql, "govs_type = 2", fixed = TRUE)
expect_false(grepl("'WI'", sql, fixed = TRUE))
})
test_that(".cohort_sql() renders ids and a predicate as an intersection", {
sql <- .cohort_sql(.make_cohort(govid = c("a", "b"), type = "city"))
expect_match(sql, "canonical_govid IN ('a','b')", fixed = TRUE)
expect_match(sql, "AND canonical_govid IN (SELECT", fixed = TRUE)
})
test_that(".cohort_sql() honours a column alias", {
# Several call sites join the xwalk under an alias (`l.`, `x.`, `v.`), so the
# predicate has to be able to name the qualified column.
sql <- .cohort_sql(.make_cohort(state = "WI"), col = "l.canonical_govid")
expect_match(sql, "l.canonical_govid IN (SELECT", fixed = TRUE)
# The subquery's own column stays unqualified -- it selects from the xwalk,
# not from the outer relation.
expect_match(sql, "SELECT canonical_govid FROM", fixed = TRUE)
})
test_that(".cohort_sql() escapes quotes in ids", {
sql <- .cohort_sql(.make_cohort(govid = "o'brien"))
expect_match(sql, "'o''brien'", fixed = TRUE)
})
test_that("a predicate cohort's SQL does not grow with cohort size", {
# The regression this guards: 20,106 ids rendered to a 301,591-character
# IN list, embedded in 5-8 statements per call.
wide <- .cohort_sql(.make_cohort(state = "CA", type = "city"))
expect_lt(nchar(wide), 200L)
})
+4 -4
View File
@@ -251,7 +251,7 @@ test_that(".suppressed_components measures the E67/E68 dollars Public Welfare dr
s <- uscogdata:::.suppressed_components( s <- uscogdata:::.suppressed_components(
con, con,
candidates = c("welfare_cash_e67_wide", "welfare_cash_e68_wide"), candidates = c("welfare_cash_e67_wide", "welfare_cash_e68_wide"),
govid = "061037123085", years = 2011L, cohort = uscogdata:::.make_cohort("061037123085"), years = 2011L,
long_view = "spending_long_harmonized", long_view = "spending_long_harmonized",
flow_prefixes = c("E", "F", "G")) flow_prefixes = c("E", "F", "G"))
@@ -269,7 +269,7 @@ test_that(".suppressed_components finds nothing in a modern year", {
s <- uscogdata:::.suppressed_components( s <- uscogdata:::.suppressed_components(
con, con,
candidates = c("welfare_cash_e67_wide", "welfare_cash_e68_wide"), candidates = c("welfare_cash_e67_wide", "welfare_cash_e68_wide"),
govid = "061037123085", years = 2019L, cohort = uscogdata:::.make_cohort("061037123085"), years = 2019L,
long_view = "spending_long_harmonized", long_view = "spending_long_harmonized",
flow_prefixes = c("E", "F", "G")) flow_prefixes = c("E", "F", "G"))
expect_equal(nrow(s), 0L) expect_equal(nrow(s), 0L)
@@ -280,7 +280,7 @@ test_that(".suppressed_components rejects a long_view outside the allowlist", {
con <- uscogdata:::.ensure_session() con <- uscogdata:::.ensure_session()
expect_error( expect_error(
uscogdata:::.suppressed_components( uscogdata:::.suppressed_components(
con, candidates = "welfare_cash_e67_wide", govid = "061037123085", con, candidates = "welfare_cash_e67_wide", cohort = uscogdata:::.make_cohort("061037123085"),
years = 2011L, long_view = "long; DROP TABLE x", years = 2011L, long_view = "long; DROP TABLE x",
flow_prefixes = c("E", "F", "G")), flow_prefixes = c("E", "F", "G")),
class = "uscogdata_internal_error") class = "uscogdata_internal_error")
@@ -298,7 +298,7 @@ test_that(".suppressed_components never measures a component from the other flow
s <- uscogdata:::.suppressed_components( s <- uscogdata:::.suppressed_components(
con, con,
candidates = c("welfare_cash_e67_wide", "welfare_cash_e68_wide"), candidates = c("welfare_cash_e67_wide", "welfare_cash_e68_wide"),
govid = "061037123085", years = 2011L, cohort = uscogdata:::.make_cohort("061037123085"), years = 2011L,
long_view = "revenue_long_harmonized", long_view = "revenue_long_harmonized",
flow_prefixes = c("T", "A", "U", "B", "C", "D")) flow_prefixes = c("T", "A", "U", "B", "C", "D"))
expect_equal(nrow(s), 0L) expect_equal(nrow(s), 0L)