15-task TDD plan covering: gov_population_yearly view, .attach_per_capita per-year join, pop_source column + multi-note concatenation, cog_find_peers year arg, cog_peer_compare cohort_year, rollup unavailable-pop exclusion, provenance updates, cog_explain rendering, vignette, cog_pipeline data dictionary, and NEWS entry. Also corrects spec to match existing rollup semantics (side-by-side, not summed) and adds /plans to .Rbuildignore.
1399 lines
49 KiB
Markdown
1399 lines
49 KiB
Markdown
# Per-year population denominator implementation plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Replace `cog_spending(per_capita = TRUE)` / `cog_revenue(per_capita = TRUE)` / `cog_geographic_rollup()` static-ACS denominator with the per-year Census F-33 population already present in `long.population`. Switch peer matching to a user-selectable cohort year. Add `pop_source` column, multi-note concatenation, updated provenance, vignette, and pipeline data dictionary entry.
|
||
|
||
**Architecture:** A new `gov_population_yearly` DuckDB view exposes `(year, canonical_govid, population, popyear)` from `long`. `.attach_per_capita()` joins on `(canonical_govid, year)` instead of querying the static `canonical_fips_xwalk.population_acs`. `cog_find_peers()` queries the new view at a chosen year (default = most recent observed year for the target). `cog_geographic_rollup()` sums population across observed govs only. Type-4 (special districts) and type-5 (school districts) govs return `NA` per-capita with `pop_source = "unavailable"` because the F-33 schema masks population for those types.
|
||
|
||
**Tech Stack:** R (DuckDB via DBI), testthat 3, roxygen2, pkgdown, tibble/dplyr, cli.
|
||
|
||
**Spec:** `specs/2026-04-29-per-year-population-denominator-design.md`
|
||
|
||
**Coverage assumption (fixture):** The bundled `inst/extdata/fixture_corpus` covers years 2019 and 2020 across all 50 states for types 0–3. It contains no type-4 or type-5 govs, so unavailability tests rely on querying govids absent from `gov_population_yearly` rather than on type filters. A list of fixture govids used in tests below: Alabama state `010000000` (pop 4,874,747 → 4,903,185), Broward County `101006006` (1,935,878 → 1,952,778), Wayne County `231082082`, Bexar County `441015015`, Tarrant County `441220220`. Static ACS values: Alabama `5,028,092`, Broward `1,940,907`.
|
||
|
||
---
|
||
|
||
## Task 1: Add `gov_population_yearly` view
|
||
|
||
**Files:**
|
||
- Create: `inst/sql/32-gov_population_yearly.sql`
|
||
- Modify: `tests/testthat/test-views.R` (append a test)
|
||
|
||
- [ ] **Step 1: Read existing `test-views.R` to learn the pattern**
|
||
|
||
```bash
|
||
cat tests/testthat/test-views.R
|
||
```
|
||
|
||
Note the pattern: tests open `with_fixture_corpus({ ... })`, then run a `DBI::dbGetQuery()` against `.ensure_session()` to verify a view exists and returns expected columns.
|
||
|
||
- [ ] **Step 2: Write the failing test**
|
||
|
||
Append to `tests/testthat/test-views.R`:
|
||
|
||
```r
|
||
test_that("gov_population_yearly exposes one row per (year, canonical_govid)", {
|
||
skip_if_no_corpus()
|
||
with_fixture_corpus({
|
||
con <- uscogdata:::.ensure_session()
|
||
df <- DBI::dbGetQuery(
|
||
con,
|
||
"SELECT year, canonical_govid, population, popyear
|
||
FROM gov_population_yearly
|
||
WHERE canonical_govid = '101006006'
|
||
ORDER BY year"
|
||
)
|
||
expect_setequal(df$year, c(2019L, 2020L))
|
||
expect_equal(nrow(df), 2L)
|
||
expect_true(all(!is.na(df$population)))
|
||
expect_equal(df$population[df$year == 2019L], 1935878L)
|
||
expect_equal(df$population[df$year == 2020L], 1952778L)
|
||
# Uniqueness on (year, canonical_govid) across the whole view.
|
||
dup <- DBI::dbGetQuery(
|
||
con,
|
||
"SELECT year, canonical_govid, COUNT(*) AS n
|
||
FROM gov_population_yearly
|
||
GROUP BY year, canonical_govid HAVING n > 1"
|
||
)
|
||
expect_equal(nrow(dup), 0L)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 3: Run the test to verify it fails**
|
||
|
||
```bash
|
||
Rscript -e 'devtools::test(filter = "views")'
|
||
```
|
||
|
||
Expected: FAIL — `gov_population_yearly` view does not exist (DuckDB binder error).
|
||
|
||
- [ ] **Step 4: Create the SQL view file**
|
||
|
||
Write `inst/sql/32-gov_population_yearly.sql`:
|
||
|
||
```sql
|
||
CREATE OR REPLACE VIEW gov_population_yearly AS
|
||
SELECT DISTINCT
|
||
year,
|
||
canonical_govid,
|
||
population,
|
||
popyear
|
||
FROM long
|
||
WHERE population IS NOT NULL;
|
||
```
|
||
|
||
The numeric prefix `32` slots between the existing `30-canonical_fips_xwalk.sql` and `40-spending_annotated.sql` so it loads before any annotated views that might depend on it.
|
||
|
||
- [ ] **Step 5: Re-run the test to verify it passes**
|
||
|
||
```bash
|
||
Rscript -e 'devtools::test(filter = "views")'
|
||
```
|
||
|
||
Expected: PASS for the new test plus the existing view tests.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add inst/sql/32-gov_population_yearly.sql tests/testthat/test-views.R
|
||
git commit -m "feat(sql): add gov_population_yearly view
|
||
|
||
Exposes one row per (year, canonical_govid) drawn from long.population.
|
||
Used by per-capita denominators and peer matching."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Per-year denominator in `.attach_per_capita` (RED)
|
||
|
||
Write the failing test first; implement in Task 3.
|
||
|
||
**Files:**
|
||
- Modify: `tests/testthat/test-spending.R` (append)
|
||
|
||
- [ ] **Step 1: Read existing `test-spending.R` patterns**
|
||
|
||
```bash
|
||
head -80 tests/testthat/test-spending.R
|
||
```
|
||
|
||
- [ ] **Step 2: Write the failing test**
|
||
|
||
Append to `tests/testthat/test-spending.R`:
|
||
|
||
```r
|
||
test_that("per_capita denominator is the per-year F-33 population", {
|
||
skip_if_no_corpus()
|
||
with_fixture_corpus({
|
||
r <- cog_spending("101006006", years = 2019:2020,
|
||
category = "Police", per_capita = TRUE)
|
||
r_ops <- r[r$spend_subtype == "operations", ]
|
||
# Implied denominator from amt_nominal / amt_per_capita_nominal
|
||
implied_pop <- r_ops$amt_nominal / r_ops$amt_per_capita_nominal
|
||
names(implied_pop) <- r_ops$year
|
||
expect_equal(implied_pop[["2019"]], 1935878, tolerance = 1)
|
||
expect_equal(implied_pop[["2020"]], 1952778, tolerance = 1)
|
||
# And the implied denominator does NOT equal the static ACS value
|
||
expect_false(all(abs(implied_pop - 1940907) < 1))
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 3: Run test to verify it fails**
|
||
|
||
```bash
|
||
Rscript -e 'devtools::test(filter = "spending")'
|
||
```
|
||
|
||
Expected: FAIL — implied denominator equals 1,940,907 (static ACS) for both years.
|
||
|
||
- [ ] **Step 4: Commit the failing test**
|
||
|
||
```bash
|
||
git add tests/testthat/test-spending.R
|
||
git commit -m "test(spending): per-year denominator expectation (failing)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Rewrite `.attach_per_capita` to join on (canonical_govid, year) (GREEN)
|
||
|
||
**Files:**
|
||
- Modify: `R/spending.R:143-159`
|
||
|
||
- [ ] **Step 1: Read current `.attach_per_capita`**
|
||
|
||
```bash
|
||
sed -n '143,160p' R/spending.R
|
||
```
|
||
|
||
- [ ] **Step 2: Replace `.attach_per_capita` with per-year join**
|
||
|
||
Edit `R/spending.R`. Replace the existing function body (lines 143–159) with:
|
||
|
||
```r
|
||
#' @noRd
|
||
.attach_per_capita <- function(result, con, govid) {
|
||
if (nrow(result) == 0L) {
|
||
result$amt_per_capita_nominal <- numeric(0)
|
||
result$pop_source <- character(0)
|
||
return(result)
|
||
}
|
||
years_lit <- paste(unique(as.integer(result$year)), collapse = ",")
|
||
sql <- sprintf(
|
||
"SELECT canonical_govid, year, population
|
||
FROM gov_population_yearly
|
||
WHERE canonical_govid IN (%s)
|
||
AND year IN (%s)",
|
||
.sql_lit_chr(govid), years_lit
|
||
)
|
||
pops <- tibble::as_tibble(DBI::dbGetQuery(con, sql))
|
||
result <- dplyr::left_join(result, pops,
|
||
by = c("canonical_govid", "year"))
|
||
result$amt_per_capita_nominal <- result$amt_nominal / result$population
|
||
result$pop_source <- ifelse(is.na(result$population),
|
||
"unavailable", "census_f33")
|
||
result$population <- NULL
|
||
result
|
||
}
|
||
```
|
||
|
||
Key changes vs. the prior implementation:
|
||
- Joins on `(canonical_govid, year)` instead of `canonical_govid` alone.
|
||
- Queries `gov_population_yearly` instead of `canonical_fips_xwalk`.
|
||
- Adds `pop_source` column (`"census_f33"` or `"unavailable"`).
|
||
- `amt_per_capita_nominal` is naturally NA when `population` is NA (R's `NA / x = NA`).
|
||
|
||
- [ ] **Step 3: Run the failing test from Task 2 to verify it passes**
|
||
|
||
```bash
|
||
Rscript -e 'devtools::test(filter = "spending")'
|
||
```
|
||
|
||
Expected: PASS for the new test. Existing per-capita tests in `test-spending.R` may now fail because they were written against the static-ACS denominator. Inspect each failure — most should be updated to assert the per-year implied denominator. Tests that asserted *equality* of per-capita across years for the same gov are no longer correct expectations.
|
||
|
||
- [ ] **Step 4: Update any existing per-capita tests in `test-spending.R` that fail**
|
||
|
||
Read each failing test. If it merely asserted `amt_per_capita_nominal` is positive/finite, no change needed. If it asserted a specific numeric value derived from `population_acs`, recompute the expected value using the per-year `population` for that gov-year. If it asserted `amt_per_capita` is the same across two years, change it to assert that the per-year implied denominator matches `gov_population_yearly`.
|
||
|
||
- [ ] **Step 5: Run the full spending test file**
|
||
|
||
```bash
|
||
Rscript -e 'devtools::test(filter = "spending")'
|
||
```
|
||
|
||
Expected: ALL PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add R/spending.R tests/testthat/test-spending.R
|
||
git commit -m "feat(per-capita): use per-year F-33 population in spending verbs
|
||
|
||
cog_spending(per_capita = TRUE) and cog_revenue(per_capita = TRUE) now
|
||
divide each year's amount by that gov-year's population from
|
||
gov_population_yearly (drawn from long.population) instead of a single
|
||
static ACS 2018-2022 value. Adds pop_source column with values
|
||
'census_f33' or 'unavailable'."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Multi-note concatenation + unavailable-pop note
|
||
|
||
**Files:**
|
||
- Modify: `R/spending.R:177-185` (`.notes_column`)
|
||
- Modify: `R/spending.R:42-80` (`.verb_spendrev`) to call `.notes_column` after per-capita is attached
|
||
- Modify: `tests/testthat/test-spending.R` (append)
|
||
|
||
- [ ] **Step 1: Read current `.notes_column`**
|
||
|
||
```bash
|
||
sed -n '177,190p' R/spending.R
|
||
```
|
||
|
||
The current implementation builds a one-element note ("Aggregate fallback applied; see cog_explain()" or "") off `aggregate_fallback`. After this task it concatenates multiple sources of notes, joined by `"; "`.
|
||
|
||
- [ ] **Step 2: Write a failing test**
|
||
|
||
Append to `tests/testthat/test-spending.R`:
|
||
|
||
```r
|
||
test_that("pop_source = 'unavailable' produces a note and NA per-capita", {
|
||
skip_if_no_corpus()
|
||
with_fixture_corpus({
|
||
# No type-4/5 govs in fixture; use a govid present in long but synthetically
|
||
# absent from gov_population_yearly by querying a year out of fixture range.
|
||
# Better: query a govid that doesn't exist anywhere — cog_spending will
|
||
# return zero rows. Use a real govid in 2019 with per_capita to confirm
|
||
# the no-NA branch works, then test the NA branch with a manual round-trip:
|
||
r <- cog_spending("101006006", years = 2019L,
|
||
category = "Police", per_capita = TRUE)
|
||
expect_true(all(r$pop_source == "census_f33"))
|
||
expect_true(all(is.na(r$notes) | r$notes == "" |
|
||
!grepl("No population denominator", r$notes)))
|
||
})
|
||
})
|
||
|
||
test_that("aggregate fallback + unavailable pop produce concatenated notes", {
|
||
# Unit-level test of .notes_column with a synthetic data frame so we don't
|
||
# depend on having a type-4/5 gov in the fixture.
|
||
result <- tibble::tibble(
|
||
aggregate_fallback = c(FALSE, TRUE, TRUE),
|
||
pop_source = c("census_f33", "census_f33", "unavailable")
|
||
)
|
||
notes <- uscogdata:::.notes_column(result)
|
||
expect_equal(notes[1], "")
|
||
expect_equal(notes[2], "Aggregate fallback applied; see cog_explain()")
|
||
expect_equal(notes[3],
|
||
"Aggregate fallback applied; see cog_explain(); No population denominator available for this gov type")
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 3: Run test to verify the second one fails**
|
||
|
||
```bash
|
||
Rscript -e 'devtools::test(filter = "spending")'
|
||
```
|
||
|
||
Expected: FAIL — `.notes_column` does not yet handle `pop_source`.
|
||
|
||
- [ ] **Step 4: Rewrite `.notes_column`**
|
||
|
||
Replace `.notes_column` in `R/spending.R` with:
|
||
|
||
```r
|
||
#' @noRd
|
||
.notes_column <- function(result) {
|
||
n <- nrow(result)
|
||
if (n == 0L) return(character(0))
|
||
parts <- vector("list", 2L)
|
||
agg <- result[["aggregate_fallback"]]
|
||
parts[[1]] <- ifelse(
|
||
!is.null(agg) & isTRUE(any(agg, na.rm = TRUE)) & agg %in% TRUE,
|
||
"Aggregate fallback applied; see cog_explain()",
|
||
NA_character_
|
||
)
|
||
ps <- result[["pop_source"]]
|
||
parts[[2]] <- if (!is.null(ps)) {
|
||
ifelse(ps == "unavailable",
|
||
"No population denominator available for this gov type",
|
||
NA_character_)
|
||
} else {
|
||
rep(NA_character_, n)
|
||
}
|
||
out <- character(n)
|
||
for (i in seq_len(n)) {
|
||
pieces <- vapply(parts, `[[`, character(1), i)
|
||
pieces <- pieces[!is.na(pieces)]
|
||
out[i] <- if (length(pieces) == 0L) "" else paste(pieces, collapse = "; ")
|
||
}
|
||
out
|
||
}
|
||
```
|
||
|
||
The per-row loop is unavoidable in base R for this exact join semantics; the result tibble is small (one row per year × govid × subtype × category) so this is fine.
|
||
|
||
- [ ] **Step 5: Verify `.notes_column` is called after `.attach_per_capita`**
|
||
|
||
Read `.verb_spendrev` (`R/spending.R:42-80`). The existing flow is:
|
||
|
||
```
|
||
sql -> result
|
||
if (per_capita) result <- .attach_per_capita(result, con, govid)
|
||
if (!is.null(adjust_to_year)) result <- .attach_real_dollars(...)
|
||
result$notes <- .notes_column(result)
|
||
```
|
||
|
||
`.notes_column` is already invoked after per-capita attachment, so no change to `.verb_spendrev` is required. Verify with:
|
||
|
||
```bash
|
||
sed -n '54,63p' R/spending.R
|
||
```
|
||
|
||
Expected output: shows `.notes_column(result)` on a line after the per-capita block.
|
||
|
||
- [ ] **Step 6: Run tests**
|
||
|
||
```bash
|
||
Rscript -e 'devtools::test(filter = "spending")'
|
||
```
|
||
|
||
Expected: ALL PASS.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add R/spending.R tests/testthat/test-spending.R
|
||
git commit -m "feat(notes): concatenate notes; flag unavailable population
|
||
|
||
.notes_column now joins multiple per-row notes with '; '. Adds the
|
||
'No population denominator available for this gov type' note when
|
||
pop_source is 'unavailable'."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: `cog_find_peers()` per-year (RED)
|
||
|
||
**Files:**
|
||
- Modify: `tests/testthat/test-peers.R` (append)
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
Append to `tests/testthat/test-peers.R`:
|
||
|
||
```r
|
||
test_that("cog_find_peers defaults `year` to most recent observed year for target", {
|
||
skip_if_no_corpus()
|
||
peers <- cog_find_peers("101006006")
|
||
expect_equal(attr(peers, "cohort_year"), 2020L)
|
||
# Returned column is now `population`, not `population_acs`
|
||
expect_true("population" %in% names(peers))
|
||
expect_false("population_acs" %in% names(peers))
|
||
})
|
||
|
||
test_that("cog_find_peers honors an explicit `year`", {
|
||
skip_if_no_corpus()
|
||
peers <- cog_find_peers("101006006", year = 2019L)
|
||
expect_equal(attr(peers, "cohort_year"), 2019L)
|
||
})
|
||
|
||
test_that("cog_find_peers errors when target has no observed pop in `year`", {
|
||
skip_if_no_corpus()
|
||
expect_error(
|
||
cog_find_peers("101006006", year = 1999L),
|
||
"no observed population"
|
||
)
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
```bash
|
||
Rscript -e 'devtools::test(filter = "peers")'
|
||
```
|
||
|
||
Expected: FAIL — function doesn't accept `year` arg, returns `population_acs` column.
|
||
|
||
- [ ] **Step 3: Commit failing tests**
|
||
|
||
```bash
|
||
git add tests/testthat/test-peers.R
|
||
git commit -m "test(peers): per-year cohort expectations (failing)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: `cog_find_peers()` per-year (GREEN)
|
||
|
||
**Files:**
|
||
- Modify: `R/peers.R:24-88`
|
||
|
||
- [ ] **Step 1: Replace `cog_find_peers` body**
|
||
|
||
Replace the entire `cog_find_peers` function in `R/peers.R` with:
|
||
|
||
```r
|
||
#' Find peer governments by similarity criteria
|
||
#'
|
||
#' Selects peer governments by combinations of government type, state, and
|
||
#' population range at a chosen `year`. Peers are ordered by `|log(pop_ratio)|`
|
||
#' ascending (closest to the target's population first).
|
||
#'
|
||
#' @param target_govid Character scalar — `canonical_govid` of the target.
|
||
#' @param year Integer scalar. Cohort vintage. When `NULL` (default), uses the
|
||
#' most recent year for which the target has an observed population in
|
||
#' `gov_population_yearly`.
|
||
#' @param same_type If `TRUE` (default) restrict peers to the target's
|
||
#' `govs_type`.
|
||
#' @param same_state If `TRUE` restrict peers to the target's `fips_state`.
|
||
#' Default `FALSE`.
|
||
#' @param pop_range Length-2 numeric vector giving lower/upper bounds.
|
||
#' @param is_ratio If `TRUE` (default) `pop_range` is multiplied by the
|
||
#' target's population at `year` to produce absolute bounds. If `FALSE`,
|
||
#' `pop_range` is interpreted as absolute population counts.
|
||
#' @param max_peers Integer cap on the number of peers returned.
|
||
#' @return Tibble with columns `canonical_govid`, `gov_name`, `fips_state`,
|
||
#' `population`, `pop_ratio`, `rank`. The cohort year is attached as
|
||
#' `attr(x, "cohort_year")`.
|
||
#' @export
|
||
cog_find_peers <- function(target_govid,
|
||
year = NULL,
|
||
same_type = TRUE,
|
||
same_state = FALSE,
|
||
pop_range = c(0.7, 1.3),
|
||
is_ratio = TRUE,
|
||
max_peers = 10L) {
|
||
if (!is.character(target_govid) || length(target_govid) != 1L) {
|
||
cli::cli_abort("`target_govid` must be a length-1 character string.")
|
||
}
|
||
if (!is.numeric(pop_range) || length(pop_range) != 2L ||
|
||
pop_range[1] >= pop_range[2]) {
|
||
cli::cli_abort("`pop_range` must be a length-2 numeric with lo < hi.")
|
||
}
|
||
if (!is.null(year) &&
|
||
(!(is.numeric(year) || is.integer(year)) || length(year) != 1L)) {
|
||
cli::cli_abort("`year` must be NULL or a length-1 integer.")
|
||
}
|
||
|
||
con <- .ensure_session()
|
||
|
||
# Confirm target exists in the xwalk and pull govs_type / fips_state.
|
||
meta_sql <- sprintf(
|
||
"SELECT canonical_govid, gov_name, govs_type, fips_state
|
||
FROM canonical_fips_xwalk
|
||
WHERE canonical_govid = %s",
|
||
.sql_lit_chr(target_govid)
|
||
)
|
||
meta <- DBI::dbGetQuery(con, meta_sql)
|
||
if (nrow(meta) == 0L) {
|
||
cli::cli_abort(c(
|
||
"govid {target_govid} not found in corpus.",
|
||
i = "v0.1 covers types 0-3 only (state/county/city/township); see vignette('coverage-scope')."
|
||
))
|
||
}
|
||
|
||
cohort_year <- .resolve_cohort_year(con, target_govid, year)
|
||
|
||
pop_sql <- sprintf(
|
||
"SELECT population FROM gov_population_yearly
|
||
WHERE canonical_govid = %s AND year = %d",
|
||
.sql_lit_chr(target_govid), as.integer(cohort_year)
|
||
)
|
||
target_pop <- DBI::dbGetQuery(con, pop_sql)$population
|
||
if (length(target_pop) == 0L || is.na(target_pop) || target_pop <= 0) {
|
||
cli::cli_abort(c(
|
||
"Target {target_govid} has no observed population in {cohort_year}.",
|
||
i = "Use a year for which population is observed; see gov_population_yearly."
|
||
))
|
||
}
|
||
|
||
if (isTRUE(is_ratio)) {
|
||
lo <- target_pop * pop_range[1]
|
||
hi <- target_pop * pop_range[2]
|
||
} else {
|
||
lo <- pop_range[1]; hi <- pop_range[2]
|
||
}
|
||
|
||
preds <- c(
|
||
sprintf("p.canonical_govid != %s", .sql_lit_chr(target_govid)),
|
||
sprintf("p.year = %d", as.integer(cohort_year)),
|
||
sprintf("p.population BETWEEN %.6f AND %.6f", lo, hi)
|
||
)
|
||
if (isTRUE(same_type)) preds <- c(preds, sprintf("x.govs_type = %d", meta$govs_type))
|
||
if (isTRUE(same_state)) preds <- c(preds, sprintf("x.fips_state = %s", .sql_lit_chr(meta$fips_state)))
|
||
|
||
peers_sql <- sprintf(
|
||
"SELECT p.canonical_govid, x.gov_name, x.fips_state, p.population,
|
||
p.population / %.6f AS pop_ratio
|
||
FROM gov_population_yearly p
|
||
JOIN canonical_fips_xwalk x USING (canonical_govid)
|
||
WHERE %s
|
||
ORDER BY ABS(LN(CAST(p.population AS DOUBLE) / %.6f))
|
||
LIMIT %d",
|
||
target_pop,
|
||
paste(preds, collapse = " AND "),
|
||
target_pop,
|
||
as.integer(max_peers)
|
||
)
|
||
peers <- tibble::as_tibble(DBI::dbGetQuery(con, peers_sql))
|
||
peers$rank <- if (nrow(peers) > 0L) seq_len(nrow(peers)) else integer(0)
|
||
attr(peers, "cohort_year") <- as.integer(cohort_year)
|
||
peers
|
||
}
|
||
|
||
#' @noRd
|
||
.resolve_cohort_year <- function(con, target_govid, year) {
|
||
if (!is.null(year)) return(as.integer(year))
|
||
sql <- sprintf(
|
||
"SELECT MAX(year) AS y FROM gov_population_yearly
|
||
WHERE canonical_govid = %s",
|
||
.sql_lit_chr(target_govid)
|
||
)
|
||
y <- DBI::dbGetQuery(con, sql)$y
|
||
if (length(y) == 0L || is.na(y)) {
|
||
cli::cli_abort(
|
||
"Target {target_govid} has no observed population in any year."
|
||
)
|
||
}
|
||
as.integer(y)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run failing tests from Task 5**
|
||
|
||
```bash
|
||
Rscript -e 'devtools::test(filter = "peers")'
|
||
```
|
||
|
||
Expected: PASS for the three new tests. Existing tests in `test-peers.R` may fail because they reference `population_acs` — fix in next step.
|
||
|
||
- [ ] **Step 3: Update existing `test-peers.R` assertions**
|
||
|
||
The existing tests that reference `population_acs` need column name and value updates. Specifically lines that check `expected_cols` and `peers$population_acs`:
|
||
|
||
```r
|
||
# In test "cog_find_peers returns same-type peers in the default pop band":
|
||
expected_cols <- c("canonical_govid", "gov_name", "fips_state",
|
||
"population", "pop_ratio", "rank")
|
||
|
||
# In test "cog_find_peers absolute pop range works":
|
||
expect_true(all(peers$population >= 1.5e6 &
|
||
peers$population <= 2.5e6))
|
||
```
|
||
|
||
Run `grep -n population_acs tests/testthat/test-peers.R` to find every instance and rename to `population`.
|
||
|
||
- [ ] **Step 4: Run all peer tests**
|
||
|
||
```bash
|
||
Rscript -e 'devtools::test(filter = "peers")'
|
||
```
|
||
|
||
Expected: ALL PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add R/peers.R tests/testthat/test-peers.R
|
||
git commit -m "feat(peers): cog_find_peers uses per-year population
|
||
|
||
Adds optional 'year' argument (defaults to most recent observed year for
|
||
the target). Filters and ranks candidates by gov_population_yearly.population
|
||
at that year. Returned column renamed population_acs -> population.
|
||
Cohort year attached as attr(x, 'cohort_year')."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: `cog_peer_compare()` cohort_year column
|
||
|
||
**Files:**
|
||
- Modify: `R/peers.R:112-146`
|
||
- Modify: `tests/testthat/test-peers.R` (append)
|
||
|
||
- [ ] **Step 1: Write failing test**
|
||
|
||
Append to `tests/testthat/test-peers.R`:
|
||
|
||
```r
|
||
test_that("cog_peer_compare stamps cohort_year from peers attribute", {
|
||
skip_if_no_corpus()
|
||
peers <- cog_find_peers("101006006", year = 2019L, max_peers = 4L)
|
||
r <- cog_peer_compare("101006006", peers, "Police", years = 2020L)
|
||
expect_true("cohort_year" %in% names(r))
|
||
expect_true(all(r$cohort_year == 2019L))
|
||
prov <- attr(r, "provenance")
|
||
expect_equal(prov$cohort_year, 2019L)
|
||
})
|
||
|
||
test_that("cog_peer_compare cohort_year is NA for bare character peers", {
|
||
skip_if_no_corpus()
|
||
r <- cog_peer_compare(
|
||
"101006006",
|
||
peers = c("441015015", "441220220"),
|
||
category = "Police", years = 2020L
|
||
)
|
||
expect_true(all(is.na(r$cohort_year)))
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify failure**
|
||
|
||
```bash
|
||
Rscript -e 'devtools::test(filter = "peers")'
|
||
```
|
||
|
||
Expected: FAIL — `cohort_year` column does not exist.
|
||
|
||
- [ ] **Step 3: Update `cog_peer_compare`**
|
||
|
||
In `R/peers.R`, edit `cog_peer_compare`. Just before the existing `attr(out, "provenance") <- prov` line, add the cohort_year derivation and column stamp; and inside the provenance block add the `cohort_year` field.
|
||
|
||
Replace the section from `peer_govids <- if (...)` through the final return with:
|
||
|
||
```r
|
||
cohort_year <- if (is.data.frame(peers)) {
|
||
ay <- attr(peers, "cohort_year")
|
||
if (is.null(ay)) NA_integer_ else as.integer(ay)
|
||
} else {
|
||
NA_integer_
|
||
}
|
||
peer_govids <- if (is.data.frame(peers)) {
|
||
as.character(peers$canonical_govid)
|
||
} else {
|
||
as.character(peers)
|
||
}
|
||
peer_govids <- peer_govids[!is.na(peer_govids) & nzchar(peer_govids)]
|
||
all_govids <- unique(c(target_govid, peer_govids))
|
||
|
||
r <- cog_spending(all_govids, years, category, per_capita, adjust_to_year)
|
||
r$role <- ifelse(r$canonical_govid == target_govid, "target", "peer")
|
||
|
||
value_col <- .peer_value_col(per_capita, adjust_to_year)
|
||
|
||
summary_rows <- .peer_summary_rows(r, value_col)
|
||
out <- dplyr::bind_rows(r, summary_rows)
|
||
rank_val <- .peer_target_rank(r, target_govid, years, value_col)
|
||
out$target_rank <- ifelse(out$role == "target", rank_val, NA_integer_)
|
||
out$cohort_year <- cohort_year
|
||
|
||
prov <- attr(r, "provenance") %||% list()
|
||
prov$verb <- "cog_peer_compare"
|
||
prov$call <- paste(deparse(call), collapse = " ")
|
||
prov$peer_count <- length(peer_govids)
|
||
prov$cohort_year <- cohort_year
|
||
prov$cohort_govids <- peer_govids
|
||
prov$target <- list(
|
||
canonical_govid = target_govid,
|
||
gov_name = unique(r$gov_name[r$role == "target"])
|
||
)
|
||
attr(out, "provenance") <- prov
|
||
out
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests**
|
||
|
||
```bash
|
||
Rscript -e 'devtools::test(filter = "peers")'
|
||
```
|
||
|
||
Expected: ALL PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add R/peers.R tests/testthat/test-peers.R
|
||
git commit -m "feat(peers): stamp cohort_year on cog_peer_compare results
|
||
|
||
Reads attr(peers, 'cohort_year') when the caller passed a cog_find_peers()
|
||
tibble; NA when the caller passed a bare character vector. Stamped as a
|
||
constant column on the result and recorded in provenance alongside the
|
||
cohort govids."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Population-aware `cog_geographic_rollup()` (RED)
|
||
|
||
**Files:**
|
||
- Modify: `tests/testthat/test-rollup.R` (append)
|
||
|
||
- [ ] **Step 1: Read existing rollup test patterns**
|
||
|
||
```bash
|
||
head -60 tests/testthat/test-rollup.R
|
||
```
|
||
|
||
- [ ] **Step 2: Write failing test**
|
||
|
||
Append to `tests/testthat/test-rollup.R`:
|
||
|
||
```r
|
||
test_that("cog_geographic_rollup per-capita uses summed per-year populations", {
|
||
skip_if_no_corpus()
|
||
with_fixture_corpus({
|
||
r <- cog_geographic_rollup(
|
||
govids = list(state = "010000000",
|
||
county = "101006006"),
|
||
category = "Police",
|
||
years = 2019:2020,
|
||
per_capita = TRUE
|
||
)
|
||
state_ops <- r[r$layer == "state" & r$spend_subtype == "operations", ]
|
||
county_ops <- r[r$layer == "county" & r$spend_subtype == "operations", ]
|
||
state_implied <- state_ops$amt_nominal / state_ops$amt_per_capita_nominal
|
||
county_implied <- county_ops$amt_nominal /
|
||
county_ops$amt_per_capita_nominal
|
||
# Per-year, per-layer denominator is the layer's own per-year population
|
||
expect_equal(state_implied[state_ops$year == 2019], 4874747, tolerance = 1)
|
||
expect_equal(state_implied[state_ops$year == 2020], 4903185, tolerance = 1)
|
||
expect_equal(county_implied[county_ops$year == 2019], 1935878, tolerance = 1)
|
||
})
|
||
})
|
||
|
||
test_that("cog_geographic_rollup records included/excluded govids in provenance", {
|
||
skip_if_no_corpus()
|
||
with_fixture_corpus({
|
||
r <- cog_geographic_rollup(
|
||
govids = list(county = "101006006"),
|
||
category = "Police",
|
||
years = 2019:2020,
|
||
per_capita = TRUE
|
||
)
|
||
prov <- attr(r, "provenance")
|
||
expect_true("rollup" %in% names(prov))
|
||
expect_true("101006006" %in% prov$rollup$included_govids)
|
||
expect_true(is.character(prov$rollup$excluded_govids))
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 3: Run to verify failure**
|
||
|
||
```bash
|
||
Rscript -e 'devtools::test(filter = "rollup")'
|
||
```
|
||
|
||
Expected: the second test fails (no `rollup` block in provenance). The first may pass already because rollup currently delegates to `cog_spending` and the per-capita is per-row — verify.
|
||
|
||
- [ ] **Step 4: Commit failing tests**
|
||
|
||
```bash
|
||
git add tests/testthat/test-rollup.R
|
||
git commit -m "test(rollup): per-year denominator + provenance expectations (failing)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Population-aware `cog_geographic_rollup()` (GREEN)
|
||
|
||
**Files:**
|
||
- Modify: `R/rollup.R:26-57`
|
||
|
||
The rollup currently passes through to `cog_spending()` and returns one row per `(year, canonical_govid, subtype, category)` tagged with its layer — a side-by-side comparison, not a summed total. The spec preserves that semantics.
|
||
|
||
The GREEN step:
|
||
- After `cog_spending(...)` returns, drop rows with `pop_source == "unavailable"` *only when `per_capita = TRUE`*.
|
||
- Record included/excluded govids in provenance.
|
||
- Document the rule in roxygen.
|
||
|
||
- [ ] **Step 1: Replace `cog_geographic_rollup`**
|
||
|
||
Edit `R/rollup.R`. Replace the existing function with:
|
||
|
||
```r
|
||
#' Aggregate spending across state/county/city layers for a place
|
||
#'
|
||
#' Wraps [cog_spending()], tags each row with its layer, and attaches a
|
||
#' human-readable `scope_note` documenting geographic-scope caveats (e.g.
|
||
#' "county totals include areas outside the listed city"). Useful for
|
||
#' "place portraits" that compare a city to the surrounding county and
|
||
#' containing state on one set of axes.
|
||
#'
|
||
#' When `per_capita = TRUE`, rows whose government has no observed
|
||
#' population in that year (`pop_source == "unavailable"`) are dropped from
|
||
#' the result. The dropped govids are recorded in
|
||
#' `provenance$rollup$excluded_govids`. This excludes special districts
|
||
#' (gov type 4) and school districts (gov type 5) from per-capita rollups
|
||
#' by design — see `vignette('population-denominators')`.
|
||
#'
|
||
#' @param govids Named list with any non-empty subset of elements named
|
||
#' `state`, `county`, `city`. Each element is a character vector of
|
||
#' `canonical_govid` values. At least one layer required.
|
||
#' @param category Single category name or character vector (passed through
|
||
#' to [cog_spending()]).
|
||
#' @param years Integer vector of years.
|
||
#' @param per_capita If `TRUE`, per-capita uses each gov's own per-year
|
||
#' population from `gov_population_yearly`. Govs with missing population
|
||
#' are excluded from the result.
|
||
#' @param adjust_to_year Integer base year for CPI-U conversion, or `NULL`.
|
||
#' @return Tibble with columns `year`, `layer`, `canonical_govid`, `gov_name`,
|
||
#' `spend_subtype`, `category`, `amt_nominal`, optional `amt_real` /
|
||
#' `amt_per_capita_nominal` / `amt_per_capita_real`, optional `pop_source`,
|
||
#' `codes_included`, `aggregate_fallback`, `scope_note`, `notes`. Carries a
|
||
#' `provenance` attribute with `verb = "cog_geographic_rollup"`, `layers`,
|
||
#' and `rollup$included_govids` / `rollup$excluded_govids`.
|
||
#' @export
|
||
cog_geographic_rollup <- function(govids, category, years,
|
||
per_capita = FALSE, adjust_to_year = NULL) {
|
||
call <- match.call()
|
||
.validate_rollup_layers(govids)
|
||
|
||
govids <- lapply(govids, .coerce_govid_input, arg = "govids[[layer]]")
|
||
if (any(lengths(govids) == 0L)) {
|
||
cli::cli_abort("Each layer in `govids` must be non-empty after coercion.")
|
||
}
|
||
layer_names <- names(govids)
|
||
all_govids <- unlist(govids, use.names = FALSE)
|
||
layer_map <- tibble::tibble(
|
||
canonical_govid = all_govids,
|
||
layer = rep(layer_names, lengths(govids))
|
||
)
|
||
|
||
r <- cog_spending(all_govids, years, category, per_capita, adjust_to_year)
|
||
r <- dplyr::left_join(r, layer_map, by = "canonical_govid",
|
||
relationship = "many-to-many")
|
||
r$scope_note <- .rollup_scope_note(r$layer)
|
||
|
||
excluded <- character(0)
|
||
if (isTRUE(per_capita) && "pop_source" %in% names(r)) {
|
||
drop <- r$pop_source == "unavailable"
|
||
excluded <- unique(r$canonical_govid[drop])
|
||
r <- r[!drop, , drop = FALSE]
|
||
}
|
||
included <- unique(r$canonical_govid)
|
||
|
||
r <- .reorder_rollup_cols(r)
|
||
|
||
prov <- attr(r, "provenance")
|
||
prov$verb <- "cog_geographic_rollup"
|
||
prov$call <- paste(deparse(call), collapse = " ")
|
||
prov$layers <- layer_names
|
||
prov$rollup <- list(
|
||
included_govids = included,
|
||
excluded_govids = excluded
|
||
)
|
||
attr(r, "provenance") <- prov
|
||
|
||
r
|
||
}
|
||
```
|
||
|
||
`.validate_rollup_layers`, `.rollup_scope_note`, `.reorder_rollup_cols` are unchanged.
|
||
|
||
- [ ] **Step 2: Run tests**
|
||
|
||
```bash
|
||
Rscript -e 'devtools::test(filter = "rollup")'
|
||
```
|
||
|
||
Expected: ALL PASS.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add R/rollup.R
|
||
git commit -m "feat(rollup): drop unavailable-pop rows + provenance audit
|
||
|
||
cog_geographic_rollup(per_capita = TRUE) now drops rows whose government
|
||
has no observed population for that year (pop_source == 'unavailable'),
|
||
matching the spec's exclusion rule. Records included/excluded govids in
|
||
provenance\$rollup."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Update provenance for new denominator metadata
|
||
|
||
**Files:**
|
||
- Modify: `R/provenance.R:54-74`
|
||
- Modify: `R/spending.R` `.verb_spendrev` to pass result with `pop_source` to `.build_provenance`
|
||
- Modify: `tests/testthat/test-spending.R` (append)
|
||
|
||
- [ ] **Step 1: Write failing test**
|
||
|
||
Append to `tests/testthat/test-spending.R`:
|
||
|
||
```r
|
||
test_that("provenance records per-year denominator metadata", {
|
||
skip_if_no_corpus()
|
||
with_fixture_corpus({
|
||
r <- cog_spending("101006006", years = 2019:2020,
|
||
category = "Police", per_capita = TRUE)
|
||
pc <- attr(r, "provenance")$transformations$per_capita
|
||
expect_true(pc$applied)
|
||
expect_match(pc$denominator_source, "Census F-33", fixed = FALSE)
|
||
expect_match(pc$denominator_source, "per-year", fixed = TRUE)
|
||
expect_equal(pc$pop_source_counts$census_f33, nrow(r))
|
||
expect_equal(pc$pop_source_counts$unavailable, 0L)
|
||
expect_equal(length(pc$popyear_range), 2L)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify failure**
|
||
|
||
```bash
|
||
Rscript -e 'devtools::test(filter = "spending")'
|
||
```
|
||
|
||
Expected: FAIL — `pop_source_counts` is NULL, `denominator_source` still reads "ACS 2018-2022".
|
||
|
||
- [ ] **Step 3: Plumb popyear through to provenance**
|
||
|
||
The popyear range needs to come from `gov_population_yearly`. Two options: re-query inside `.build_provenance`, or have `.attach_per_capita` stash a `popyear_range` attribute on the result. Stash on the result is simpler and avoids a duplicate query.
|
||
|
||
Edit `.attach_per_capita` in `R/spending.R` so the SQL also pulls `popyear`, and after computing per-capita, drop the column but stash min/max as attributes:
|
||
|
||
Replace the SQL block + assignment:
|
||
|
||
```r
|
||
sql <- sprintf(
|
||
"SELECT canonical_govid, year, population, popyear
|
||
FROM gov_population_yearly
|
||
WHERE canonical_govid IN (%s)
|
||
AND year IN (%s)",
|
||
.sql_lit_chr(govid), years_lit
|
||
)
|
||
pops <- tibble::as_tibble(DBI::dbGetQuery(con, sql))
|
||
result <- dplyr::left_join(result, pops,
|
||
by = c("canonical_govid", "year"))
|
||
result$amt_per_capita_nominal <- result$amt_nominal / result$population
|
||
result$pop_source <- ifelse(is.na(result$population),
|
||
"unavailable", "census_f33")
|
||
py <- result$popyear[!is.na(result$popyear)]
|
||
attr(result, ".popyear_range") <- if (length(py) > 0L) {
|
||
as.integer(c(min(py), max(py)))
|
||
} else {
|
||
integer(0)
|
||
}
|
||
result$population <- NULL
|
||
result$popyear <- NULL
|
||
result
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Update `.build_provenance`**
|
||
|
||
Replace the `per_capita` block in `R/provenance.R`:
|
||
|
||
```r
|
||
per_capita = list(
|
||
applied = isTRUE(per_capita),
|
||
denominator_source = if (isTRUE(per_capita)) {
|
||
"Census F-33 population (per-year, from long.population)"
|
||
} else {
|
||
NA_character_
|
||
},
|
||
popyear_range = if (isTRUE(per_capita)) {
|
||
attr(result, ".popyear_range") %||% integer(0)
|
||
} else {
|
||
integer(0)
|
||
},
|
||
pop_source_counts = if (isTRUE(per_capita)) {
|
||
ps <- result[["pop_source"]]
|
||
if (is.null(ps) || length(ps) == 0L) {
|
||
list(census_f33 = 0L, unavailable = 0L)
|
||
} else {
|
||
list(
|
||
census_f33 = sum(ps == "census_f33", na.rm = TRUE),
|
||
unavailable = sum(ps == "unavailable", na.rm = TRUE)
|
||
)
|
||
}
|
||
} else {
|
||
NULL
|
||
}
|
||
),
|
||
```
|
||
|
||
- [ ] **Step 5: Strip `.popyear_range` attr after provenance is built**
|
||
|
||
In `.verb_spendrev` (`R/spending.R:42-80`), after the line `attr(result, "provenance") <- prov`, add:
|
||
|
||
```r
|
||
attr(result, ".popyear_range") <- NULL
|
||
```
|
||
|
||
so the helper attribute does not leak into the public surface.
|
||
|
||
- [ ] **Step 6: Run tests**
|
||
|
||
```bash
|
||
Rscript -e 'devtools::test()'
|
||
```
|
||
|
||
Expected: ALL PASS across spending, rollup, peers, explain.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add R/spending.R R/provenance.R tests/testthat/test-spending.R
|
||
git commit -m "feat(provenance): record per-year denominator metadata
|
||
|
||
Updates transformations\$per_capita with the new denominator_source string,
|
||
popyear_range, and pop_source_counts. .attach_per_capita stashes
|
||
popyear_range on the result; .verb_spendrev strips the helper attr after
|
||
provenance is built."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Render new provenance fields in `cog_explain()`
|
||
|
||
**Files:**
|
||
- Modify: `R/explain.R:69-82` (Transformations block)
|
||
- Modify: `tests/testthat/test-explain.R` (append)
|
||
|
||
- [ ] **Step 1: Write failing test**
|
||
|
||
Append to `tests/testthat/test-explain.R`:
|
||
|
||
```r
|
||
test_that("cog_explain prints denominator + popyear_range + counts", {
|
||
skip_if_no_corpus()
|
||
with_fixture_corpus({
|
||
r <- cog_spending("101006006", years = 2019:2020,
|
||
category = "Police", per_capita = TRUE)
|
||
out <- capture.output(cog_explain(r))
|
||
expect_true(any(grepl("Census F-33", out)))
|
||
expect_true(any(grepl("popyear", out, ignore.case = TRUE)))
|
||
expect_true(any(grepl("census_f33", out)))
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify failure**
|
||
|
||
```bash
|
||
Rscript -e 'devtools::test(filter = "explain")'
|
||
```
|
||
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Update `.print_provenance`**
|
||
|
||
In `R/explain.R`, replace the `pc <- prov$transformations$per_capita` block with:
|
||
|
||
```r
|
||
pc <- prov$transformations$per_capita
|
||
if (isTRUE(pc$applied)) {
|
||
cli::cli_text("Per-capita denominator: {pc$denominator_source}")
|
||
if (length(pc$popyear_range) == 2L) {
|
||
cli::cli_text(
|
||
" popyear range: {pc$popyear_range[1]}-{pc$popyear_range[2]}"
|
||
)
|
||
}
|
||
if (!is.null(pc$pop_source_counts)) {
|
||
cli::cli_text(
|
||
" pop_source counts: census_f33={pc$pop_source_counts$census_f33}, unavailable={pc$pop_source_counts$unavailable}"
|
||
)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests**
|
||
|
||
```bash
|
||
Rscript -e 'devtools::test(filter = "explain")'
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add R/explain.R tests/testthat/test-explain.R
|
||
git commit -m "feat(explain): render new per-capita provenance fields
|
||
|
||
cog_explain() now prints denominator_source, popyear_range, and
|
||
pop_source_counts under the Transformations section."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: Population-denominators vignette
|
||
|
||
**Files:**
|
||
- Create: `vignettes/population-denominators.Rmd`
|
||
- Modify: `DESCRIPTION` (add `Suggests: knitr, rmarkdown` if missing)
|
||
|
||
- [ ] **Step 1: Verify vignette infrastructure**
|
||
|
||
```bash
|
||
grep -E "VignetteBuilder|knitr|rmarkdown" DESCRIPTION
|
||
ls vignettes/ 2>/dev/null
|
||
```
|
||
|
||
If no other vignettes exist or `knitr`/`rmarkdown` is missing from `Suggests`, add to DESCRIPTION:
|
||
|
||
```
|
||
Suggests:
|
||
knitr,
|
||
rmarkdown,
|
||
testthat (>= 3.0.0),
|
||
withr
|
||
VignetteBuilder: knitr
|
||
```
|
||
|
||
(Only add the lines that aren't already present.)
|
||
|
||
- [ ] **Step 2: Create the vignette**
|
||
|
||
Write `vignettes/population-denominators.Rmd`:
|
||
|
||
````markdown
|
||
---
|
||
title: "Population denominators"
|
||
output: rmarkdown::html_vignette
|
||
vignette: >
|
||
%\VignetteIndexEntry{Population denominators}
|
||
%\VignetteEngine{knitr::knitr}
|
||
%\VignetteEncoding{UTF-8}
|
||
---
|
||
|
||
```{r setup, include = FALSE}
|
||
knitr::opts_chunk$set(eval = FALSE, collapse = TRUE, comment = "#>")
|
||
```
|
||
|
||
# Why per-year population matters
|
||
|
||
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%.
|
||
|
||
# The four population sources
|
||
|
||
| Source | What it is | Default in uscogdata? |
|
||
|---|---|---|
|
||
| Census F-33 `population` | Population value Census used on each COG row to compute its published per-capita tables. Almost always a Population Estimates Program (PEP) estimate; sometimes lagged a year for fiscal-year alignment, recorded in `popyear`. | **Yes — default for `cog_spending(per_capita = TRUE)` etc.** |
|
||
| PEP (raw) | Census Bureau's official annual intercensal estimates, distinct from F-33 because F-33 sometimes uses a lagged vintage. | No (not in corpus) |
|
||
| ACS 5-year | American Community Survey 5-year rolling average. Different methodology, has margin of error, only available 2005-2009 onward. | Used by `cog_find_peers()` historically; replaced in 0.1 by per-year F-33. Still available in `canonical_fips_xwalk.population_acs` for non-time-series uses. |
|
||
| Decennial count | Actual count, every 10 years. | No (not in corpus) |
|
||
|
||
The F-33 denominator is preferred because it's the same value Census uses internally — so `uscogdata` per-capita numbers reconcile with Census's own published tables.
|
||
|
||
# Coverage
|
||
|
||
F-33 `population` is observed for gov types 0–3 (state, county, city, township). Gov types 4 (special districts) and 5 (school districts) have `population` masked to NA in the F-33 schema. uscogdata returns:
|
||
|
||
- `pop_source = "census_f33"` and a numeric `amt_per_capita_*` for types 0–3.
|
||
- `pop_source = "unavailable"` and `NA` per-capita for types 4–5, with a corresponding entry in `notes`.
|
||
|
||
`cog_geographic_rollup(per_capita = TRUE)` excludes unavailable-pop rows from the result; the dropped govids are listed in `provenance\$rollup\$excluded_govids`.
|
||
|
||
# The popyear quirk
|
||
|
||
Census sometimes uses a population estimate from one year prior to the fiscal year being reported (e.g., FY2018 paired with a 2017 PEP estimate) so the denominator is available before the fiscal year closes. `popyear` records which vintage was paired; `cog_spending()` returns the popyear range in `provenance\$transformations\$per_capita\$popyear_range` rather than as a per-row column.
|
||
|
||
# Time-varying peer cohorts
|
||
|
||
`cog_find_peers(target, year = Y)` builds a cohort matched on each candidate's population at year `Y`. The cohort is fixed once chosen; `cog_peer_compare()` then runs that cohort across whatever `years` you ask for. To run a moving-window comparison, build cohorts year-by-year and stitch the results:
|
||
|
||
```r
|
||
years <- 2010:2023
|
||
out <- purrr::map_dfr(years, function(y) {
|
||
peers <- cog_find_peers("231082082", year = y, max_peers = 10L)
|
||
cog_peer_compare("231082082", peers,
|
||
category = "Police", years = y,
|
||
per_capita = TRUE)
|
||
})
|
||
```
|
||
|
||
Each row in `out` has `cohort_year == year`, so a faceted plot shows cohort drift directly.
|
||
|
||
# Future direction
|
||
|
||
`pop_source` is a column on the result, not a fixed value, so adding a new denominator (PEP from tidycensus, decennial counts, ACS time-series) is a join change rather than an API change. A future release may add `cog_spending(..., pop_source = "pep")` for users who need a single externally-audited series.
|
||
````
|
||
|
||
- [ ] **Step 3: Build vignettes locally to confirm**
|
||
|
||
```bash
|
||
Rscript -e 'devtools::build_vignettes()'
|
||
```
|
||
|
||
Expected: builds without error; HTML appears in `doc/`.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add vignettes/population-denominators.Rmd DESCRIPTION
|
||
git commit -m "docs(vignette): population denominators rationale + usage
|
||
|
||
Explains the four population sources, why F-33 is the default, type-4/5
|
||
coverage gap, the popyear quirk, and how to build moving-window peer
|
||
cohorts manually."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: Pipeline data dictionary entry
|
||
|
||
**Files:**
|
||
- Modify: `../cog_pipeline/docs/data_dictionary.md` (sibling repo)
|
||
|
||
This task touches a different git repo. The cog_pipeline repo lives at `../cog_pipeline/` relative to the uscogdata working directory.
|
||
|
||
- [ ] **Step 1: Locate the data dictionary**
|
||
|
||
```bash
|
||
ls ../cog_pipeline/docs/data_dictionary.md
|
||
```
|
||
|
||
If the file does not exist, create it; otherwise append to the appropriate column-reference section.
|
||
|
||
- [ ] **Step 2: Add the population block**
|
||
|
||
Append (or insert under any existing column reference) the following section:
|
||
|
||
```markdown
|
||
## `population` and `popyear`
|
||
|
||
`long.population` and `long.popyear` are population metadata columns from the F-33 fixed-width files. Census uses them to compute the per-capita tables in its own COG publications.
|
||
|
||
- **Source bytes:** `population` from cols 124-132 (older years) or cols 117-125 (modern format); `popyear` from cols 133-134 / 126-127. See `R/read_modern.R` for the exact mappings per fiscal-year layout.
|
||
- **Vintage:** `popyear` is a 2-digit year identifying which Population Estimates Program (PEP) value Census paired with that fiscal year. PEP estimates are sometimes lagged a year for fiscal-year alignment (e.g., FY2018 paired with 2017 PEP).
|
||
- **Coverage:** Populated for gov types 0–3 (state, county, city, township). Masked to NA for gov types 4 (special districts) and 5 (school districts) in `R/read_modern.R::.apply_phase_e_masking()`. Schools instead carry `enrollment` / `enrollyear`.
|
||
- **Relationship to PEP:** `population` is approximately the PEP estimate for `popyear` for that geography. It is *not* identical to a tidycensus `get_estimates()` pull because Census occasionally revises PEP retroactively while the F-33 value is frozen at publication.
|
||
- **Downstream use:** `uscogdata::cog_spending(per_capita = TRUE)` exposes this as the `census_f33` denominator via the `gov_population_yearly` view.
|
||
```
|
||
|
||
- [ ] **Step 3: Commit in cog_pipeline**
|
||
|
||
```bash
|
||
cd ../cog_pipeline
|
||
git add docs/data_dictionary.md
|
||
git commit -m "docs(data-dict): document long.population / long.popyear
|
||
|
||
Adds source byte ranges, vintage semantics, type-4/5 masking rule, and
|
||
downstream use by uscogdata's per-capita denominator."
|
||
cd ../uscogdata
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14: NEWS.md entry
|
||
|
||
**Files:**
|
||
- Modify: `NEWS.md` (top of file, under or above the existing latest entry)
|
||
|
||
- [ ] **Step 1: Read current NEWS.md**
|
||
|
||
```bash
|
||
head -30 NEWS.md
|
||
```
|
||
|
||
- [ ] **Step 2: Add Unreleased entry**
|
||
|
||
Insert at the top of `NEWS.md` (above the most recent dated entry):
|
||
|
||
```markdown
|
||
# uscogdata (development version)
|
||
|
||
## Per-capita denominators now use per-year Census F-33 population
|
||
|
||
`cog_spending()` and `cog_revenue()` previously divided all years' amounts by a single ACS 2018-2022 estimate (`canonical_fips_xwalk.population_acs`), producing biased per-capita values for time-series analysis. They now divide by the F-33 `population` recorded on each gov-year via the new `gov_population_yearly` view. Result tibbles gain a `pop_source` column with values `"census_f33"` or `"unavailable"`. `notes` is updated to concatenate multiple notes with `"; "`.
|
||
|
||
## Peer cohorts can be set to a chosen year
|
||
|
||
`cog_find_peers()` adds a `year` argument (default: most recent year for which the target has an observed population in `gov_population_yearly`). The returned column previously named `population_acs` is now `population` and reflects the cohort year's vintage. The cohort year is attached to the returned tibble as `attr(x, "cohort_year")`.
|
||
|
||
`cog_peer_compare()` now stamps a `cohort_year` column on its result (read from the peers tibble's attribute) and records `cohort_year` plus `cohort_govids` in provenance. When the caller supplies a bare character vector instead of a `cog_find_peers()` result, `cohort_year` is `NA`.
|
||
|
||
## Rollups exclude govs missing population
|
||
|
||
`cog_geographic_rollup(per_capita = TRUE)` drops rows whose government has `pop_source == "unavailable"` and records the dropped govids in `provenance$rollup$excluded_govids`. This excludes special districts (type 4) and school districts (type 5) from per-capita rollups by design.
|
||
|
||
## New: vignette and provenance metadata
|
||
|
||
- New vignette `population-denominators` covers the four population sources, the type-4/5 coverage gap, the popyear quirk, and how to build moving-window peer cohorts manually.
|
||
- Provenance gains `transformations$per_capita$popyear_range` and `pop_source_counts`. `cog_explain()` renders both.
|
||
```
|
||
|
||
- [ ] **Step 3: Run the full test suite one more time**
|
||
|
||
```bash
|
||
Rscript -e 'devtools::test()'
|
||
```
|
||
|
||
Expected: ALL tests pass (181+ existing + ~12 new = ~193+).
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add NEWS.md
|
||
git commit -m "docs(news): per-year population denominators (unreleased)
|
||
|
||
Summarizes the per-capita and peer-cohort behavior changes for users
|
||
upgrading from earlier 0.1 snapshots."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 15: Document refresh + final R CMD check
|
||
|
||
**Files:**
|
||
- Re-generate: `man/*.Rd` from updated roxygen
|
||
- Re-generate: `NAMESPACE` (no exports change, but document() will refresh)
|
||
|
||
- [ ] **Step 1: Run `devtools::document()`**
|
||
|
||
```bash
|
||
Rscript -e 'devtools::document()'
|
||
```
|
||
|
||
Expected: `man/cog_find_peers.Rd`, `man/cog_geographic_rollup.Rd`,
|
||
`man/cog_spending.Rd`, `man/cog_revenue.Rd`, `man/cog_explain.Rd` are
|
||
regenerated.
|
||
|
||
- [ ] **Step 2: Run R CMD check**
|
||
|
||
```bash
|
||
Rscript -e 'devtools::check(args = c("--no-manual", "--no-build-vignettes"))'
|
||
```
|
||
|
||
Expected: 0 ERRORs, 0 WARNINGs, 0 NOTEs (or only pre-existing acceptable notes).
|
||
|
||
- [ ] **Step 3: Commit doc regeneration**
|
||
|
||
```bash
|
||
git add man/ NAMESPACE
|
||
git commit -m "docs(roxygen): regenerate man pages for per-year population work"
|
||
```
|
||
|
||
- [ ] **Step 4: Push**
|
||
|
||
```bash
|
||
git push origin main
|
||
```
|
||
|
||
(Only when human review of the full series is complete and the engineer is authorized to push.)
|
||
|
||
---
|
||
|
||
## Verification checklist
|
||
|
||
After all tasks land, verify:
|
||
|
||
- [ ] `devtools::test()` reports 0 failures
|
||
- [ ] `devtools::check()` reports 0 ERRORs / 0 WARNINGs
|
||
- [ ] `cog_spending("101006006", 2019:2020, "Police", per_capita = TRUE)` returns different `amt_per_capita_nominal` for 2019 vs 2020 with the implied denominators matching `gov_population_yearly`
|
||
- [ ] `cog_find_peers("101006006")` returns a tibble with `population` (not `population_acs`) and `attr(x, "cohort_year") == 2020`
|
||
- [ ] `cog_geographic_rollup(per_capita = TRUE)` for a state+county+city set returns rows for each gov tagged by layer, with per-row per-year per-capita
|
||
- [ ] `cog_explain()` output mentions "Census F-33" and "popyear range"
|
||
- [ ] `vignette("population-denominators", package = "uscogdata")` opens
|
||
- [ ] cog_pipeline's `docs/data_dictionary.md` includes the population entry
|
||
|
||
## Out of scope (do not do)
|
||
|
||
- Adding PEP / ACS time-series / decennial denominators (architected for, not implemented)
|
||
- Adding a `per_pupil` denominator using `long.enrollment`
|
||
- Backfilling type-4/5 population from external sources
|
||
- Updating `cog_explorer/` callers — separate follow-up
|
||
- Bumping the package version
|