Compare commits

..
Author SHA1 Message Date
jaredandClaude Opus 5.5 ba57f9cb27 docs: record session
Mirror to GitHub / mirror (push) Successful in 8s
R-CMD-check / check (push) Successful in 3m21s
Journal entries for the move out of Nextcloud and the 2026-09-09 CI fix,
and the regenerated status board.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:15:56 -04:00
jaredandClaude Opus 5.5 63b421aea2 docs: point CLAUDE.md at the pipeline clone's paths
The repo moved out of Nextcloud today, and its sibling pipeline clone is
now census_of_governments_finance_pipeline, not cog_explorer/cog_pipeline.
The phase N task plan had already moved to docs/archive/ on 2026-07-23
(pipeline acfacf2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:15:00 -04:00
jaredandClaude Sonnet 5 d1165a507e fix(ci): find the google-chrome apt source by content, not filename
Mirror to GitHub / mirror (push) Successful in 7s
R-CMD-check / check (push) Successful in 4m35s
The prior commit's `sudo rm -f /etc/apt/sources.list.d/google-chrome.list`
guessed a filename that doesn't match what's actually on GitHub's
ubuntu-latest image: the step reported success (rm -f swallows
"no such file"), but setup-r@v2's subsequent apt-get update still hit
the same dl.google.com Hash Sum mismatch. Locate the offending source
by grepping for its URL instead, and fail this step explicitly if a
match survives removal, so a future runner-image change is loud here
rather than confusing inside setup-r@v2.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 13:53:40 -04:00
jaredandClaude Sonnet 5 d83301bdbd fix(ci): drop unused google-chrome apt source before setup-r
Mirror to GitHub / mirror (push) Successful in 6s
R-CMD-check / check (push) Successful in 3m30s
r-lib/actions/setup-r@v2 runs `sudo apt-get update` before installing
R, which fails outright if any configured apt source is broken - even
one we never touch. The google-chrome source baked into GitHub's
ubuntu-latest image intermittently serves a stale Packages.gz that
doesn't match its own Release file's hash (a Google CDN sync race),
which took down every Linux leg of the R-CMD-check matrix on
2026-09-09 regardless of R version. Removing the source we don't need
makes this failure class impossible here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 13:47:42 -04:00
jaredandClaude Sonnet 5 9adb921170 docs: record session (#36)
Mirror to GitHub / mirror (push) Successful in 7s
R-CMD-check / check (push) Successful in 3m37s
Journal entries for the #33/#34 split and the #36 coverage counter
(two bugs caught before merge in the latter), plus the vignette
obligation filed as #72. Status board regenerated and republished.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 13:38:25 -04:00
jaredandClaude Sonnet 5 9f5cd988b2 docs(suggestions): restore dropped M/L-counterpart rationale (roborev job 224)
Mirror to GitHub / mirror (push) Successful in 7s
R-CMD-check / check (push) Successful in 3m46s
The #33 decomposition (0c7c7eb) silently dropped four roxygen lines
explaining why .attach_ig_counterparts()'s second flow-family check is
needed: condition 1 alone doesn't block ig_federal_b47_wide under
cog_revenue(), since its own "B" IS inside revenue's own flow_prefixes.
Restored them, plus the backticks around "B" that were also dropped as
an unstated formatting change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 13:35:18 -04:00
jared 9eaa759ccb Merge pull request 'feat(coverage): n_units_collected separates sampling from real zeros (#36)' (#71) from fix/coverage-collected-36 into main
Mirror to GitHub / mirror (push) Successful in 8s
R-CMD-check / check (push) Successful in 3m57s
2026-09-09 11:51:06 -04:00
jaredandClaude Sonnet 5 b41d5ee2aa feat(coverage): n_units_collected separates sampling from real zeros (#36)
R-CMD-check / check (push) Successful in 3m39s
R-CMD-check / check (pull_request) Successful in 3m42s
provenance$coverage's n_units_reporting is category-conditional: it
counts governments with rows for the SPECIFIC requested category, which
conflates two different things -- a government never collected that
year (sampling), and one collected but genuinely spending nothing in
that category (a real zero). FY2012 Georgia Police is the motivating
case from the issue: a complete census year reads as a 69% "response
rate" because most of the gap is cities that contract policing to the
county sheriff, not non-response.

Adds a second counter, n_units_collected: how many of the caller's
expected cohort appear in the corpus that year for ANY category.
n_units_collected / n_units_expected is the true collection rate;
n_units_reporting / n_units_collected is category participation among
collected units. cog_geographic_rollup() and cog_peer_compare() both
carry it; cog_explain() prints it alongside n_units_reporting.

Two real bugs caught and fixed while finishing this (both against the
already-written, previously-uncommitted draft):

- .coverage_table()'s candidate list for the collection query was
  derived from the category-filtered result rows, not the caller's
  full expected cohort. A government with zero rows in the requested
  category across every requested year never appears in that result,
  so it was silently excluded from n_units_collected too -- collapsing
  the new counter back to the old, broken one for exactly the
  governments it exists to count. Fixed by threading an explicit
  `expected_ids` (all_govids / peer_govids) through instead.
- The collection query hardcoded long_view = "spending_long_harmonized",
  which does not exist on a corpus with schema_version < 5 (R/basis.R
  resolves basis = "raw" there; R/views.R only registers the
  harmonized views on v5+). cog_geographic_rollup()/cog_peer_compare()
  would hard-error on a corpus vintage the package otherwise explicitly
  supports. Fixed by deriving long_view from the basis cog_spending()
  actually resolved (prov$basis) via the existing .select_long_view()
  helper, matching how every other basis-aware query in the package
  already does this.

Also: cog_explain()'s general "complete census only in years ending in
2 or 7" footnote was gated on the OLD counter's absence, making it
permanently unreachable now that both callers always supply the new
one -- ungated it, since the explanation is orthogonal to which
counter set is present. Dropped a dead conditional branch, fixed two
stale roxygen blocks in R/peers.R/R/rollup.R still describing the old
two-counter model, fixed the same staleness in README.md, and switched
two `uscogdata:::` self-references to the package's own convention of
calling internal helpers unqualified.

1101 tests pass (2 skipped live-corpus), including new direct
regression tests for both bugs above (one exercising a government
collected-but-absent from a category result, one running the full
rollup/peer-compare path against a doctored schema_version 4 corpus).

Reviewed by an independent code-reviewer pass (1 HIGH, 1 MEDIUM, 3 LOW
-- all addressed above).

Closes #36.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:46:17 -04:00
jaredandClaude Sonnet 5 d0d724c4c8 fix(suggestions): guard empty candidates and restore year typing in .query_covered_years()
Mirror to GitHub / mirror (push) Successful in 6s
R-CMD-check / check (push) Successful in 3m15s
Addresses roborev jobs 205/208 (reviewing pre-merge draft/original commits
of #33/#34, now split and merged as #69/#70):

- Restore `res$year <- as.integer(res$year)` in .query_covered_years(),
  dropped during the #33 rebuild as apparently-dead code. Its own roxygen
  promises an integer `year` column; without the coercion the
  implementation no longer matches that documented contract.
- Guard .query_covered_years() against empty `candidates`, mirroring the
  existing `gap_years` guard. Not reachable via .build_suggestions() today
  (candidates is checked non-empty before this is called), but the
  extracted helper is independently callable and previously built a
  malformed `WHERE r.recipe_id IN ()` clause for a hypothetical direct
  caller with no candidates.
- Add a direct fixture-only unit test of .query_candidate_recipes()'s
  category_type filtering (#34) in both flow directions -- queries only
  summary_categories/harmonization_recipes metadata, so it runs without
  skip_if_no_corpus(), unlike the two existing end-to-end tests.

1080 tests pass (2 skipped live-corpus).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:28:01 -04:00
jared 56f610ea3f Merge pull request 'fix(suggestions): scope candidate recipes by category_type (#34)' (#70) from fix/category-type-34 into main
Mirror to GitHub / mirror (push) Successful in 6s
R-CMD-check / check (push) Successful in 3m36s
2026-09-09 11:18:52 -04:00
jared 62741343ee Merge pull request 'refactor(suggestions): decompose .build_suggestions() into named helpers (#33)' (#69) from issue-33 into main
Mirror to GitHub / mirror (push) Successful in 6s
R-CMD-check / check (push) Successful in 3m33s
2026-09-09 11:18:01 -04:00
jaredandClaude Sonnet 5 392643bd74 fix(suggestions): scope candidate recipes by category_type (#34)
R-CMD-check / check (push) Successful in 4m15s
R-CMD-check / check (pull_request) Successful in 4m25s
.query_candidate_recipes() (extracted in #33) now filters candidates by
category_type ('expenditure' vs 'revenue'), derived from the calling
verb's own flow_prefixes (E/F/G -> 'expenditure', else 'revenue').

Without this, a category shared across both flow families in
summary_categories leaked cross-family recipes: cog_revenue(category =
"Corrections") surfaced the expenditure-only corrections_combined recipe
(E04/E05) merely because "Corrections" is also a spending category name,
and cog_spending(category = "IG Federal") surfaced the revenue-only
ig_federal_b47_wide recipe. Both are wrong: following either hint would
attribute dollars to the wrong flow, or (IG Federal) fire the
coverage-gap machinery for a category the calling verb structurally
cannot report on at all.

Updates the two tests this changes the expected behavior of:
- "a mis-scoped cog_spending() call never attaches an M/L counterpart to
  a revenue-flavored recipe" (test-expenditure-concept.R): IG Federal is
  revenue-only, so a spending call now finds zero candidates outright
  rather than firing the suggestion and then blocking its M/L
  counterpart as a second-order check.
- "cog_revenue never suggests expenditure-only recipes"
  (test-recipes.R, was "I1: ... never fabricates suppressed dollars"):
  corrections_combined is expenditure-only, so a revenue call now never
  considers it as a candidate, rather than considering it and reporting
  zero suppressed dollars.

All 1078 tests pass (2 skipped live-corpus), measured devtools::test()
against this commit in a clean worktree stacked on the #33 refactor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:11:16 -04:00
jaredandClaude Sonnet 5 0c7c7eb299 refactor(suggestions): decompose .build_suggestions() into named helpers (#33)
R-CMD-check / check (pull_request) Successful in 4m12s
R-CMD-check / check (push) Successful in 4m20s
Extract three functions from the ~140-line .build_suggestions()
orchestrator to comply with the 'functions under 50 lines' convention:

- .query_candidate_recipes(): candidate recipe lookup by category/subtype
  scope, plus M/L self-exclusion
- .query_recipe_meta(): metadata lookup for labels and year spans
- .query_covered_years(): Path 1 gap-year coverage query via the recipe's
  own generic join; returns empty data frame when gap_years is empty

Kept inline per design: the for-loop that merges covered-years +
suppressed-components into suggestion objects, the M/L-exclusion comment
block as call-site rationale, and .attach_ig_counterparts() at the end.

Pure extraction, no behavior change -- SQL text is unchanged apart from
whitespace. Restored real multi-line SQL string literals in the two new
helpers (the original candidate/covered-years queries were written that
way; keep it consistent with .query_recipe_meta()) and normal roxygen
'#'' comment-marker spacing throughout, both of which drifted during
extraction in an earlier pass.

All 1084 tests pass (2 skipped live-corpus), measured devtools::test()
against this commit in a clean worktree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:09:01 -04:00
jared 7274ce3bfe chore: correct the roborev exclusions and refresh the guidelines
Mirror to GitHub / mirror (push) Successful in 9s
R-CMD-check / check (push) Successful in 3m46s
roborev matches excluded_commit_patterns as substrings, and compass's cadence rule
requires `type(ws): subject (#N)`, so `chore:` never matched `chore(engine): ...`.
Adds `chore(`.

Does not add `docs(`. Those commits carry the journal entry and the board
narrative, and the plain-language rule exists to check exactly that prose -- it has
no other commit to fire on.

The guidelines were also stale: they were composed before base.md gained the
plain-language rule, and nothing re-composes them on its own. Refreshed, which is
what put that rule in this repository for the first time.
2026-08-23 23:57:05 -04:00
jared 24e86ed598 chore: retire the three plans whose work has shipped
Mirror to GitHub / mirror (push) Successful in 6s
R-CMD-check / check (push) Successful in 3m21s
186 unticked checkboxes across three plans, none of them outstanding work.
Superpowers-style plans are execution transcripts: nobody ticks the boxes, and
the plan is abandoned at the point the work is done. Left in place they are
indistinguishable from a live backlog -- the old compass retrofit rule, "open
checkboxes become issues", would have filed 186 issues for finished work.

Evidence, from scripts/plans.py plus a check by hand:

  2026-08-04-partial-coverage-signposting  NEWS: "Coverage signposting ..."
  2026-08-08-public-release                NEWS: "First public release."
  2026-04-29-per-year-population-denominator
      no NEWS line matched and 19 of the 21 files it names exist, so the
      classifier called it ambiguous. Confirmed shipped by hand:
      33c0274 docs(news): per-year population denominators, plus the feat
      commits behind it.

specs/ is untouched. A spec explains why the design is what it is and stays
useful; a plan is scaffolding, and once the building stands it is in the way.
All three remain in git history.
2026-08-23 23:32:11 -04:00
jared 1ec20174b7 chore: move compass out of docs/, which pkgdown deletes
Mirror to GitHub / mirror (push) Successful in 7s
R-CMD-check / check (push) Successful in 3m28s
compass.toml, JOURNAL.md, STATUS.md and decisions/ were sitting inside pkgdown's
output directory. Asked directly, pkgdown listed docs/pm and docs/decisions among
the 28 top-level entries clean_site() would delete, and the guard that would have
refused -- check_dest_is_pkgdown() -- was satisfied by docs/pkgdown.yml. After the
move it lists 26 and none of them are compass's.

Nothing was lost. The journal had no entries and there were no decision records
yet, so this was the cheapest moment to move.

The .gitignore workaround goes with it. Re-including two children of an excluded
docs/ forced the rule to be written as /docs/* plus two negations, which changed
the anchoring and made the fixture corpus's own docs/ need a separate rule. A bare
docs/ matches at any depth again, so both are unnecessary.

.Rbuildignore gains ^pm$ -- R CMD check flags a non-standard top-level directory.

Compass reads both layouts, so this repository worked either way; the point is
that docs/ is a directory another tool empties.
2026-08-23 23:26:33 -04:00
26 changed files with 694 additions and 3792 deletions
+1
View File
@@ -3,6 +3,7 @@
^\.Rproj\.user$
^_pkgdown\.yml$
^docs$
^pm$
^Meta$
^doc$
^pkgdown$
+23
View File
@@ -44,6 +44,29 @@ jobs:
- uses: r-lib/actions/setup-pandoc@v2
- name: Drop the unused google-chrome apt source
if: runner.os == 'Linux'
run: |
set -x
grep -rl 'dl\.google\.com' /etc/apt/sources.list.d/ /etc/apt/sources.list 2>/dev/null || true
sudo sh -c "grep -rl 'dl\.google\.com' /etc/apt/sources.list.d/ /etc/apt/sources.list 2>/dev/null | xargs -r rm -f"
grep -rl 'dl\.google\.com' /etc/apt/sources.list.d/ /etc/apt/sources.list 2>/dev/null && exit 1 || true
# setup-r@v2 runs `sudo apt-get update` before installing R, and that
# command fails outright if ANY configured apt source is broken --
# even one we never use. The google-chrome source baked into GitHub's
# ubuntu-latest image intermittently serves a stale Packages.gz that
# doesn't match its own Release file's hash (a Google CDN sync race,
# not anything about R or this repo), which took down every ubuntu
# leg of this matrix on 2026-09-09. `rm -f` on a guessed filename
# (google-chrome.list) reported success but removed nothing -- the
# file it actually is on this image apparently doesn't match that
# name, since the source kept showing up in setup-r's `apt-get
# update` afterward. Find-by-content instead of guessing the
# filename, and fail loudly here (before setup-r even runs) if a
# matching source is still present, so a future runner-image change
# surfaces as a clear failure in this step instead of a confusing
# one in setup-r.
- uses: r-lib/actions/setup-r@v2
with:
r-version: ${{ matrix.config.r }}
+5 -11
View File
@@ -4,17 +4,11 @@
.Ruserdata
*.Rproj
inst/doc
# pkgdown output. Listed as children rather than `docs/` so compass's
# docs/pm/ and docs/decisions/ can be re-included -- git cannot re-include
# anything beneath an excluded directory.
#
# Note the anchoring change this forces: a bare `docs/` matches a directory of
# that name at ANY depth, while `/docs/*` matches only at the repo root. The
# fixture corpus's own docs/ therefore needs its own rule to stay excluded.
/docs/*
!/docs/pm/
!/docs/decisions/
inst/extdata/fixture_corpus/docs/
# pkgdown output. Compass used to keep its files in docs/pm/ and
# docs/decisions/, which forced this to be written as children with two
# re-includes -- git cannot re-include anything beneath an excluded directory.
# Compass lives in pm/ now, so the whole directory can be excluded again.
docs/
/doc/
/Meta/
.DS_Store
+5 -1
View File
@@ -2,7 +2,7 @@
# Reviews are queued to a background daemon -- they never block a commit.
post_commit_review = 'commit'
excluded_commit_patterns = ['WIP', 'chore:', 'docs:', 'Merge ']
excluded_commit_patterns = ['WIP', 'chore:', 'chore(', 'docs:', 'Merge ']
review_guidelines = '''
# --- compass:begin (generated -- edit the sources, not this) ---
@@ -19,6 +19,10 @@ review_guidelines = '''
deeper than four levels.
- No magic numbers or hardcoded paths -- name them as constants or read them from config.
- New behaviour needs a test. A bug fix needs a test that fails without the fix.
- Prose a person reads -- an issue title or body, a journal entry, a decision record,
the narrative on the status board -- names the action or the thing, not the shape of
the machinery. Flag "gate", "seam", "surface area", "load-bearing", "first-class",
"primitive", "blast radius". A project's own defined vocabulary is not the target.
- Use the native pipe `|>`, not magrittr `%>%`.
- snake_case for objects and functions; UPPER_SNAKE for constants. Never use `.` as a
word separator in a function name -- it collides with S3 dispatch.
+4 -3
View File
@@ -5,11 +5,12 @@
R package providing a curated reader API for the Civilytics US Census of
Governments finance corpus. Reads Hive-partitioned parquet + `manifest.json`
published by `cog_pipeline` via DuckDB (local path or remote URL). This is a
standalone Gitea repo, sibling to `cog_explorer/cog_pipeline/`.
standalone Gitea repo, sibling to the pipeline repo,
`Civilytics/census_of_governments_finance_pipeline`, cloned beside this one.
**Gitea remote:** `gitea.civilytics.org/Civilytics/uscogdata`
**Full implementation plan:** `../cog_pipeline/docs/plan_phase_n_tasks.md` (Tasks 2.1–2.8 + Phase 3)
**Reader contract spec:** `../cog_pipeline/docs/reader-specification.md`
**Full implementation plan (archived):** `../census_of_governments_finance_pipeline/docs/archive/plan_phase_n_tasks.md` (Tasks 2.1–2.8 + Phase 3)
**Reader contract spec:** `../census_of_governments_finance_pipeline/docs/reader-specification.md`
## Architecture
+76 -6
View File
@@ -84,22 +84,92 @@
#' `n_units_reporting = 0`, which is precisely the disclosure a silently
#' missing year fails to make.
#'
#' `n_units_reporting` describes the result the caller actually received, so
#' under `coverage = "consistent"` it reports the balanced count. `is_census_year`
#' is a statement about the SURVEY CALENDAR, never a claim of completeness:
#' FY1967 is a census year in which only 97 of Wisconsin's 608 cities report.
#' `n_units_reporting` is the number that tells the truth.
#' Three counters are returned, each answering a different question:
#'
#' * `n_units_expected` -- the universe the caller named (govids passed in,
#' or peers for cog_peer_compare). "How many governments did you ask
#' about?"
#' * `n_units_collected` -- how many of those appear in the corpus at all
#' that year, in ANY category. This is a statement about survey collection,
#' independent of what was asked for: "of the governments you named, how
#' many did Census actually collect data from this year?" It separates
#' sampling (not collected) from real zeros (collected but spends nothing
#' in your category).
#' * `n_units_reporting` -- how many of those appear with rows for the
#' SPECIFIC category you requested. This is always <= n_units_collected:
#' a government can be collected but have no rows for "Police" because it
#' contracts policing to the county sheriff, not because it wasn't
#' surveyed.
#'
#' `n_units_reporting` therefore conflates two very different things: a unit
#' that was not collected (sampling) and a unit that was collected but spends
#' nothing in that category. The ratio n_units_collected / n_units_expected is
#' the true collection rate; n_units_reporting / n_units_collected measures
#' category participation among collected units.
#'
#' `is_census_year` is a statement about the SURVEY CALENDAR, never a claim of
#' completeness: FY1967 is a census year in which only 97 of Wisconsin's 608
#' cities report. The counters are what tell the truth.
#'
#' @param con Active DuckDB connection (used to look up n_units_collected).
#' @param long_view The verb's own long view, used for the collection query;
#' NULL skips the lookup and leaves n_units_collected as NA_integer_.
#' @param expected_ids The full EXPECTED cohort (govids the caller named),
#' used as the candidate list for the collection query. Required alongside
#' `con`/`long_view` for a correct count -- see the note below on why it
#' must not be derived from `result`/`rows`. `NULL`, or non-`NULL` but
#' empty after dropping `NA`/`""` entries, skips the lookup and leaves
#' n_units_collected as NA_integer_.
#' @noRd
.coverage_table <- function(result, years, n_expected,
id_col = "canonical_govid", rows = NULL) {
id_col = "canonical_govid", rows = NULL,
con = NULL, long_view = NULL,
expected_ids = NULL) {
years <- sort(unique(as.integer(years)))
src <- if (is.null(rows)) result else rows
reporting <- vapply(years, function(y) {
ids <- src[[id_col]][as.integer(src$year) == y]
length(unique(ids[!is.na(ids)]))
}, integer(1))
# n_units_collected: count EXPECTED cohort members present in the corpus
# for ANY category that year, not just the requested one. This separates
# sampling (not collected at all) from real zeros (collected but no rows
# for this category). Only computed when a connection, long_view, AND
# expected_ids are all provided; otherwise NA_integer_.
#
# The candidate list MUST be expected_ids, not derived from `result`/
# `rows`: a government with zero rows in the requested category across
# EVERY requested year never appears in `result` at all, so deriving
# candidates from it would silently exclude exactly the "collected but
# real zero" governments this counter exists to count -- collapsing
# n_units_collected back to n_units_reporting for precisely the case #36
# was filed over.
if (!is.null(con) && !is.null(long_view) && length(expected_ids) > 0L) {
cohort_chr <- .sql_lit_chr(unique(expected_ids[!is.na(expected_ids) &
nzchar(expected_ids)]))
years_lit <- paste(years, collapse = ",")
collected_q <- sprintf(
"SELECT year, COUNT(DISTINCT canonical_govid) AS n
FROM %s
WHERE canonical_govid IN (%s)
AND year IN (%s)
GROUP BY year",
long_view, cohort_chr, years_lit
)
collected_df <- DBI::dbGetQuery(con, collected_q)
collected_map <- setNames(collected_df$n, as.integer(collected_df$year))
collected <- vapply(years, function(y) {
val <- collected_map[as.character(y)]
if (is.na(val)) 0L else as.integer(val)
}, integer(1))
} else {
collected <- rep(NA_integer_, length(years))
}
tibble::tibble(
year = years,
n_units_collected = collected,
n_units_reporting = as.integer(reporting),
n_units_expected = rep(as.integer(n_expected), length(years)),
is_census_year = .is_census_year(years)
+24 -6
View File
@@ -166,12 +166,30 @@ cog_explain <- function(result, format = c("print", "list")) {
cli::cli_h2("Reporting coverage")
cli::cli_text("Mode: {prov$coverage_mode %||% 'all'}")
cov <- prov$coverage
cli::cli_ul(sprintf(
"%d: %d of %d units reporting (%.0f%%) -- %s year",
cov$year, cov$n_units_reporting, cov$n_units_expected,
100 * cov$n_units_reporting / pmax(cov$n_units_expected, 1L),
ifelse(cov$is_census_year, "census", "sample")
))
has_collected <- "n_units_collected" %in% names(cov)
if (has_collected) {
# Three counters: collected separates sampling from real zeros;
# reporting is category-conditional and never a response rate.
cli::cli_ul(sprintf(
"%d: %d of %d units collected, %d reporting in this category -- %s year",
cov$year,
cov$n_units_collected,
cov$n_units_expected,
cov$n_units_reporting,
ifelse(cov$is_census_year, "census", "sample")
))
} else {
cli::cli_ul(sprintf(
"%d: %d of %d units reporting (%.0f%%) -- %s year",
cov$year, cov$n_units_reporting, cov$n_units_expected,
100 * cov$n_units_reporting / pmax(cov$n_units_expected, 1L),
ifelse(cov$is_census_year, "census", "sample")
))
}
# Explains what the per-row "-- sample year" tag means, regardless of
# which branch above rendered it -- not gated on has_collected, which
# would make this permanently unreachable now that both real callers
# (cog_geographic_rollup(), cog_peer_compare()) always supply it.
if (any(!cov$is_census_year)) {
cli::cli_text(
"Note: the Census of Governments is a complete census only in years ending in 2 or 7; every other year is a sample."
+34 -13
View File
@@ -195,11 +195,13 @@ cog_find_peers <- function(target_govid,
#' a balanced panel.
#'
#' Regardless of mode, `provenance$coverage` always carries per-year
#' `n_units_reporting`, `n_units_expected` and `is_census_year`, and
#' `provenance$coverage_mode` records the mode. `is_census_year` is a
#' statement about the **survey calendar**, never a claim of completeness:
#' FY1967 is a census year in which only 97 of Wisconsin's 608 cities
#' report. `n_units_reporting` is the number that tells the truth.
#' `n_units_expected`, `n_units_collected`, `n_units_reporting` and
#' `is_census_year`, and `provenance$coverage_mode` records the mode.
#' `is_census_year` is a statement about the **survey calendar**, never a
#' claim of completeness: FY1967 is a census year in which only 97 of
#' Wisconsin's 608 cities report. `n_units_reporting` is
#' category-conditional and is not a response rate on its own -- see
#' "Reading `coverage`" below for what each counter answers.
#'
#' The comparison target is exempt from `"consistent"` balancing -- it is the
#' subject of the comparison, not a member of the cohort -- and the
@@ -241,14 +243,23 @@ cog_find_peers <- function(target_govid,
#' summarise(p50 = quantile(total, 0.5, na.rm = TRUE))
#' ```
#' @section Reading `coverage`:
#' `provenance$coverage` reports `n_units_reporting` against
#' `n_units_expected` per year. **`n_units_reporting` is category-conditional:
#' it counts cohort members with rows for the category you asked for, not
#' cohort members collected that year.** A government that was surveyed and
#' genuinely spends nothing in that category is indistinguishable here from one
#' that was never surveyed.
#' `provenance$coverage` carries three per-year counters:
#'
#' * `n_units_expected` -- how many governments you asked about.
#' * `n_units_collected` -- how many of those appear in the corpus at all
#' that year (in ANY category), separating sampling from real zeros.
#' * `n_units_reporting` -- how many have rows for the SPECIFIC category you
#' requested. This is always <= n_units_collected: a government can be
#' collected but have no rows for "Police" because it contracts policing
#' to the county sheriff, not because it wasn't surveyed.
#'
#' **`n_units_reporting` is category-conditional** and therefore **not a
#' response rate**: `n_units_reporting / n_units_expected` conflates sampling
#' (never collected) with real zeros (collected but spends nothing in your
#' category). Use `n_units_collected / n_units_expected` for the true
#' collection rate, and `n_units_reporting / n_units_collected` for category
#' participation among collected units.
#'
#' The ratio is therefore **not a response rate** and must not be used as one.
#' In FY2022 — a complete census year — Georgia reports 393 of 567 cities for
#' `category = "Police"`; the 174-city gap is overwhelmingly cities that
#' contract policing to the county sheriff, not non-response.
@@ -325,10 +336,20 @@ cog_peer_compare <- function(target_govid, peers, category, years,
# Counted over PEER rows only, against the cohort size: "3 of your 15 peers
# reported in FY2019". Including the target would inflate every count by one
# and make a cohort that has entirely stopped reporting look non-empty.
# n_units_collected is looked up against the spending long view matching
# whatever basis cog_spending() actually resolved above (prov$basis) --
# NOT hardcoded to spending_long_harmonized, which does not exist on a
# corpus with schema_version < 5 (R/basis.R resolves basis = "raw" there,
# and only *_long, not *_long_harmonized, is registered; see R/views.R).
# The con comes from .ensure_session() already called inside cog_spending().
con <- .ensure_session()
prov$coverage_mode <- coverage
prov$coverage <- .coverage_table(
out, years, length(peer_govids),
rows = r[r$role == "peer", , drop = FALSE]
rows = r[r$role == "peer", , drop = FALSE],
con = con,
long_view = .select_long_view("spending_annotated", prov$basis),
expected_ids = peer_govids
)
attr(out, "provenance") <- prov
out
+36 -13
View File
@@ -49,11 +49,13 @@
#' a balanced panel.
#'
#' Regardless of mode, `provenance$coverage` always carries per-year
#' `n_units_reporting`, `n_units_expected` and `is_census_year`, and
#' `provenance$coverage_mode` records the mode. `is_census_year` is a
#' statement about the **survey calendar**, never a claim of completeness:
#' FY1967 is a census year in which only 97 of Wisconsin's 608 cities
#' report. `n_units_reporting` is the number that tells the truth.
#' `n_units_expected`, `n_units_collected`, `n_units_reporting` and
#' `is_census_year`, and `provenance$coverage_mode` records the mode.
#' `is_census_year` is a statement about the **survey calendar**, never a
#' claim of completeness: FY1967 is a census year in which only 97 of
#' Wisconsin's 608 cities report. `n_units_reporting` is
#' category-conditional and is not a response rate on its own -- see
#' "Reading `coverage`" below for what each counter answers.
#' @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`,
@@ -61,14 +63,23 @@
#' `provenance` attribute with `verb = "cog_geographic_rollup"`, `layers`,
#' and `rollup$included_govids` / `rollup$excluded_govids`.
#' @section Reading `coverage`:
#' `provenance$coverage` reports `n_units_reporting` against
#' `n_units_expected` per year. **`n_units_reporting` is category-conditional:
#' it counts governments with rows for the category you asked for, not
#' governments collected that year.** A government that was surveyed and
#' genuinely spends nothing in that category is indistinguishable here from one
#' that was never surveyed.
#' `provenance$coverage` carries three per-year counters:
#'
#' * `n_units_expected` -- how many governments you asked about.
#' * `n_units_collected` -- how many of those appear in the corpus at all
#' that year (in ANY category), separating sampling from real zeros.
#' * `n_units_reporting` -- how many have rows for the SPECIFIC category you
#' requested. This is always <= n_units_collected: a government can be
#' collected but have no rows for "Police" because it contracts policing
#' to the county sheriff, not because it wasn't surveyed.
#'
#' **`n_units_reporting` is category-conditional** and therefore **not a
#' response rate**: `n_units_reporting / n_units_expected` conflates sampling
#' (never collected) with real zeros (collected but spends nothing in your
#' category). Use `n_units_collected / n_units_expected` for the true
#' collection rate, and `n_units_reporting / n_units_collected` for category
#' participation among collected units.
#'
#' The ratio is therefore **not a response rate** and must not be used as one.
#' In FY2022 — a complete census year — Georgia reports 393 of 567 cities for
#' `category = "Police"`; the 174-city gap is overwhelmingly cities that
#' contract policing to the county sheriff, not non-response.
@@ -137,8 +148,20 @@ cog_geographic_rollup <- function(govids, category, years,
# n_units_expected is the universe the CALLER named -- the govids passed in
# -- not the national universe. That is what makes the ratio meaningful:
# "597 of the 608 Wisconsin cities you asked about reported in FY2012".
# n_units_collected is looked up against the spending long view matching
# whatever basis cog_spending() actually resolved above (prov$basis) --
# NOT hardcoded to spending_long_harmonized, which does not exist on a
# corpus with schema_version < 5 (R/basis.R resolves basis = "raw" there,
# and only *_long, not *_long_harmonized, is registered; see R/views.R).
# The con comes from .ensure_session() already called inside cog_spending().
con <- .ensure_session()
prov$coverage_mode <- coverage
prov$coverage <- .coverage_table(r, years, length(unique(all_govids)))
prov$coverage <- .coverage_table(
r, years, length(unique(all_govids)),
con = con,
long_view = .select_long_view("spending_annotated", prov$basis),
expected_ids = all_govids
)
attr(r, "provenance") <- prov
r
+167 -49
View File
@@ -84,6 +84,21 @@
#' @return List of `list(recipe_id, label, available_years, hint,
#' ig_recipe_id, trigger, suppressed_amount, suppressed_years,
#' suppressed_codes)`, possibly empty.
#'
#' Decomposed (Issue #33) into three extracted helpers to stay within the
#' project's "functions under 50 lines" convention:
#' \itemize{
#' \item `.query_candidate_recipes()` -- candidate recipe lookup by
#' category/subtype scope + `category_type` filter (#34) + M/L exclusion.
#' \item `.query_recipe_meta()` -- metadata (label, year spans).
#' \item `.query_covered_years()` -- Path 1 gap-year coverage via the
#' recipe's own generic join.
#' }
#' The for-loop that merges covered-years + suppressed-components into
#' suggestion objects stays inline here because it interleaves
#' empty_hit/supp_hit precedence with field assembly. Likewise kept inline:
#' the M/L-exclusion design-comment block and the final
#' `.attach_ig_counterparts()` call.
#' @noRd
.build_suggestions <- function(con, cohort, years, category, result, basis,
flow_prefixes, long_view,
@@ -111,28 +126,16 @@
# by `category` (`.ALL_CATEGORIES` is never a row in
# `summary_categories.category`, so a category-keyed sub-select always
# came back empty here). The M/L exclusion below is unchanged either way.
candidate_scope_sql <- if (isTRUE(all_categories)) {
sprintf(
"SELECT DISTINCT item_code FROM summary_categories WHERE %s IN (%s)",
subtype_col, .sql_lit_chr(subtype_scope)
)
} else {
sprintf(
"SELECT DISTINCT item_code FROM summary_categories WHERE category IN (%s)",
.sql_lit_chr(category)
)
}
candidates <- DBI::dbGetQuery(con, sprintf(
"SELECT DISTINCT recipe_id FROM harmonization_recipes
WHERE component_code IN (
%s
)
AND recipe_id NOT IN (
SELECT DISTINCT recipe_id FROM harmonization_recipes
WHERE LEFT(component_code, 1) IN ('M', 'L')
)",
candidate_scope_sql
))$recipe_id
#
# Issue #34: scope the candidate query by `category_type` ('expenditure'
# vs 'revenue') to prevent cross-flow-family leakage -- e.g.
# `cog_revenue(category = "Corrections")` must not surface
# expenditure-only recipes (E04/E05) merely because they share the same
# category name in summary_categories. The type is derived from
# flow_prefixes: E/F/G -> 'expenditure', anything else -> 'revenue'.
candidates <- .query_candidate_recipes(con, category, flow_prefixes,
all_categories, subtype_col,
subtype_scope)
if (length(candidates) == 0L) return(list())
result_years <- if (is.null(result) || nrow(result) == 0L) {
@@ -164,36 +167,11 @@
if (length(gap_years) == 0L && nrow(supp) == 0L) return(list())
meta <- tibble::as_tibble(DBI::dbGetQuery(con, sprintf(
"SELECT recipe_id, any_value(label) AS label,
MIN(year_min) AS year_min, MAX(year_max) AS year_max
FROM harmonization_recipes
WHERE recipe_id IN (%s)
GROUP BY recipe_id",
.sql_lit_chr(candidates)
)))
meta <- .query_recipe_meta(con, candidates)
# Path 1 (unchanged): (recipe, year) pairs the recipe's own generic join
# covers for this government, restricted to the gap years.
covered <- if (length(gap_years) == 0L) {
data.frame(recipe_id = character(0), year = integer(0))
} else {
DBI::dbGetQuery(con, sprintf(
"SELECT DISTINCT r.recipe_id, l.year
FROM long l
JOIN harmonization_recipes r
ON l.item_code = r.component_code
AND l.year BETWEEN r.year_min AND r.year_max
AND (r.gov_type_scope = 'all'
OR (r.gov_type_scope = 'state' AND l.type = 0)
OR (r.gov_type_scope = 'local' AND l.type BETWEEN 1 AND 3))
WHERE r.recipe_id IN (%s)
AND %s
AND l.year IN (%s)",
.sql_lit_chr(candidates), .cohort_sql(cohort, "l.canonical_govid"),
paste(gap_years, collapse = ",")
))
}
covered <- .query_covered_years(con, candidates, cohort, gap_years)
suggestions <- list()
for (rid in candidates) {
@@ -227,6 +205,146 @@
.attach_ig_counterparts(con, suggestions, flow_prefixes)
}
#' Query candidate harmonization recipe IDs for a coverage-gap suggestion.
#'
#' Selects recipes whose component codes fall within the requested scope
#' (category or subtype allowlist), excluding any recipe that is ITSELF an
#' intergovernmental (M/L) recipe -- i.e. every one of its own component
#' codes is M/L-prefixed. Without this exclusion, a category whose
#' summary_categories rows span both a Direct family (e.g. E04/E05,
#' "Corrections") and its M/L counterpart (M04/M05) makes the M/L recipe
#' itself a raw top-level candidate for a plain `cog_spending()` call --
#' following that hint would silently return intergovernmental dollars
#' under `expenditure_concept = "direct"` provenance.
#'
#' In all-categories mode (`all_categories = TRUE`) the inner sub-select is
#' scoped by `subtype_col`/`subtype_scope` -- the same allowlist
#' `.build_verb_sql()` applies as a WHERE predicate to make the summed
#' result a *concept* (see R/spending.R), not by `category`.
#' `.ALL_CATEGORIES` ("All Categories") is never itself a row in
#' `summary_categories.category`, so a category-keyed sub-select always
#' returns zero candidates and silently disables signposting.
#'
#' Scope is also by `category_type` ('expenditure' vs 'revenue', Issue #34)
#' to prevent cross-flow-family leakage: `cog_revenue(category =
#' "Corrections")` must not surface expenditure-only recipes (E04/E05)
#' merely because they share the same category name in summary_categories.
#' The type is derived from flow_prefixes: E/F/G -> 'expenditure', anything
#' else -> 'revenue'.
#'
#' @param con Active DuckDB connection.
#' @param category Category name, or `NULL`.
#' @param flow_prefixes The calling verb's own flow-type prefixes (see
#' `.build_suggestions()`). Used to derive `category_type` (#34).
#' @param all_categories `TRUE` when the caller used `.ALL_CATEGORIES`.
#' @param subtype_col Name of the summary_categories subtype column to
#' scope by when `all_categories = TRUE`; ignored otherwise.
#' @param subtype_scope Character vector of subtype values to scope by
#' when `all_categories = TRUE`; ignored otherwise.
#' @return Character vector of recipe IDs (possibly empty).
#' @noRd
.query_candidate_recipes <- function(con, category, flow_prefixes,
all_categories = FALSE,
subtype_col = NULL,
subtype_scope = NULL) {
# Issue #34: derive category_type from flow_prefixes to prevent
# cross-flow-family leakage -- e.g. cog_revenue(category = "Corrections")
# must not surface expenditure-only recipes merely because they share the
# same category name in summary_categories.
category_type <- if (all(flow_prefixes %in% c("E", "F", "G"))) {
"expenditure"
} else {
"revenue"
}
candidate_scope_sql <- if (isTRUE(all_categories)) {
sprintf(
"SELECT DISTINCT item_code FROM summary_categories
WHERE %s IN (%s) AND category_type = '%s'",
subtype_col, .sql_lit_chr(subtype_scope), category_type
)
} else {
sprintf(
"SELECT DISTINCT item_code FROM summary_categories
WHERE category IN (%s) AND category_type = '%s'",
.sql_lit_chr(category), category_type
)
}
DBI::dbGetQuery(con, sprintf(
"SELECT DISTINCT recipe_id FROM harmonization_recipes
WHERE component_code IN (
%s
)
AND recipe_id NOT IN (
SELECT DISTINCT recipe_id FROM harmonization_recipes
WHERE LEFT(component_code, 1) IN ('M', 'L')
)",
candidate_scope_sql
))$recipe_id
}
#' Query gap-year coverage: which (recipe, year) pairs the recipe's own
#' generic join covers for this government, restricted to `gap_years`.
#'
#' This is Path 1 of a suggestion (unchanged): it finds recipes whose
#' component codes' generic join produces at least one row for this
#' government in each gap year -- i.e. the category returned nothing in
#' that year but a recipe would fill it.
#'
#' @param con Active DuckDB connection.
#' @param candidates Character vector of recipe IDs to check coverage for.
#' @param cohort The verb's cohort object (see `.make_cohort()`), rendered
#' into the govid predicate on the joined `long` scan via `.cohort_sql()`.
#' @param gap_years Integer vector of requested years absent from the
#' result.
#' @return Data frame with columns `recipe_id` (character) and `year`
#' (integer). Returns an empty data frame (`recipe_id = character(0)`,
#' `year = integer(0)`) when `gap_years` or `candidates` is empty, so
#' callers can safely reference `$recipe_id`.
#' @noRd
.query_covered_years <- function(con, candidates, cohort, gap_years) {
if (length(gap_years) == 0L || length(candidates) == 0L) {
return(data.frame(recipe_id = character(0), year = integer(0)))
}
res <- DBI::dbGetQuery(con, sprintf(
"SELECT DISTINCT r.recipe_id, l.year
FROM long l
JOIN harmonization_recipes r
ON l.item_code = r.component_code
AND l.year BETWEEN r.year_min AND r.year_max
AND (r.gov_type_scope = 'all'
OR (r.gov_type_scope = 'state' AND l.type = 0)
OR (r.gov_type_scope = 'local' AND l.type BETWEEN 1 AND 3))
WHERE r.recipe_id IN (%s)
AND %s
AND l.year IN (%s)",
.sql_lit_chr(candidates), .cohort_sql(cohort, "l.canonical_govid"),
paste(gap_years, collapse = ",")
))
res$year <- as.integer(res$year)
res
}
#' Query recipe metadata: labels and year spans for a set of candidate
#' recipes.
#'
#' @param con Active DuckDB connection.
#' @param candidates Character vector of recipe IDs to look up.
#' @return Tibble with columns `recipe_id`, `label`, `year_min` (int), and
#' `year_max` (int).
#' @noRd
.query_recipe_meta <- function(con, candidates) {
tibble::as_tibble(DBI::dbGetQuery(con, sprintf(
"SELECT recipe_id, any_value(label) AS label,
MIN(year_min) AS year_min, MAX(year_max) AS year_max
FROM harmonization_recipes
WHERE recipe_id IN (%s)
GROUP BY recipe_id",
.sql_lit_chr(candidates)
)))
}
#' Attach `ig_recipe_id` to each suggestion: the intergovernmental-expenditure
#' recipe (an M-to-local or L-to-state recipe) whose component codes cover
#' exactly the same set of function suffixes as the firing recipe's own
+10 -3
View File
@@ -227,7 +227,8 @@ A statewide total resting on a fifth of the universe looks exactly like one
resting on all of it, so every multi-government result now says which it is:
```r
attr(rollup, "provenance")$coverage # per-year n_units_reporting, is_census_year
attr(rollup, "provenance")$coverage
# per-year n_units_expected, n_units_collected, n_units_reporting, is_census_year
```
`cog_geographic_rollup()`, `cog_peer_compare()` and `cog_find_peers()` take a
@@ -235,8 +236,14 @@ attr(rollup, "provenance")$coverage # per-year n_units_reporting, is_census_ye
`"consistent"` (only units reporting in every requested year, a balanced
panel).
`n_units_reporting` is **category-conditional**, and it is not a response rate. A government that was surveyed and genuinely spends
nothing in the requested category is indistinguishable from one never surveyed.
`n_units_reporting` is **category-conditional**: it counts governments with
rows for the *specific* category you asked for, so a government that was
surveyed and genuinely spends nothing in that category is indistinguishable
from one never surveyed — it is not a response rate on its own.
`n_units_collected` is the number that separates them: governments present in
the corpus that year for *any* category. `n_units_collected / n_units_expected`
is the true collection rate; `n_units_reporting / n_units_collected` is
category participation among collected units.
### Absent cells mean two different things
-14
View File
@@ -1,14 +0,0 @@
# Project journal
Append-only, newest first. **Entries are never edited** — the value of this file is
that it records what was believed at the time, including the parts that turned out
wrong. Where things stand *today* is in `STATUS.md`, which is generated.
Four lines per entry. The analysis belongs in the issue or the decision record; this
file carries the reasoning and the pointers.
- **Why** — the driver. The one line git cannot reconstruct later.
- **Obligates** — issues this change created elsewhere. Numbers, not prose.
- **Refs** — commits, issues, decision records.
---
-73
View File
@@ -1,73 +0,0 @@
# Project status
> Between the compass markers is generated. Edit the sources, not this.
<!-- compass:begin -->
<!-- compass:board -->
## Where this stands
uscogdata is at 0.4.0 and its public surface is settled: the query verbs, the cohort
predicates added in this release, and the provenance contract every verb returns.
The six open issues split cleanly. Two are API work carried out of the #9 review pass
and deliberately deferred there rather than fixed in that branch. Three concern the
corpus layer, and the largest of them, partition-level caching, was named the single
highest-leverage change on the remote path before being deferred. One, the
data-correction intake, is a decision rather than a task: it was parked during the
0.3.0 design and it gates the API announcement, because without it the corpus cannot
make the "traceable and correctable" claim that most distinguishes it from Census's
own files.
Nothing here is blocked on anything else, so the ordering is a judgement about value
rather than a dependency graph.
## Ready to work on next
- **#34** cog_revenue() offers expenditure recipes as suggestions: scope the candidate query by category_type · `ws/api` — nothing is blocking it; something is currently wrong
- **#36** n_units_reporting is category-conditional and cannot be read as a response rate · `ws/corpus` — nothing is blocking it; owed work from an earlier change
- **#2** Extend population data to be households as an alternate spending denominator · `ws/corpus` — nothing is blocking it
- **#33** Decompose .build_suggestions() (106 lines) into named helpers · `ws/api` — nothing is blocking it
- **#52** Release 11/11: design the data-correction intake (deferred; gates the API announcement) · `ws/corpus` — nothing is blocking it
- **#64** Partition-level caching: R/cache.R is still a stub, and the remote path pays for it every session · `ws/corpus` — nothing is blocking it
## Workstreams
| Stream | Commits since | Open | Debt | Owes docs |
|---|---|---|---|---|
| Query verbs and results | 77 | 2 | 0 | no |
| Corpus, mirror, provenance | 39 | 4 | 1 | no |
| Vignettes and guides | 34 | 0 | 0 | **yes** |
## CI
![R-CMD-check](https://gitea.civilytics.org/Civilytics/uscogdata/actions/workflows/ci.yml/badge.svg?branch=main)
![Mirror to GitHub](https://gitea.civilytics.org/Civilytics/uscogdata/actions/workflows/mirror-github.yml/badge.svg?branch=main)
<details>
<summary>Dependency graph and detail</summary>
```mermaid
graph TD
I34["#34 cog_revenue() offers expenditure recipes as sug…"]
I36["#36 n_units_reporting is category-conditional and c…"]
I2["#2 Extend population data to be households as an a…"]
I33["#33 Decompose .build_suggestions() (106 lines) into…"]
I52["#52 Release 11/11: design the data-correction intak…"]
I64["#64 Partition-level caching: R/cache.R is still a s…"]
class I34 ready;
class I36 ready;
class I2 ready;
class I33 ready;
class I52 ready;
class I64 ready;
classDef ready fill:#dafbe1,stroke:#2da44e;
```
- Marker: `none` (no journal entry yet)
- Commits since: 164
- Open issues: 6
</details>
<!-- compass:end -->
+23 -12
View File
@@ -55,11 +55,13 @@ Direct spending); `"primary"` and `"direct"` combine safely.}
a balanced panel.
Regardless of mode, `provenance$coverage` always carries per-year
`n_units_reporting`, `n_units_expected` and `is_census_year`, and
`provenance$coverage_mode` records the mode. `is_census_year` is a
statement about the **survey calendar**, never a claim of completeness:
FY1967 is a census year in which only 97 of Wisconsin's 608 cities
report. `n_units_reporting` is the number that tells the truth.}
`n_units_expected`, `n_units_collected`, `n_units_reporting` and
`is_census_year`, and `provenance$coverage_mode` records the mode.
`is_census_year` is a statement about the **survey calendar**, never a
claim of completeness: FY1967 is a census year in which only 97 of
Wisconsin's 608 cities report. `n_units_reporting` is
category-conditional and is not a response rate on its own -- see
"Reading `coverage`" below for what each counter answers.}
}
\value{
Tibble with columns `year`, `layer`, `canonical_govid`, `gov_name`,
@@ -86,14 +88,23 @@ by design — see `vignette('population-denominators')`.
}
\section{Reading `coverage`}{
`provenance$coverage` reports `n_units_reporting` against
`n_units_expected` per year. **`n_units_reporting` is category-conditional:
it counts governments with rows for the category you asked for, not
governments collected that year.** A government that was surveyed and
genuinely spends nothing in that category is indistinguishable here from one
that was never surveyed.
`provenance$coverage` carries three per-year counters:
* `n_units_expected` -- how many governments you asked about.
* `n_units_collected` -- how many of those appear in the corpus at all
that year (in ANY category), separating sampling from real zeros.
* `n_units_reporting` -- how many have rows for the SPECIFIC category you
requested. This is always <= n_units_collected: a government can be
collected but have no rows for "Police" because it contracts policing
to the county sheriff, not because it wasn't surveyed.
**`n_units_reporting` is category-conditional** and therefore **not a
response rate**: `n_units_reporting / n_units_expected` conflates sampling
(never collected) with real zeros (collected but spends nothing in your
category). Use `n_units_collected / n_units_expected` for the true
collection rate, and `n_units_reporting / n_units_collected` for category
participation among collected units.
The ratio is therefore **not a response rate** and must not be used as one.
In FY2022 — a complete census year — Georgia reports 393 of 567 cities for
`category = "Police"`; the 174-city gap is overwhelmingly cities that
contract policing to the county sheriff, not non-response.
+23 -12
View File
@@ -50,11 +50,13 @@ safely.}
a balanced panel.
Regardless of mode, `provenance$coverage` always carries per-year
`n_units_reporting`, `n_units_expected` and `is_census_year`, and
`provenance$coverage_mode` records the mode. `is_census_year` is a
statement about the **survey calendar**, never a claim of completeness:
FY1967 is a census year in which only 97 of Wisconsin's 608 cities
report. `n_units_reporting` is the number that tells the truth.
`n_units_expected`, `n_units_collected`, `n_units_reporting` and
`is_census_year`, and `provenance$coverage_mode` records the mode.
`is_census_year` is a statement about the **survey calendar**, never a
claim of completeness: FY1967 is a census year in which only 97 of
Wisconsin's 608 cities report. `n_units_reporting` is
category-conditional and is not a response rate on its own -- see
"Reading `coverage`" below for what each counter answers.
The comparison target is exempt from `"consistent"` balancing -- it is the
subject of the comparison, not a member of the cohort -- and the
@@ -109,14 +111,23 @@ them.
}
\section{Reading `coverage`}{
`provenance$coverage` reports `n_units_reporting` against
`n_units_expected` per year. **`n_units_reporting` is category-conditional:
it counts cohort members with rows for the category you asked for, not
cohort members collected that year.** A government that was surveyed and
genuinely spends nothing in that category is indistinguishable here from one
that was never surveyed.
`provenance$coverage` carries three per-year counters:
* `n_units_expected` -- how many governments you asked about.
* `n_units_collected` -- how many of those appear in the corpus at all
that year (in ANY category), separating sampling from real zeros.
* `n_units_reporting` -- how many have rows for the SPECIFIC category you
requested. This is always <= n_units_collected: a government can be
collected but have no rows for "Police" because it contracts policing
to the county sheriff, not because it wasn't surveyed.
**`n_units_reporting` is category-conditional** and therefore **not a
response rate**: `n_units_reporting / n_units_expected` conflates sampling
(never collected) with real zeros (collected but spends nothing in your
category). Use `n_units_collected / n_units_expected` for the true
collection rate, and `n_units_reporting / n_units_collected` for category
participation among collected units.
The ratio is therefore **not a response rate** and must not be used as one.
In FY2022 — a complete census year — Georgia reports 393 of 567 cities for
`category = "Police"`; the 174-city gap is overwhelmingly cities that
contract policing to the county sheriff, not non-response.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
# Project journal
Append-only, newest first. **Entries are never edited** — the value of this file is
that it records what was believed at the time, including the parts that turned out
wrong. Where things stand *today* is in `STATUS.md`, which is generated.
Four lines per entry. The analysis belongs in the issue or the decision record; this
file carries the reasoning and the pointers.
- **Why** — the driver. The one line git cannot reconstruct later.
- **Obligates** — issues this change created elsewhere. Numbers, not prose.
- **Refs** — commits, issues, decision records.
---
## 2026-09-30 · repo · moved out of Nextcloud; code syncs only through Gitea
**Why:** Nextcloud was syncing this repo's `.git`, which risks conflict copies inside
it and stalls the client's cold scan across dozens of repos. Nothing untracked is
data, so nothing is linked (`--no-data`); the move changed no test result (1,101
pass, 2 skip before and after).
**Obligates:** #73, #74
**Refs:** 4bedf85 (ci/apt-https pushed to clear the move's preflight), 63b421a
## 2026-09-09 · ci · Linux R-CMD-check legs unblocked (recorded 2026-09-30)
**Why:** GitHub's ubuntu-latest image carries a google-chrome apt source that
intermittently fails its own hash check, and setup-r's `apt-get update` died on it for
every R version; the source is now found by URL and removed, and the step fails
loudly if it survives.
**Obligates:** (none)
**Refs:** d83301b, d1165a5
## 2026-09-09 · model · #36 coverage counter finished, two bugs caught before merge
**Why:** the drafted n_units_collected fix (uncommitted) derived its candidate
cohort from category-filtered results instead of the caller's full expected
cohort, and hardcoded a view name absent below schema_version 5 -- both
silent on the fixture, both would have shipped without an independent review
pass before merge.
**Obligates:** #72
**Refs:** #36, b41d5ee, PR#71
## 2026-09-09 · model · #33/#34 split into independently-tested commits
**Why:** #33's own branch had its tests sitting uncommitted, and quietly bundled
a behavior change (#34) into what its commit message called a pure refactor;
splitting them let each pass CI with its own tests instead of merging on a
false "tests pass" claim.
**Obligates:** (none)
**Refs:** #33, #34, 0c7c7eb, 392643b, d0d724c, 9f5cd98, PR#69, PR#70
+51
View File
@@ -0,0 +1,51 @@
# Project status
> Between the compass markers is generated. Edit the sources, not this.
<!-- compass:begin -->
<!-- compass:board -->
## Where this stands
uscogdata's code now lives only in Gitea; the old Nextcloud folder keeps just build
output and working files. The move changed no test result: 1,101 pass and the two
opt-in live-corpus tests skip, before and after. It turned up one defect, #73: the
fixture's reference docs have never been in git, because a `.gitignore` rule matches
too broadly. Next in line are #73 and #72 (a vignette for the coverage counters), with
#52 (data-correction intake) and #74 (the stale state section in `CLAUDE.md`) waiting
on a decision.
## Ready to work on next
- **#73** The fixture corpus's docs/ never reaches git: .gitignore's docs/ rule is unanchored · `ws/corpus` — nothing is blocking it; something is currently wrong
- **#72** docs: add a vignette explaining provenance$coverage counters · `ws/docs` — nothing is blocking it; owed work from an earlier change
- **#2** Extend population data to be households as an alternate spending denominator · `ws/corpus` — nothing is blocking it
- **#64** Partition-level caching: R/cache.R is still a stub, and the remote path pays for it every session · `ws/corpus` — nothing is blocking it
- **#74** CLAUDE.md's Current State section is frozen at 2026-08-03 · `ws/docs` — waiting on a person, not on other work
- **#52** Release 11/11: design the data-correction intake (deferred; gates the API announcement) · `ws/corpus` — waiting on a person, not on other work
## Workstreams
| Stream | Commits since | Open | Debt | Owes docs |
|---|---|---|---|---|
| Query verbs and results | 0 | 0 | 0 | no |
| Corpus, mirror, provenance | 0 | 4 | 0 | no |
| Vignettes and guides | 0 | 2 | 2 | no |
## CI
![R-CMD-check](https://gitea.civilytics.org/Civilytics/uscogdata/actions/workflows/ci.yml/badge.svg?branch=main)
![Mirror to GitHub](https://gitea.civilytics.org/Civilytics/uscogdata/actions/workflows/mirror-github.yml/badge.svg?branch=main)
<details>
<summary>Dependency graph and detail</summary>
_Nothing blocks anything else, so there is no graph to draw._
- Marker: `9adb9211` (2026-09-09)
- Commits since: 3
- Open issues: 6
</details>
<!-- compass:end -->
+108
View File
@@ -48,6 +48,13 @@ test_that("multi-government aggregates disclose reporting coverage on every resu
expect_equal(cov$n_units_reporting, c(152L, 597L, 112L, 114L))
expect_equal(cov$is_census_year, c(FALSE, TRUE, FALSE, FALSE))
# uscogdata#36: with category = NULL (no category scope), "reported at
# all" and "collected" are the same question, so n_units_collected must
# equal n_units_reporting exactly here. This case alone cannot catch a
# regression in HOW n_units_collected is computed, though: see the
# category-scoped test below for that.
expect_equal(cov$n_units_collected, cov$n_units_reporting)
# Cross-check against the raw partitions, scoped to the SAME universe the
# rollup was given -- the 608 govids above. Scoping instead on the long
# table's own `type`/`fips_state` asks a different question and answers 595:
@@ -80,6 +87,8 @@ test_that("multi-government aggregates disclose reporting coverage on every resu
expect_equal(cov_peers$n_units_expected, rep(15L, 3L))
expect_equal(cov_peers$n_units_reporting, c(15L, 3L, 3L))
expect_equal(cov_peers$is_census_year, c(TRUE, FALSE, FALSE))
# uscogdata#36: same identity as the rollup case above, category = NULL.
expect_equal(cov_peers$n_units_collected, cov_peers$n_units_reporting)
# -- the three coverage modes --------------------------------------------
expect_equal(attr(cog_peer_compare(target_govid = chilton, peers = peers,
@@ -101,3 +110,102 @@ test_that("multi-government aggregates disclose reporting coverage on every resu
coverage = "census")
expect_equal(sort(unique(census_only$year)), 2012)
})
test_that("n_units_collected separates sampling from real zeros, category-scoped (uscogdata#36)", {
# The motivating case from the issue: Wisconsin cities, category = "Police".
# FY2012 is a complete census year -- collection is not partial -- yet a
# category-conditional n_units_reporting alone reads like a sampling gap.
# n_units_collected must diverge from n_units_reporting here, unlike the
# category = NULL cases above, because most of the FY2012 gap is cities
# that contract policing to the county sheriff (collected, real zero), not
# cities Census never surveyed.
wi <- cog_gov_search(name = NULL, state = "WI", type = "city")
roll <- suppressMessages(cog_geographic_rollup(
govids = list(city = wi$canonical_govid), category = "Police",
years = c(2011L, 2012L, 2019L, 2020L)))
cov <- wt_coverage(roll)
expect_equal(cov$n_units_expected, rep(608L, 4L))
expect_equal(cov$n_units_collected, c(152L, 597L, 112L, 114L))
expect_equal(cov$n_units_reporting, c(152L, 485L, 109L, 111L))
# The pair the issue actually wants: collected/expected is the true
# collection rate (98% in the FY2012 census year, matching the raw
# cross-check above); reporting/collected is category participation among
# collected units (81% -- most of the gap is real, not sampling).
expect_equal(round(cov$n_units_collected[cov$year == 2012] /
cov$n_units_expected[cov$year == 2012], 2), 0.98)
expect_equal(round(cov$n_units_reporting[cov$year == 2012] /
cov$n_units_collected[cov$year == 2012], 2), 0.81)
# Every year: collected is bounded between reporting and expected.
expect_true(all(cov$n_units_collected >= cov$n_units_reporting))
expect_true(all(cov$n_units_collected <= cov$n_units_expected))
})
test_that(".coverage_table() candidates a government collected-but-absent from the category result (uscogdata#36)", {
# Direct regression test for the mechanism itself: n_units_collected's
# candidate list must be the caller's full expected cohort (expected_ids),
# never derived from `result`/`rows`. A government with zero rows in the
# requested category across every requested year never appears in
# `result` at all, so deriving candidates from `result` would silently
# drop exactly the "collected but real zero" governments this counter
# exists to count -- collapsing it back to n_units_reporting.
con <- uscogdata:::.ensure_session()
# A real fixture govid, present in spending_long_harmonized for 2019 (in
# SOME category), but absent from this fake category-specific `result`.
govid <- "011021100004"
fake_result <- data.frame(canonical_govid = character(0), year = integer(0))
cov <- uscogdata:::.coverage_table(
fake_result, years = 2019L, n_expected = 1L,
con = con, long_view = "spending_long_harmonized",
expected_ids = govid
)
expect_equal(cov$n_units_collected, 1L)
expect_equal(cov$n_units_reporting, 0L)
# Without a connection, long_view, or expected_ids, the lookup is skipped
# rather than silently wrong.
no_con <- uscogdata:::.coverage_table(fake_result, years = 2019L, n_expected = 1L)
expect_true(is.na(no_con$n_units_collected))
no_ids <- uscogdata:::.coverage_table(
fake_result, years = 2019L, n_expected = 1L,
con = con, long_view = "spending_long_harmonized"
)
expect_true(is.na(no_ids$n_units_collected))
})
test_that("n_units_collected uses the resolved basis's long view, not a hardcoded harmonized one (uscogdata#36)", {
# spending_long_harmonized only exists when schema_version >= 5 (R/views.R
# gates the harmonization views on it); on an older corpus cog_spending()
# resolves basis = "raw" and queries spending_long instead. The coverage
# lookup must follow the SAME resolved basis, not a literal
# "spending_long_harmonized", or it hard-errors with a DuckDB catalog
# error on every schema_version < 5 corpus -- a vintage the package
# otherwise explicitly still supports (see test-manifest.R's dual-accept
# tests).
skip_if_no_corpus()
with_doctored_schema_version(4L, {
con <- cog_open()
ids <- DBI::dbGetQuery(con,
"SELECT DISTINCT canonical_govid FROM spending_long WHERE year = 2011 LIMIT 3"
)$canonical_govid
expect_gte(length(ids), 3L)
roll <- suppressMessages(cog_geographic_rollup(
list(city = ids), category = NULL, years = 2011L))
expect_equal(attr(roll, "provenance")$basis, "raw")
cov <- attr(roll, "provenance")$coverage
expect_false(is.na(cov$n_units_collected))
expect_equal(cov$n_units_collected, length(ids))
cmp <- suppressMessages(cog_peer_compare(
target_govid = ids[1], peers = ids[-1], category = NULL, years = 2011L))
expect_equal(attr(cmp, "provenance")$basis, "raw")
cov_peers <- attr(cmp, "provenance")$coverage
expect_false(is.na(cov_peers$n_units_collected))
})
})
+7 -5
View File
@@ -315,15 +315,17 @@ test_that("a mis-scoped cog_spending() call never attaches an M/L counterpart to
# (SB194, cog_pipeline#64), so the recipe stopped being a candidate there.
# FL state carries a real FY2011 B47 amount, so this exercises the guard
# against a suggestion that genuinely fires.
#
# Issue #34: "IG Federal" maps to B-prefixed codes in summary_categories
# with category_type = 'revenue'. A spending verb (flow_prefixes E/F/G)
# now scopes its candidate query by category_type = 'expenditure', so it
# correctly finds NO candidates for this revenue-only category -- the
# suggestion machinery cannot fire, and no M/L counterpart is attached.
r <- suppressMessages(
cog_spending("120000226351", years = c(2005, 2011), category = "IG Federal")
)
sugg <- attr(r, "provenance")$suggestions
expect_gt(length(sugg), 0L)
ids <- vapply(sugg, function(s) s$recipe_id %||% "", character(1))
expect_true("ig_federal_b47_wide" %in% ids)
ig <- unlist(lapply(sugg, function(s) s$ig_recipe_id))
expect_length(ig, 0L)
expect_length(sugg, 0L)
})
test_that("C1: 'total' on a legacy aggregate-only family reports the IG-only figure honestly, not as Direct + IG", {
+14
View File
@@ -118,3 +118,17 @@ test_that("cog_explain prints denominator + popyear_range + counts", {
expect_false(grepl("popyear range: 19-20", out, fixed = TRUE))
})
})
test_that("cog_explain reports units collected alongside units reporting (uscogdata#36)", {
skip_if_no_corpus()
wi <- cog_gov_search(name = NULL, state = "WI", type = "city")
roll <- suppressMessages(cog_geographic_rollup(
govids = list(city = wi$canonical_govid), category = "Police",
years = 2012L))
out <- paste(c(
capture.output(cog_explain(roll)),
capture.output(cog_explain(roll), type = "message")
), collapse = "\n")
expect_true(grepl("597 of 608 units collected", out, fixed = TRUE))
expect_true(grepl("485 reporting in this category", out, fixed = TRUE))
})
+32 -21
View File
@@ -371,34 +371,45 @@ test_that("uscogdata#9: the revenue verb inherits the same trigger", {
expect_null(sugg[[1]]$ig_recipe_id)
})
test_that("I1: cog_revenue never fabricates suppressed dollars for an expenditure-only recipe", {
test_that("I1 + #34: cog_revenue never suggests expenditure-only recipes", {
# uscogdata#9 review, finding I1: Corrections is an expenditure-only
# category (E04/E05). cog_revenue() naturally returns zero rows for it, so
# corrections_combined still fires as an empty_year suggestion (its own
# generic join finds real E04/E05 data for this government) -- but before
# the flow_prefixes fix, .suppressed_components() measured E04/E05 against
# cog_revenue()'s OWN view (which can never contain an E-coded row by
# construction) and reported the full $3,631,945,000 as "suppressed",
# when cog_spending() for the same gov/years/category actually returns
# $3,691,029,000 -- nothing was suppressed at all.
# category (E04/E05). Before the flow_prefixes fix (#9), .suppressed_components()
# measured E04/E05 against cog_revenue()'s OWN view and reported $3.6B as
# "suppressed" -- nothing was suppressed at all.
#
# Issue #34 builds on that: the candidate query now also filters by
# category_type ('revenue'), so expenditure-only recipes like corrections_combined
# (whose components E04/E05 are classified as 'expenditure' in summary_categories)
# are never even considered for a revenue verb. This is stronger than just
# suppressing the dollar claim -- it prevents the suggestion from firing at all.
skip_if_no_corpus()
r <- suppressMessages(
cog_revenue("061037123085", years = 2019:2020, category = "Corrections"))
sugg <- attr(r, "provenance")$suggestions
ids <- vapply(sugg, function(s) s$recipe_id, character(1))
expect_true("corrections_combined" %in% ids)
ids <- vapply(sugg, function(s) s$recipe_id %||% "", character(1))
hit <- sugg[[which(ids == "corrections_combined")]]
expect_equal(hit$suppressed_amount, 0)
expect_equal(hit$suppressed_years, integer(0))
expect_equal(hit$suppressed_codes, character(0))
# corrections_combined should NOT appear -- its components are expenditure-only.
expect_false("corrections_combined" %in% ids)
})
# And cog_spending() for the identical gov/years/category is unaffected --
# it actually finds the E04/E05 dollars the buggy measurement claimed were
# excluded.
sp <- suppressMessages(
cog_spending("061037123085", years = 2019:2020, category = "Corrections"))
expect_equal(sum(sp$amt_nominal), 3691029000)
test_that(".query_candidate_recipes() scopes candidates by category_type (#34)", {
# Direct assertion on the mechanism the two tests above exercise
# end-to-end: corrections_combined's own components (E04/E05) are
# category_type = 'expenditure' in summary_categories, so an
# expenditure-flavored flow_prefixes call must surface it and a
# revenue-flavored one must not. This queries only summary_categories/
# harmonization_recipes (no government data), so it runs against the
# bundled fixture with no skip_if_no_corpus() needed.
con <- uscogdata:::.ensure_session()
expenditure <- uscogdata:::.query_candidate_recipes(
con, category = "Corrections", flow_prefixes = c("E", "F", "G"))
expect_true("corrections_combined" %in% expenditure)
revenue <- uscogdata:::.query_candidate_recipes(
con, category = "Corrections",
flow_prefixes = c("T", "A", "U", "B", "C", "D"))
expect_false("corrections_combined" %in% revenue)
})
test_that("uscogdata#9: no partial-coverage fire in a modern year", {