commit cdd4a2062fdacbc7079177a69134a489ecd798b2
parent d5f99e80c27330e0a5dca237cf1e22a7dfa6cb49
Author: triesap <tyson@radroots.org>
Date: Mon, 20 Jul 2026 01:47:20 +0000
geocoder: normalize administrative identifiers
- expose subdivision identifiers as opaque lossless strings
- cast mixed SQLite storage classes at every query boundary
- define deterministic locality and reverse-result tie ordering
- cover mixed identifiers and classify the breaking public change
Diffstat:
6 files changed, 166 insertions(+), 32 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
@@ -9,6 +9,10 @@ publish policy both pass for the same source revision.
### Changed
+- Geocoder locality and reverse results now expose administrative subdivision
+ identifiers as opaque strings. SQLite integer and text values normalize to
+ the same lossless public representation, and mixed-storage candidate order
+ remains deterministic.
- Trusted event-contract admission now has one signature-verified entry point.
Profile, root Post, Reply, Comment, DeletionRequest, and FoodAvailability
retain typed admitted values; other registered events require full contract
diff --git a/contracts/releases/1.0.0-alpha.1.toml b/contracts/releases/1.0.0-alpha.1.toml
@@ -320,3 +320,12 @@ id = "operational-listing-authoring-validation"
classification = "breaking"
semver_impacts = ["add_enum_variant", "change_exported_algorithm_behavior"]
summary = "Require every canonical Operational Listing edit to pass the shared model-semantic validator, reject duplicate IDs and invalid quantity or price semantics across every bin, and return the typed validation cause before draft construction, signing, or durable workflow mutation."
+
+[[changes]]
+id = "textual-geonames-administrative-identifiers"
+classification = "breaking"
+semver_impacts = [
+ "change_exported_field_type",
+ "change_exported_algorithm_behavior",
+]
+summary = "Represent GeoNames administrative subdivision identifiers as opaque strings, normalize SQLite integer and text values at query boundaries, and make locality and reverse tie ordering deterministic."
diff --git a/crates/geocoder/README b/crates/geocoder/README
@@ -11,6 +11,8 @@ queries for the `radroots` core libraries.
options;
* country lookup, country listing, and country center helpers backed by the
same dataset;
+ * opaque administrative subdivision identifiers normalized to text across
+ SQLite integer and text storage classes;
* a `std`-based implementation over bundled GeoNames-style SQLite data.
## Copyright
diff --git a/crates/geocoder/src/geocoder.rs b/crates/geocoder/src/geocoder.rs
@@ -59,7 +59,7 @@ impl Geocoder {
SELECT
g.id,
g.name,
- g.admin1_id,
+ CAST(g.admin1_id AS TEXT) AS admin1_id,
g.admin1_name,
g.country_id,
g.country_name,
@@ -72,7 +72,8 @@ impl Geocoder {
AND c.longitude BETWEEN ? - ? AND ? + ?
ORDER BY
((? - c.latitude) * (? - c.latitude))
- + ((? - c.longitude) * (? - c.longitude) * ?) ASC
+ + ((? - c.longitude) * (? - c.longitude) * ?) ASC,
+ g.id ASC
LIMIT ?
"#,
)
@@ -103,7 +104,7 @@ impl Geocoder {
SELECT
id,
name,
- admin1_id,
+ CAST(admin1_id AS TEXT) AS admin1_id,
admin1_name,
country_id,
country_name,
@@ -194,28 +195,41 @@ impl Geocoder {
let query = sqlx::query(
r#"
SELECT
- id,
- name,
- admin1_id,
- admin1_name,
- country_id,
- country_name,
- latitude,
- longitude
- FROM geonames
- WHERE lower(name) = ?
+ g.id,
+ g.name,
+ CAST(g.admin1_id AS TEXT) AS admin1_id,
+ g.admin1_name,
+ g.country_id,
+ g.country_name,
+ g.latitude,
+ g.longitude
+ FROM geonames AS g
+ WHERE lower(g.name) = ?
AND (
? IS NULL
- OR lower(country_id) = ?
- OR lower(country_name) = ?
+ OR lower(g.country_id) = ?
+ OR lower(g.country_name) = ?
)
ORDER BY
- lower(name) ASC,
- lower(country_id) ASC,
- lower(coalesce(country_name, '')) ASC,
- lower(coalesce(admin1_name, '')) ASC,
- coalesce(admin1_id, -1) ASC,
- id ASC
+ lower(g.name) ASC,
+ lower(g.country_id) ASC,
+ lower(coalesce(g.country_name, '')) ASC,
+ lower(coalesce(g.admin1_name, '')) ASC,
+ CASE
+ WHEN g.admin1_id IS NULL THEN 0
+ WHEN typeof(g.admin1_id) IN ('integer', 'real') THEN 1
+ WHEN typeof(g.admin1_id) = 'text' THEN 2
+ ELSE 3
+ END ASC,
+ CASE
+ WHEN typeof(g.admin1_id) IN ('integer', 'real') THEN g.admin1_id
+ ELSE NULL
+ END ASC,
+ CASE
+ WHEN typeof(g.admin1_id) = 'text' THEN CAST(g.admin1_id AS TEXT)
+ ELSE NULL
+ END COLLATE BINARY ASC,
+ g.id ASC
"#,
)
.bind(locality)
@@ -241,7 +255,7 @@ impl Geocoder {
SELECT
id,
name,
- admin1_id,
+ CAST(admin1_id AS TEXT) AS admin1_id,
admin1_name,
country_id,
country_name,
@@ -909,7 +923,7 @@ mod tests {
map_reverse_row_error(
"'bad'",
"'name'",
- "1",
+ "'1'",
"'admin'",
"'US'",
"'United States'",
@@ -919,7 +933,7 @@ mod tests {
map_reverse_row_error(
"1",
"'name'",
- "'bad'",
+ "x'00'",
"'admin'",
"'US'",
"'United States'",
@@ -929,7 +943,7 @@ mod tests {
map_reverse_row_error(
"1",
"'name'",
- "1",
+ "'1'",
"1",
"'US'",
"'United States'",
@@ -939,18 +953,18 @@ mod tests {
map_reverse_row_error(
"1",
"'name'",
- "1",
+ "'1'",
"'admin'",
"1",
"'United States'",
"1.0",
"2.0",
),
- map_reverse_row_error("1", "'name'", "1", "'admin'", "'US'", "1", "1.0", "2.0"),
+ map_reverse_row_error("1", "'name'", "'1'", "'admin'", "'US'", "1", "1.0", "2.0"),
map_reverse_row_error(
"1",
"'name'",
- "1",
+ "'1'",
"'admin'",
"'US'",
"'United States'",
@@ -960,7 +974,7 @@ mod tests {
map_reverse_row_error(
"1",
"'name'",
- "1",
+ "'1'",
"'admin'",
"'US'",
"'United States'",
diff --git a/crates/geocoder/src/model.rs b/crates/geocoder/src/model.rs
@@ -25,7 +25,8 @@ impl Default for GeocoderReverseOptions {
pub struct GeocoderReverseResult {
pub id: i64,
pub name: String,
- pub admin1_id: Option<i64>,
+ /// Opaque administrative subdivision identifier normalized to text.
+ pub admin1_id: Option<String>,
pub admin1_name: Option<String>,
pub country_id: String,
pub country_name: Option<String>,
@@ -45,7 +46,8 @@ pub struct GeocoderCountryListResult {
pub struct GeocoderLocalityCandidate {
pub id: i64,
pub name: String,
- pub admin1_id: Option<i64>,
+ /// Opaque administrative subdivision identifier normalized to text.
+ pub admin1_id: Option<String>,
pub admin1_name: Option<String>,
pub country_id: String,
pub country_name: Option<String>,
diff --git a/crates/geocoder/tests/geocoder.rs b/crates/geocoder/tests/geocoder.rs
@@ -46,7 +46,7 @@ fn reverse_returns_nearest_match_by_default() {
assert_eq!(results[0].id, 1);
assert_eq!(results[0].name, "San Francisco");
assert_eq!(results[0].country_id, "US");
- assert_eq!(results[0].admin1_id, Some(6));
+ assert_eq!(results[0].admin1_id.as_deref(), Some("6"));
assert_eq!(results[0].admin1_name.as_deref(), Some("California"));
}
@@ -148,6 +148,60 @@ fn locality_resolves_structured_query_freeform_query_id_and_ambiguity() {
}
#[test]
+fn locality_normalizes_and_orders_mixed_administrative_identifiers() {
+ let geocoder = open_mixed_admin1_geocoder();
+
+ let lookup = geocoder
+ .locality(&GeocoderLocalityQuery::structured("Mixed Locality").with_country("ZZ"))
+ .expect("mixed-type locality lookup");
+ let GeocoderLocalityLookup::Ambiguous { candidates } = lookup else {
+ panic!("expected ambiguous mixed-type locality lookup");
+ };
+
+ assert_eq!(
+ candidates
+ .iter()
+ .map(|candidate| candidate.id)
+ .collect::<Vec<_>>(),
+ vec![9004, 9002, 9003, 9001]
+ );
+ assert_eq!(
+ candidates
+ .iter()
+ .map(|candidate| candidate.admin1_id.as_deref())
+ .collect::<Vec<_>>(),
+ vec![None, Some("2"), Some("10"), Some("HCW")]
+ );
+}
+
+#[test]
+fn reverse_normalizes_mixed_administrative_identifiers_with_stable_ties() {
+ let geocoder = open_mixed_admin1_geocoder();
+
+ let results = geocoder
+ .reverse(
+ GeocoderPoint { lat: 1.0, lng: 2.0 },
+ Some(GeocoderReverseOptions {
+ limit: 4,
+ degree_offset: 0.1,
+ }),
+ )
+ .expect("mixed-type reverse lookup");
+
+ assert_eq!(
+ results.iter().map(|result| result.id).collect::<Vec<_>>(),
+ vec![9001, 9002, 9003, 9004]
+ );
+ assert_eq!(
+ results
+ .iter()
+ .map(|result| result.admin1_id.as_deref())
+ .collect::<Vec<_>>(),
+ vec![Some("HCW"), Some("2"), Some("10"), None]
+ );
+}
+
+#[test]
fn locality_query_builders_cover_blank_single_region_alias_and_display_fallbacks() {
let geocoder = open_forward_fixture_geocoder();
@@ -493,6 +547,11 @@ fn open_forward_fixture_geocoder() -> Geocoder {
Geocoder::open_path(&path).expect("open geocoder")
}
+fn open_mixed_admin1_geocoder() -> Geocoder {
+ let path = build_mixed_admin1_database();
+ Geocoder::open_path(&path).expect("open mixed administrative identifier geocoder")
+}
+
fn open_empty_geocoder() -> Geocoder {
let temp = NamedTempFile::new().expect("temp db");
let path = temp.into_temp_path();
@@ -534,6 +593,13 @@ fn build_forward_fixture_database() -> tempfile::TempPath {
path
}
+fn build_mixed_admin1_database() -> tempfile::TempPath {
+ let temp = NamedTempFile::new().expect("temp db");
+ let path = temp.into_temp_path();
+ seed_mixed_admin1_database(path.to_str().expect("utf-8 temp path"));
+ path
+}
+
fn seed_fixture_database(path: &str) {
let mut conn = open_test_path_connection(path);
seed_schema(&mut conn);
@@ -600,6 +666,43 @@ fn seed_forward_fixture_database(path: &str) {
insert_feature(&mut conn, 3008, "No Alias Place", "ZZ", 100, 10.5, 11.5);
}
+fn seed_mixed_admin1_database(path: &str) {
+ let mut conn = open_test_path_connection(path);
+ execute_batch(
+ &mut conn,
+ r#"
+ CREATE TABLE geonames(
+ id INTEGER,
+ name TEXT,
+ admin1_id,
+ admin1_name TEXT,
+ country_id TEXT,
+ country_name TEXT,
+ latitude REAL,
+ longitude REAL
+ );
+ CREATE TABLE coordinates(
+ feature_id INTEGER,
+ latitude REAL,
+ longitude REAL
+ );
+ INSERT INTO geonames
+ (id, name, admin1_id, admin1_name, country_id, country_name, latitude, longitude)
+ VALUES
+ (9004, 'Mixed Locality', NULL, 'Fixture Region', 'ZZ', 'Fixtureland', 1.0, 2.0),
+ (9003, 'Mixed Locality', 10, 'Fixture Region', 'ZZ', 'Fixtureland', 1.0, 2.0),
+ (9002, 'Mixed Locality', 2, 'Fixture Region', 'ZZ', 'Fixtureland', 1.0, 2.0),
+ (9001, 'Mixed Locality', 'HCW', 'Fixture Region', 'ZZ', 'Fixtureland', 1.0, 2.0);
+ INSERT INTO coordinates (feature_id, latitude, longitude)
+ VALUES
+ (9004, 1.0, 2.0),
+ (9003, 1.0, 2.0),
+ (9002, 1.0, 2.0),
+ (9001, 1.0, 2.0);
+ "#,
+ );
+}
+
fn seed_reverse_country_row_error_database(path: &str) {
let mut conn = open_test_path_connection(path);
execute_batch(