Files
uscogdata/R/explain.R
jared d95c9032c5
R-CMD-check / check (pull_request) Successful in 3m13s
R-CMD-check / check (push) Successful in 3m18s
feat: coverage argument + always-on reporting-coverage metadata (#13)
The Census of Governments is a complete census only in years ending in 2 and
7. Every other year is a sample, and the sample varies enormously. Neither
cog_geographic_rollup() nor cog_peer_compare()/cog_find_peers() had any
concept of "the universe": each summed or labelled whichever govids happened
to have rows and returned that with nothing distinguishing "every government
reported" from "a fifth of them did".

On the bundled fixture, Wisconsin's 608-city universe rolls up 597
governments in FY2012 and 112 in FY2019. The peer side is worse exposure, not
better: a Madison-scale cohort looks stable because Madison is large, while
governments matched to a small target sit in exactly the population band the
sample cycle hits hardest. Chilton's 15-peer cohort reports 15 of 15 in
FY2012 and 3 of 15 in FY2019.

Implements the owner's settled design: coverage = c("all", "census",
"consistent") on all three verbs, defaulting to "all" so nothing currently
calling them changes, PLUS always-on provenance$coverage carrying per-year
n_units_reporting / n_units_expected / is_census_year and
provenance$coverage_mode. cog_explain() prints a "Reporting coverage"
section. The default mode can no longer mislead silently, which is the point
-- using these verbs correctly must not require knowing the survey calendar.

Decisions worth stating:

  - n_units_expected is the universe the CALLER named, not the national one.
    That is what makes the ratio mean something: "597 of the 608 Wisconsin
    cities you asked about". For peers it is the cohort size, counted over
    peer rows only -- including the target would inflate every count by one
    and make a cohort that has entirely stopped reporting look non-empty.

  - The coverage table is built from the REQUESTED years, not the years
    present in the result, so a year in which nothing reported still appears
    with n_units_reporting = 0. A year that vanishes silently is precisely
    the disclosure failure at issue.

  - "census" filters years BEFORE the query, and aborts when the range holds
    no census year rather than returning an empty result for a query the
    caller believes they made.

  - "consistent" exempts the peer-comparison target: it is the subject of the
    comparison, not a member of the cohort being balanced, and dropping it
    would leave nothing to compare. The summary_* quantiles are computed
    AFTER the filter so they describe the cohort actually returned.

  - is_census_year is documented as 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 (DoD 3). n_units_reporting is the number
    that tells the truth.

On cog_find_peers(), where there is no year range, coverage governs the
cohort VINTAGE: "census" snaps to the most recent census year with an
observed population, so a cohort is not built from a sample year in which
most of the candidate universe is absent. "consistent" is a comparison-time
concept and selects like "all" there, carried on the result for
cog_peer_compare().

One fix to the committed test, which was internally inconsistent. It pinned
n_units_reporting == 597 for FY2012 AND asserted that number equals a raw
cross-check that answers 595. Both numbers are right for different questions:
VERNON VILLAGE and WAUKESHA VILLAGE carry type = 3 in `long` (their
as-of-year identity, as townships) while the xwalk lists them as govs_type =
2 (their present identity, as villages) -- schema v6 made the long table's
geography present-harmonized but `type` still reads as-of-year. The rollup
counts against the requested govid set, so 597 answers "how many of the
governments I asked about reported". The cross-check now scopes to that same
universe instead of to long.type/long.fips_state; it still reads raw parquet
rather than going through the verb under test.

Suite: 670 pass / 0 fail / 2 skip (was 658/0/3). rcmdcheck clean.
The two remaining skips are #11 and #12.
2026-07-30 11:57:11 -04:00

245 lines
8.2 KiB
R

