6  Other features

Beyond the core parse-score-match pipeline, gnafr includes an interactive geocoder app, spatial join/plotting helpers, and synthetic address generation for testing. This chapter covers all three, plus a closer look at the match cache from a usage (rather than mechanism) angle.

6.1 The Shiny geocoder app

gnaf_app(db_path = "C:/temp/gnaf.duckdb")
# or, reusing an existing connection:
gnaf_app(con = con)

gnaf_app() launches an interactive geocoder built on the same gnaf_match() engine as everything else in this manual — nothing in the app does its own matching. You paste one address per line, it runs gnaf_match() against your connected database, and shows results in a reactable table.

Two diagnostics are added on top of the usual match columns specifically for manual review: full-string Jaro-Winkler and Jaccard similarity between the raw input and the matched address_label, with gradient highlighting so a low-confidence lexical match stands out even when the structured score looks fine (e.g. a match that’s structurally correct — same number, postcode, type — but textually quite different from the original string, which can be a sign the parse went down an unexpected path).

Pass run = FALSE to get the shiny.appobj back without launching it — useful for embedding the geocoder inside a larger Shiny app, or for testing.

app_obj <- gnaf_app(con = con, run = FALSE)

6.2 Spatial helpers

gnaf_match() returns longitude/latitude for every match — the spatial helpers exist to take that coordinate and join it to other geographies (ABS Mesh Blocks, SA2 regions, custom catchment polygons) or visualise it.

library(gnafr)

# 1. Read a polygon shapefile and see what columns it has
sa2 <- read_shapefile("C:/temp/sa2/SA2_2021_AUST_GDA2020.shp")

# 2. Optionally subset to the area you care about
sa2_qld <- subset_shapefile(sa2, var = "STE_NAME21", values = "Queensland")

# 3. Point-in-polygon join: attach SA2 attributes to your matched addresses
matched <- gnaf_match(addresses, con, max_results = 1)
geo <- spatial_lookup(
  matched[matched == TRUE], sa2_qld,
  lat = "latitude", lon = "longitude",
  return_cols = c("SA2_CODE21", "SA2_NAME21")
)

# 4. Visualise match density against the polygon boundaries
plot_boundaries_heatmap(sa2_qld, points_dt = geo, bins = 200)

# ...or as an interactive leaflet map
plot_boundaries_heatmap(sa2_qld, points_dt = geo, use_leaflet = TRUE)

spatial_lookup() processes points in chunks (chunk_size, default 100,000) to bound memory use on large batches, and supports multiple = "all" for the (rare) case where your polygons overlap and you want every match, not just the first.

6.3 Synthetic test data

address_perturb_sample() generates realistic-but-messy test addresses by sampling real rows from your loaded data and perturbing them — useful for QA and benchmarking gnaf_match() without hand-labelling a test set.

library(gnafr)
library(data.table)

con <- gnaf_connect(":memory:")
gnaf_init(con)
gnaf_add(con, data.table(
  address_label = c(
    "10 MUSGRAVE ROAD, RED HILL QLD 4059",
    "18-20 DRIFT CLOSE, GOLDSBOROUGH QLD 4865"
  ),
  number_first  = c(10L, 18L), number_last = c(NA_integer_, 20L),
  street_name   = c("MUSGRAVE", "DRIFT"),
  street_type   = c("ROAD", "CLOSE"),
  locality_name = c("RED HILL", "GOLDSBOROUGH"),
  state = c("QLD", "QLD"), postcode = c(4059L, 4865L)
))

sample_rows <- sample_gnaf(con, n = 100)
# gnaf_init() always creates four base tables (gnaf_addresses, custom_addresses,
# gnaf_locality_index, gnaf_match_cache), so sample_gnaf() returns a named list —
# pick whichever address table actually has rows in it.
sample_source <- if (is.list(sample_rows) && !is.data.frame(sample_rows)) {
  sample_rows$custom_addresses
} else {
  sample_rows
}

synthetic <- address_perturb_sample(
  sample_source, n = 5, replace = TRUE, seed = 42, max_changes = 2L
)
synthetic[, .(address_label, simulated_address, perturbations)]
                              address_label                        simulated_address                      perturbations
                                     <char>                                   <char>                             <char>
