feat(search): basket mode for cog_gov_search()
Vector name + state + type arguments dispatch to a per-row resolver that produces a basket tibble with a 'resolution' sidecar attribute. Utility mode (length-1 name) is unchanged.
This commit is contained in:
+96
-14
@@ -2,32 +2,45 @@
|
|||||||
|
|
||||||
#' Search for governments by name, state, and/or type
|
#' Search for governments by name, state, and/or type
|
||||||
#'
|
#'
|
||||||
#' Returns rows from `canonical_fips_xwalk` matching the supplied filters.
|
#' Two modes:
|
||||||
#' Intended as the entry point users call to resolve a human-readable place
|
|
||||||
#' name into one or more `canonical_govid` values before calling
|
|
||||||
#' [cog_spending()] / [cog_revenue()] / etc.
|
|
||||||
#'
|
#'
|
||||||
#' @param name Character regex matched case-insensitively against
|
#' * **Utility mode** (single `name`): returns all rows from
|
||||||
#' `gov_name`. `NULL` (default) means no name filter.
|
#' `canonical_fips_xwalk` whose `gov_name` matches the regex
|
||||||
#' @param state Either a 2-letter USPS abbreviation (e.g. `"FL"`), a FIPS
|
#' case-insensitively, sorted by `population_acs` descending.
|
||||||
#' integer (e.g. `12`), or `NULL`.
|
#' * **Basket mode** (`length(name) > 1`): resolves each input row to a
|
||||||
|
#' single canonical govid via exact-then-substring matching with
|
||||||
|
#' deterministic disambiguation. Returns up to `length(name)` rows in
|
||||||
|
#' input order plus a `"resolution"` sidecar attribute. See
|
||||||
|
#' [cog_basket_resolution()].
|
||||||
|
#'
|
||||||
|
#' @param name Character vector of place name(s). Length 1 = utility mode;
|
||||||
|
#' length >1 = basket mode.
|
||||||
|
#' @param state Either a 2-letter USPS abbreviation, a FIPS integer, or
|
||||||
|
#' `NULL`. Length 1 recycles across all entries in basket mode.
|
||||||
#' @param type Government type: an integer in `0:3` or one of `"state"`,
|
#' @param type Government type: an integer in `0:3` or one of `"state"`,
|
||||||
#' `"county"`, `"city"`, `"township"`. Passing `4`, `5`,
|
#' `"county"`, `"city"`, `"township"`, or `NA` (per-row optional in
|
||||||
#' `"special_district"`, or `"school_district"` emits an explanatory
|
#' basket mode). Passing `4`, `5`, `"special_district"`, or
|
||||||
#' message and returns an empty tibble (v0.1 corpus excludes those types).
|
#' `"school_district"` emits an explanatory message and returns an
|
||||||
#' @return Tibble from `canonical_fips_xwalk` sorted by `population_acs`
|
#' empty tibble (v0.1 corpus excludes those types).
|
||||||
#' descending (`NULL`s last).
|
#' @return Tibble from `canonical_fips_xwalk`. In utility mode, sorted by
|
||||||
|
#' `population_acs` descending (`NULL`s last). In basket mode, in input
|
||||||
|
#' order, with `attr(result, "resolution")` set to the sidecar tibble.
|
||||||
#' @export
|
#' @export
|
||||||
cog_gov_search <- function(name = NULL, state = NULL, type = NULL) {
|
cog_gov_search <- function(name = NULL, state = NULL, type = NULL) {
|
||||||
if (!is.null(type) && .is_excluded_type(type)) {
|
if (!is.null(type) && length(type) == 1L && .is_excluded_type(type)) {
|
||||||
cli::cli_inform(c(
|
cli::cli_inform(c(
|
||||||
i = "v0.1 covers gov_types 0-3 (state/county/city/township) only.",
|
i = "v0.1 covers gov_types 0-3 (state/county/city/township) only.",
|
||||||
i = "Types 4 (special districts) and 5 (school districts) are excluded; see vignette('coverage-scope')."
|
i = "Types 4 (special districts) and 5 (school districts) are excluded; see vignette('coverage-scope')."
|
||||||
))
|
))
|
||||||
return(.empty_xwalk_tibble())
|
return(.empty_xwalk_tibble())
|
||||||
}
|
}
|
||||||
|
|
||||||
con <- .ensure_session()
|
con <- .ensure_session()
|
||||||
|
|
||||||
|
if (length(name) > 1L) {
|
||||||
|
return(.resolve_basket(name = name, state = state, type = type, con = con))
|
||||||
|
}
|
||||||
|
|
||||||
preds <- character(0)
|
preds <- character(0)
|
||||||
if (!is.null(name)) {
|
if (!is.null(name)) {
|
||||||
if (!is.character(name) || length(name) != 1L) {
|
if (!is.character(name) || length(name) != 1L) {
|
||||||
@@ -266,3 +279,72 @@ cog_gov_search <- function(name = NULL, state = NULL, type = NULL) {
|
|||||||
candidates = matches
|
candidates = matches
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Orchestrates basket-mode resolution: validate, per-row resolve,
|
||||||
|
# assemble the basket tibble + sidecar, attach the sidecar as an attr.
|
||||||
|
# Caller is responsible for emitting any post-resolution summary message
|
||||||
|
# (see Task 7 — this stays silent for now).
|
||||||
|
#' @noRd
|
||||||
|
.resolve_basket <- function(name, state, type, con) {
|
||||||
|
args <- .validate_basket_args(name = name, state = state, type = type)
|
||||||
|
n <- length(args$name)
|
||||||
|
|
||||||
|
resolved <- vector("list", n)
|
||||||
|
for (i in seq_len(n)) {
|
||||||
|
resolved[[i]] <- .resolve_basket_row(
|
||||||
|
name = args$name[i],
|
||||||
|
state = args$state[i],
|
||||||
|
type = args$type[i],
|
||||||
|
con = con
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
basket_rows <- lapply(resolved, function(r) r$row)
|
||||||
|
basket <- dplyr::bind_rows(basket_rows[vapply(basket_rows, function(r) nrow(r) > 0L, logical(1))])
|
||||||
|
if (nrow(basket) == 0L) basket <- .empty_xwalk_tibble()
|
||||||
|
|
||||||
|
sidecar <- .build_sidecar(args, resolved)
|
||||||
|
attr(basket, "resolution") <- sidecar
|
||||||
|
basket
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build the sidecar tibble. One row per input; carries query_*, status,
|
||||||
|
# match_method, canonical_govid, gov_name, n_candidates, and a list-col
|
||||||
|
# `candidates` of full-schema match-candidate tibbles.
|
||||||
|
#' @noRd
|
||||||
|
.build_sidecar <- function(args, resolved) {
|
||||||
|
type_label <- unname(vapply(args$type, function(t) {
|
||||||
|
if (is.na(t)) NA_character_ else .type_to_label(t)
|
||||||
|
}, character(1)))
|
||||||
|
|
||||||
|
status <- vapply(resolved, `[[`, character(1), "status")
|
||||||
|
method <- vapply(resolved, `[[`, character(1), "match_method")
|
||||||
|
ncand <- vapply(resolved, `[[`, integer(1), "n_candidates")
|
||||||
|
govid <- vapply(resolved, function(r) {
|
||||||
|
if (nrow(r$row) == 0L) NA_character_ else r$row$canonical_govid[1L]
|
||||||
|
}, character(1))
|
||||||
|
gname <- vapply(resolved, function(r) {
|
||||||
|
if (nrow(r$row) == 0L) NA_character_ else r$row$gov_name[1L]
|
||||||
|
}, character(1))
|
||||||
|
cands <- lapply(resolved, `[[`, "candidates")
|
||||||
|
|
||||||
|
tibble::tibble(
|
||||||
|
query_name = args$name,
|
||||||
|
query_state = args$state,
|
||||||
|
query_type = type_label,
|
||||||
|
status = status,
|
||||||
|
match_method = method,
|
||||||
|
canonical_govid = govid,
|
||||||
|
gov_name = gname,
|
||||||
|
n_candidates = ncand,
|
||||||
|
candidates = cands
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Convert a type input (integer-like or label) into the canonical label
|
||||||
|
# string used in the sidecar query_type column.
|
||||||
|
#' @noRd
|
||||||
|
.type_to_label <- function(type) {
|
||||||
|
int_type <- .coerce_type(type)
|
||||||
|
c("0" = "state", "1" = "county", "2" = "city", "3" = "township")[[as.character(int_type)]]
|
||||||
|
}
|
||||||
|
|||||||
@@ -243,3 +243,100 @@ test_that(".resolve_basket_row resolves with type override on ambiguous case", {
|
|||||||
expect_equal(out$match_method, "substring")
|
expect_equal(out$match_method, "substring")
|
||||||
expect_equal(out$row$canonical_govid, "052037010")
|
expect_equal(out$row$canonical_govid, "052037010")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# ---- basket mode public surface ----
|
||||||
|
|
||||||
|
test_that("cog_gov_search basket mode resolves clean inputs in input order", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
basket <- cog_gov_search(
|
||||||
|
name = c("BROWARD COUNTY", "SAN DIEGO CITY", "AUSTIN CITY"),
|
||||||
|
state = c("FL", "CA", "TX")
|
||||||
|
)
|
||||||
|
expect_s3_class(basket, "tbl_df")
|
||||||
|
expect_equal(nrow(basket), 3L)
|
||||||
|
expect_equal(basket$canonical_govid, c("101006006", "052037010", "442227001"))
|
||||||
|
expect_equal(basket$gov_name, c("BROWARD COUNTY", "SAN DIEGO CITY", "AUSTIN CITY"))
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("cog_gov_search basket mode attaches a resolution sidecar", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
basket <- cog_gov_search(
|
||||||
|
name = c("Broward", "San Diego"),
|
||||||
|
state = c("FL", "CA"),
|
||||||
|
type = c(NA, "city")
|
||||||
|
)
|
||||||
|
res <- attr(basket, "resolution")
|
||||||
|
expect_s3_class(res, "tbl_df")
|
||||||
|
expect_equal(nrow(res), 2L)
|
||||||
|
expect_equal(res$query_name, c("Broward", "San Diego"))
|
||||||
|
expect_equal(res$query_state, c("FL", "CA"))
|
||||||
|
expect_equal(res$query_type, c(NA_character_, "city"))
|
||||||
|
expect_equal(res$status, c("resolved", "resolved"))
|
||||||
|
expect_equal(res$match_method, c("substring", "substring"))
|
||||||
|
expect_true(is.list(res$candidates))
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("cog_gov_search basket mode skips ambiguous and no_match rows", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
basket <- suppressMessages(cog_gov_search(
|
||||||
|
name = c("Broward", "San Diego", "Notarealplace"),
|
||||||
|
state = c("FL", "CA", "NY")
|
||||||
|
))
|
||||||
|
# Broward resolves; San Diego ambiguous; Notarealplace no_match.
|
||||||
|
expect_equal(nrow(basket), 1L)
|
||||||
|
expect_equal(basket$canonical_govid, "101006006")
|
||||||
|
res <- attr(basket, "resolution")
|
||||||
|
expect_equal(nrow(res), 3L)
|
||||||
|
expect_equal(res$status, c("resolved", "ambiguous", "no_match"))
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("cog_gov_search basket mode preserves input order", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
basket <- cog_gov_search(
|
||||||
|
name = c("AUSTIN CITY", "BROWARD COUNTY", "SAN DIEGO CITY"),
|
||||||
|
state = c("TX", "FL", "CA")
|
||||||
|
)
|
||||||
|
expect_equal(basket$gov_name, c("AUSTIN CITY", "BROWARD COUNTY", "SAN DIEGO CITY"))
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("cog_gov_search basket mode recycles single state", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
basket <- cog_gov_search(
|
||||||
|
name = c("SAN DIEGO CITY", "OAKLAND CITY"),
|
||||||
|
state = "CA"
|
||||||
|
)
|
||||||
|
expect_equal(nrow(basket), 2L)
|
||||||
|
expect_equal(basket$canonical_govid, c("052037010", "052001009"))
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("cog_gov_search basket mode within-type largest_pop records candidates", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
basket <- suppressMessages(cog_gov_search(
|
||||||
|
name = c("Miami", "OAKLAND CITY"),
|
||||||
|
state = c("FL", "CA")
|
||||||
|
))
|
||||||
|
expect_equal(nrow(basket), 2L)
|
||||||
|
res <- attr(basket, "resolution")
|
||||||
|
miami_row <- res[res$query_name == "Miami", ]
|
||||||
|
expect_equal(miami_row$status, "largest_pop")
|
||||||
|
expect_equal(miami_row$canonical_govid, "102013013")
|
||||||
|
expect_gte(miami_row$n_candidates, 2L)
|
||||||
|
expect_gte(nrow(miami_row$candidates[[1]]), 2L)
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("cog_gov_search utility mode (length-1 name) has no sidecar", {
|
||||||
|
skip_if_no_corpus()
|
||||||
|
r <- cog_gov_search("BROWARD")
|
||||||
|
expect_null(attr(r, "resolution"))
|
||||||
|
expect_gte(nrow(r), 1L)
|
||||||
|
})
|
||||||
|
|
||||||
|
test_that("cog_gov_search basket mode validates argument lengths", {
|
||||||
|
expect_error(
|
||||||
|
cog_gov_search(
|
||||||
|
name = c("Broward", "San Diego", "Austin"),
|
||||||
|
state = c("FL", "CA")
|
||||||
|
),
|
||||||
|
regexp = "must be length 1 or 3"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user