lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

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:
MCHANGELOG.md | 4++++
Mcontracts/releases/1.0.0-alpha.1.toml | 9+++++++++
Mcrates/geocoder/README | 2++
Mcrates/geocoder/src/geocoder.rs | 72+++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mcrates/geocoder/src/model.rs | 6++++--
Mcrates/geocoder/tests/geocoder.rs | 105++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
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(