Final-review findings F-1, F-2, F-6, F-8 (plus the F-9 @return reword,
which shares R/balances.R).
F-2: .validate_balance_inputs() checked 2 of cog_balances()' 7 arguments.
years = integer(0) leaked a raw DuckDB 'Parser Error ... AND year IN ()'
with the generated SQL echoed back; govid = character(0) and a non-character
category returned 0 rows with no error at all; recipe = c("a","b") threw
'the condition has length > 1' from inside .validate_recipe_id(). Replaced
with a call to the money verbs' own .validate_verb_inputs() (R/spending.R),
which validates the exact superset needed. Deleted the local copy rather
than extending it -- two validators is how they drift. Placed AFTER
.coerce_govid_input(), because .validate_verb_inputs() asserts
is.character(govid) and a data-frame govid is not unwrapped before that.
This is helper reuse of the same kind as .build_verb_sql()/.attach_per_capita();
the verb still does NOT route through .verb_spendrev().
F-1: falls out of F-2 for free -- the recipe/category mutual-exclusivity
guard lives inside .validate_verb_inputs(). Previously recipe silently
discarded category AND overwrote provenance$category with the recipe label,
so a caller asking for Fund Balances got X40/Z77 insurance-trust holdings
with no trace of the dropped filter.
F-6: cog_explain() rendered every provenance caveat block except
balance_caveats. Since .emit_balance_caveats() fires at most once per
session -- and is routinely consumed by a suppressMessages() call or an
unread knitr chunk -- cog_explain() is the only surface left for a caller
who deliberately audits the result. Added a 'Holdings caveats' section
guarded on !is.null(prov$balance_caveats). Also relabels the cosmetic
'Concept: NA' line on balance results as 'not applicable (holdings are a
stock, not a flow)'.
F-8: the coverage-window query has no govid and no year predicate -- its
answer depends only on the mounted corpus -- yet it scanned all of
balance_long on every call (35% of verb runtime on the fixture, and a
per-request throughput ceiling for cog-api#26). Memoised in
.uscogdata_env$balance_coverage_windows, invalidated by cog_close(), the
same pattern as .uscogdata_env$manifest.
123 lines
4.4 KiB
R
123 lines
4.4 KiB
R
# R/balance_caveats.R
|
|
#
|
|
# The four caveats from cog_pipeline/docs/data_dictionary.md § Cash and
|
|
# security holdings. Each one silently invalidates an obvious analysis, so
|
|
# they travel in provenance (machine-readable, for cog-api#26) rather than
|
|
# living only in prose.
|
|
#
|
|
# Two of the four are already carried by the code-driven series-break
|
|
# builders and are deliberately NOT duplicated here:
|
|
# * SB195/SB196 -- X40/X41 book -> market at FY2002 -- fire via
|
|
# series_break_refs on the recipe path, the only path that observes those
|
|
# codes.
|
|
# What remains is the GAAP distinction (a constant) and the coverage windows
|
|
# (measured, never hardcoded, so they stay correct as the corpus grows).
|
|
|
|
#' Per-subtype observed year extents, plus which requested families are
|
|
#' truncated relative to the requested span.
|
|
#' @noRd
|
|
.balance_caveats <- function(con, codes_observed, years) {
|
|
cw <- .balance_coverage_windows(con)
|
|
|
|
observed_subtypes <- if (length(codes_observed) == 0L) {
|
|
character(0)
|
|
} else {
|
|
DBI::dbGetQuery(con, sprintf(
|
|
"SELECT DISTINCT balance_subtype FROM summary_categories
|
|
WHERE item_code IN (%s) AND balance_subtype IS NOT NULL",
|
|
.sql_lit_chr(codes_observed)
|
|
))$balance_subtype
|
|
}
|
|
|
|
# A family is "truncated" when the caller asked for years outside the span
|
|
# that family actually covers -- the FY2016 employee-retirement termination
|
|
# and the FY2021 end of the W family are both this shape.
|
|
truncated <- character(0)
|
|
if (length(years) > 0L) {
|
|
for (s in observed_subtypes) {
|
|
w <- cw[[s]]
|
|
if (is.null(w)) next
|
|
if (max(years) > w[2] || min(years) < w[1]) truncated <- c(truncated, s)
|
|
}
|
|
}
|
|
|
|
list(
|
|
not_gaap = TRUE,
|
|
not_gaap_note = paste0(
|
|
"Census holdings are gross -- no liabilities are netted -- and are NOT ",
|
|
"GAAP fund balance. A reserve ratio built from them overstates what is ",
|
|
"actually available."
|
|
),
|
|
coverage_window = cw,
|
|
truncated = sort(unique(truncated))
|
|
)
|
|
}
|
|
|
|
#' Per-subtype [min year, max year] extents for EVERY balance subtype in the
|
|
#' mounted corpus, memoised for the session.
|
|
#'
|
|
#' The query carries no govid and no year predicate -- its answer is a property
|
|
#' of the mounted corpus alone and cannot change between calls -- but it scans
|
|
#' the whole of `balance_long`, which measured 35% of `cog_balances()` runtime
|
|
#' on the bundled fixture and would be a per-request throughput ceiling once
|
|
#' cog-api#26 serves this verb over HTTP. Memoised in `.uscogdata_env` and
|
|
#' invalidated by `cog_close()`, the same pattern as `.uscogdata_env$manifest`.
|
|
#'
|
|
#' Scope is deliberately corpus-wide rather than query-scoped: a caller asking
|
|
#' "is there a family I missed?" needs every window. The observed-scoped field
|
|
#' is `truncated`. Documented as such in inst/schemas/provenance-v1.json.
|
|
#' @noRd
|
|
.balance_coverage_windows <- function(con) {
|
|
cached <- .uscogdata_env$balance_coverage_windows
|
|
if (!is.null(cached)) return(cached)
|
|
|
|
windows <- DBI::dbGetQuery(con,
|
|
"SELECT c.balance_subtype AS subtype,
|
|
MIN(l.year) AS year_min,
|
|
MAX(l.year) AS year_max
|
|
FROM balance_long l
|
|
JOIN summary_categories c USING (item_code)
|
|
WHERE c.balance_subtype IS NOT NULL
|
|
GROUP BY 1
|
|
ORDER BY 1"
|
|
)
|
|
|
|
cw <- stats::setNames(
|
|
lapply(seq_len(nrow(windows)),
|
|
function(i) as.integer(c(windows$year_min[i], windows$year_max[i]))),
|
|
windows$subtype
|
|
)
|
|
.uscogdata_env$balance_coverage_windows <- cw
|
|
cw
|
|
}
|
|
|
|
#' TRUE the first time `key` is seen this session, FALSE thereafter.
|
|
#' Reset by cog_close().
|
|
#' @noRd
|
|
.balance_caveat_once <- function(key) {
|
|
seen <- .uscogdata_env$balance_caveats_shown
|
|
if (is.null(seen)) seen <- character(0)
|
|
if (key %in% seen) return(FALSE)
|
|
.uscogdata_env$balance_caveats_shown <- c(seen, key)
|
|
TRUE
|
|
}
|
|
|
|
#' Emit at most one message per caveat class per session.
|
|
#' @noRd
|
|
.emit_balance_caveats <- function(caveats) {
|
|
if (.balance_caveat_once("not_gaap")) {
|
|
cli::cli_inform(c(
|
|
"!" = "Census holdings are gross and are {.strong not} GAAP fund balance.",
|
|
"i" = "No liabilities are netted; a reserve ratio built from them overstates available funds."
|
|
))
|
|
}
|
|
if (length(caveats$truncated) > 0L &&
|
|
.balance_caveat_once("coverage_window")) {
|
|
cli::cli_inform(c(
|
|
"!" = "Requested years extend beyond what {.val {caveats$truncated}} actually covers.",
|
|
"i" = "See {.code provenance$balance_caveats$coverage_window}."
|
|
))
|
|
}
|
|
invisible(NULL)
|
|
}
|