1:      10 MUSGRAVE ROAD, RED HILL QLD 4059        10 MUSGRAVE ROD RED HILL QLD 4059         remove_commas, street_typo
2:      10 MUSGRAVE ROAD, RED HILL QLD 4059      10 Musgrave Roa,  Red Hill Qld 4059            case_noise, street_typo
3:      10 MUSGRAVE ROAD, RED HILL QLD 4059       10 Musgrave Road Red Hill Qld 4059          case_noise, remove_commas
4:      10 MUSGRAVE ROAD, RED HILL QLD 4059       10 MUSGRAVE ROAD RED HILL 4059 QLD remove_commas, state_postcode_swap
5: 18-20 DRIFT CLOSE, GOLDSBOROUGH QLD 4865 18-20 Drift Clos,  Goldsborough Qld 4865            case_noise, street_typo

Round-trip it straight back through gnaf_match() to see how well the matcher recovers the original record from its own perturbed output — a quick, repeatable regression check after changing scoring weights or parser logic:

roundtrip <- gnaf_match(synthetic$simulated_address, con, max_results = 1, verbose = FALSE)
roundtrip[, .(input_raw, matched, total_score, address_label)]
                                  input_raw matched total_score                            address_label
                                     <char>  <lgcl>       <int>                                   <char>
1:        10 MUSGRAVE ROD RED HILL QLD 4059    TRUE         100      10 MUSGRAVE ROAD, RED HILL QLD 4059
2:      10 Musgrave Roa,  Red Hill Qld 4059    TRUE         100      10 MUSGRAVE ROAD, RED HILL QLD 4059
3:       10 Musgrave Road Red Hill Qld 4059    TRUE         100      10 MUSGRAVE ROAD, RED HILL QLD 4059
4:       10 MUSGRAVE ROAD RED HILL 4059 QLD    TRUE         100      10 MUSGRAVE ROAD, RED HILL QLD 4059
5: 18-20 Drift Clos,  Goldsborough Qld 4865    TRUE         100 18-20 DRIFT CLOSE, GOLDSBOROUGH QLD 4865

max_changes caps how many perturbations are applied per address (typos, abbreviation swaps, dropped components); keep_original = TRUE (the default) keeps the unperturbed label alongside the perturbed one so you can compare directly.

6.4 Unit testing and regression protection

The package is covered by a suite of automated unit tests under tests/testthat/. These are run with testthat from the package root, typically via:

library(devtools)
devtools::test()

The test files are organised by responsibility:

  • tests/testthat/test-parse.R verifies the parser behaviour for Australian addresses, including street/flat notation, abbreviations, missing commas, postcode/state variations, and multiple input cases.
  • tests/testthat/test-score.R checks the scoring calculations directly, ensuring street-type, number, postcode and flat scoring follow the package’s rules and that the total score remains the sum of all components.
  • tests/testthat/test-match.R exercises the matching pipeline itself, including input validation and end-to-end gnaf_match() behaviour against an in-memory DuckDB database.

Together these tests serve three goals:

  1. Validate current behaviour — they codify the expected outputs for the parser, scorer, and matcher so the package behaves consistently across changes.
  2. Prevent regressions — when parser or scoring code is modified, the test suite catches unintended side effects immediately instead of letting them leak into production results.
  3. Document assumptions — test cases like “Rd instead of Ct” or “unit prefix notation” make the package’s address-handling contracts explicit and easy to understand.

The address_perturb_sample() helper complements the formal tests by enabling repeatable, data-driven regression checks. It generates noisy variants of real records and lets you verify that gnaf_match() still recovers the intended address, which is especially useful when tuning parser logic or scoring weights.

6.5 The match cache, day to day

The mechanics are covered in How matching works; day to day, the cache functions you’ll actually reach for are:

  • gnaf_cache_status(con) — quick sanity check: how many entries, how old is the oldest one. Good first thing to check if a batch job feels slower than expected (an empty or stale cache means every input is going through the full pipeline).
  • gnaf_cache_history(con, by = "day") — cache growth over time, plus the score distribution of what’s been cached. A sudden drop in avg_score for a given day is worth investigating — it usually means a batch of genuinely harder or messier input, not a regression in the matcher.
  • gnaf_cache_rollback(con, after = ...) — when a batch was matched under conditions you later realise were wrong (bad input data, a bug since fixed) and you want exactly that batch re-matched on the next run, without discarding everything else the cache has learned.
  • gnaf_cache_clear(con) — the blunt instrument. Reasonable after a major scoring or parsing change you don’t trust the old cached results under, but prefer gnaf_cache_rollback() when you can scope it to a time window instead.

Remember the cache only applies when gnaf_match() is called with the default weights (Section 3.2) — if you’re routinely calling with custom weights, the cache functions above will simply stay empty, which is expected, not a bug.