# R/explain.R
#' Explain a verb result's provenance
#'
#' Prints the structured provenance attached to a tibble returned by any
#' `cog_*` verb, or returns it as a list for downstream use (MCP tools,
#' dashboards, JSON export).
#'
#' @param result A tibble returned by a `cog_*` verb.
#' @param format `"print"` (default) for a human-readable cli summary;
#' returns `result` invisibly for chaining. `"list"` returns the raw
#' provenance list (identical to `attr(result, "provenance")`).
#' @return Either `result` (invisibly) or the provenance list.
#' @export
cog_explain <- function(result, format = c("print", "list")) {
format <- match.arg(format)
prov <- attr(result, "provenance")
if (is.null(prov)) {
cli::cli_abort(c(
"No `provenance` attribute on result.",
i = "Pass a tibble returned by a cog_* verb (e.g. cog_spending())."
))
}
if (format == "list") return(prov)
.print_provenance(prov)
invisible(result)
}
#' @noRd
.print_provenance <- function(prov) {
cli::cli_h1("{prov$verb}()")
tgt_ids <- paste(prov$target$canonical_govid, collapse = ", ")
tgt_names <- if (length(prov$target$gov_name) == 0L) {
"(no rows returned)"
} else {
paste(prov$target$gov_name, collapse = ", ")
}
cli::cli_text("Target: {tgt_names} [canonical_govid: {tgt_ids}]")
yrs <- prov$years
cli::cli_text(if (length(yrs) == 1L) {
"Year: {yrs}"
} else {
"Years: {min(yrs)}-{max(yrs)} ({length(yrs)} years)"
})
if (!is.null(prov$category)) {
cli::cli_text("Category: {paste(prov$category, collapse = ', ')}")
} else {
cli::cli_text("Category: (all)")
}
if (!is.null(prov$basis)) {
note <- if (!is.null(prov$basis_note) && !is.na(prov$basis_note)) {
sprintf(" (%s)", prov$basis_note)
} else {
""
}
cli::cli_text("Basis: {prov$basis}{note}")
}
if (!is.null(prov$expenditure_concept)) {
concept_note <- if (!is.null(prov$expenditure_concept_note) &&
!is.na(prov$expenditure_concept_note)) {
sprintf(" (%s)", prov$expenditure_concept_note)
} else {
""
}
cli::cli_text("Concept: {prov$expenditure_concept}{concept_note}")
if (isTRUE(prov$expenditure_concept_direct_suppressed)) {
cli::cli_alert_warning(
"Direct leg unavailable for at least one requested (year, category) -- affected rows report intergovernmental dollars alone, not Direct + IG. See each row's notes."
)
}
}
cli::cli_h2("Codes observed")
codes <- prov$codes_summed$observed
if (length(codes) == 0L) {
cli::cli_alert_info("No item codes matched.")
} else {
cli::cli_ul(codes)
}
if (isTRUE(prov$aggregate_fallback$applied)) {
cli::cli_h2("Aggregate fallback")
cli::cli_alert_warning(
"Aggregate fallback used for years: {paste(prov$aggregate_fallback$years, collapse = ', ')}"
)
}
h <- prov$harmonization
if (!is.null(h) && isTRUE(h$applied)) {
cli::cli_h2("Harmonization")
cli::cli_text(
"Excluded {h$na_rows_excluded} row(s) with no harmonized_code (${format(h$na_amount_excluded, big.mark = ',')})"
)
}
rc <- prov$recipe
if (!is.null(rc)) {
cli::cli_h2("Recipe")
cli::cli_text("{rc$recipe_id}: {rc$label}")
comp_lines <- vapply(rc$components, function(x) {
sprintf("%s (%s, %s-%s, weight=%s)", x$component_code, x$gov_type_scope,
x$year_min, x$year_max, x$weight)
}, character(1))
cli::cli_ul(comp_lines)
}
if (length(prov$suggestions) > 0L) {
cli::cli_h2("Suggestions")
sugg_lines <- vapply(prov$suggestions, function(s) {
sprintf("%s -- %s (years %s-%s): %s", s$recipe_id, s$label,
s$available_years[1], s$available_years[2], s$hint)
}, character(1))
cli::cli_ul(sugg_lines)
}
if (!is.null(prov$coverage) && nrow(prov$coverage) > 0L) {
cli::cli_h2("Reporting coverage")
cli::cli_text("Mode: {prov$coverage_mode %||% 'all'}")
cov <- prov$coverage
cli::cli_ul(sprintf(
"%d: %d of %d units reporting (%.0f%%) -- %s year",
cov$year, cov$n_units_reporting, cov$n_units_expected,
100 * cov$n_units_reporting / pmax(cov$n_units_expected, 1L),
ifelse(cov$is_census_year, "census", "sample")
))
if (any(!cov$is_census_year)) {
cli::cli_text(
"Note: the Census of Governments is a complete census only in years ending in 2 or 7; every other year is a sample."
)
}
}
if (isTRUE(prov$completion$applied)) {
cli::cli_h2("Completion")
cli::cli_text(
"Filled {prov$completion$rows_filled} absent cell(s) from the corpus code set."
)
rules <- prov$completion$absence_means
if (length(rules) > 0L) {
cli::cli_ul(vapply(names(rules), function(y) {
sprintf("%s: an absent cell means %s", y,
if (identical(rules[[y]], "census_zero")) {
"Census published $0 (filled as 0)"
} else {
"the government did not report (filled as NA, not 0)"
})
}, character(1)))
}
}
if (length(prov$series_break_refs) > 0L) {
cli::cli_h2("Series breaks")
cli::cli_ul(.series_break_story_lines(prov$series_break_refs))
}
# Kept in a section of its own: these qualify the whole result, so folding
# them in with the per-code breaks above would invite reading them as a
# caveat about one series.
if (length(prov$corpus_break_refs) > 0L) {
cli::cli_h2("Corpus-wide caveats")
cli::cli_ul(.series_break_story_lines(prov$corpus_break_refs))
}
cli::cli_h2("Transformations")
uc <- prov$transformations$units_conversion
if (isTRUE(uc$applied)) {
cli::cli_text("Units: {uc$source_unit} -> {uc$target_unit} (x{uc$multiplier})")
}
pc <- prov$transformations$per_capita
if (isTRUE(pc$applied)) {
cli::cli_text("Per-capita denominator: {pc$denominator_source}")
if (length(pc$popyear_range) == 2L) {
lo <- .expand_popyear(pc$popyear_range[1])
hi <- .expand_popyear(pc$popyear_range[2])
cli::cli_text(" popyear range: {lo}-{hi}")
}
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}"
)
}
}
infl <- prov$transformations$inflation
if (isTRUE(infl$applied)) {
cli::cli_text("Inflation: {infl$index}, base year {infl$base_year}")
}
cli::cli_h2("Scope")
cli::cli_text(
"Included gov types: {paste(prov$scope$gov_types_included, collapse = ', ')}"
)
cli::cli_text(
"Excluded gov types: {paste(prov$scope$gov_types_excluded, collapse = ', ')}"
)
if (nzchar(prov$scope$scope_note %||% "")) {
cli::cli_text("Note: {prov$scope$scope_note}")
}
cli::cli_h2("Data vintage")
cli::cli_text(
"Manifest schema v{prov$manifest$schema_version}, pipeline {prov$manifest$pipeline_commit}, built {prov$manifest$built_at}"
)
invisible(NULL)
}
# One "break-story" line per referenced break_id: "SB109 (2005): <join_advice>".
# Re-queries series_breaks_pq for the detail (break_year, join_advice) that
# provenance$series_break_refs deliberately doesn't carry (the schema keeps
# that field to a plain id array). Falls back to bare ids if no session is
# available (e.g. explaining a result after cog_close()) rather than
# erroring cog_explain() over a cosmetic detail.
#' @noRd
.series_break_story_lines <- function(break_ids) {
con <- tryCatch(.ensure_session(), error = function(e) NULL)
if (is.null(con) || !DBI::dbIsValid(con)) return(break_ids)
detail <- tryCatch(
DBI::dbGetQuery(con, sprintf(
"SELECT break_id, break_year, join_advice FROM series_breaks_pq
WHERE break_id IN (%s) ORDER BY break_id",
.sql_lit_chr(break_ids)
)),
error = function(e) NULL
)
if (is.null(detail) || nrow(detail) == 0L) return(break_ids)
sprintf("%s (%s): %s", detail$break_id, detail$break_year, detail$join_advice)
}
# Expand a 2-digit Census popyear (e.g. 19) to a 4-digit calendar year (2019).
# F-33 metadata stores popyear as 2 digits; pivot at 70 to handle a future
# corpus that ever spans pre-1970 vintages, though current scope is 2000+.
#' @noRd
.expand_popyear <- function(yy) {
yy <- as.integer(yy)
if (length(yy) == 0L || is.na(yy)) return(NA_integer_)
if (yy >= 100L) return(yy) # already 4-digit
if (yy < 70L) return(2000L + yy)
1900L + yy
}