CiaranDevSeed Claude Opus 5 commited on
Commit
30114d9
·
1 Parent(s): 5b2b311

Match every name a place has, without case or accents

Browse files

Three of the misses in #12 were in the index all along, under another
spelling: Chittagong is filed as Chattogram District, Da Nang as Đà Nẵng,
Bogota as Bogotá, D.c. Search compared the query with the primary and
English names only, and Jaro-Winkler rewards a shared prefix, so a
near-homograph on another continent won.

A record is now matched under every name Overture holds for it: its own,
its translations (names.common) and its alternate, short and official
names (names.rules), each compared after lowercasing and stripping
accents. matched_name reports the spelling that hit. Natural Earth, which
has no alternates, is matched on its own and English names as before,
folded the same way.

Building and folding those names on every query would make search about
twice as slow as main, so normalize_geodata stores them in a
search_names column, and search reads it where every file has one. A raw
Overture download still works; it builds the names on the fly. On the
2026-08-19.0 release a fuzzy ids_only search now takes 0.19s against
main's 0.82s, and a full-geometry one 0.74s. The column adds 0.16 GB to
divisions_area and 0.22 GB to localities.

The natural-language pipeline sees better candidates, but still of the
subtypes its model was trained on; the name it is shown is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

dataset/scripts/normalize_geodata.py CHANGED
@@ -18,6 +18,10 @@ Cities and towns (Overture ``locality`` and ``localadmin``) go to their own
18
  file. Fuzzy search reads it; the training pipeline and the natural-language
19
  search read only ``divisions_area``, because the model was not trained on
20
  those subtypes.
 
 
 
 
21
  """
22
 
23
  from pathlib import Path
@@ -25,6 +29,7 @@ from pathlib import Path
25
  import duckdb
26
 
27
  from gazet.config import _DATA_DIR
 
28
 
29
  #: The subtypes the natural-language model was trained on.
30
  TRAINED_SUBTYPES = ("country", "region", "county")
@@ -63,9 +68,9 @@ def normalize_geodata(output_root: Path | None = None) -> dict[str, str]:
63
  con.execute(
64
  f"""
65
  COPY (
66
- SELECT * REPLACE (
67
- ST_GeomFromWKB(ST_AsWKB(geometry)) AS geometry
68
- )
69
  FROM read_parquet('{root / "overture/divisions_area/*.parquet"}')
70
  WHERE geometry IS NOT NULL
71
  AND subtype IN {subtypes}
 
18
  file. Fuzzy search reads it; the training pipeline and the natural-language
19
  search read only ``divisions_area``, because the model was not trained on
20
  those subtypes.
21
+
22
+ Both Overture files also store ``search_names``: every name a record
23
+ answers to, folded for comparison, so fuzzy search does not rebuild and
24
+ fold them on every query.
25
  """
26
 
27
  from pathlib import Path
 
29
  import duckdb
30
 
31
  from gazet.config import _DATA_DIR
32
+ from gazet.search import DIVISIONS_AREA_SEARCH_NAMES, SEARCH_NAMES_COLUMN
33
 
34
  #: The subtypes the natural-language model was trained on.
35
  TRAINED_SUBTYPES = ("country", "region", "county")
 
68
  con.execute(
69
  f"""
70
  COPY (
71
+ SELECT
72
+ * REPLACE (ST_GeomFromWKB(ST_AsWKB(geometry)) AS geometry),
73
+ {DIVISIONS_AREA_SEARCH_NAMES} AS {SEARCH_NAMES_COLUMN}
74
  FROM read_parquet('{root / "overture/divisions_area/*.parquet"}')
75
  WHERE geometry IS NOT NULL
76
  AND subtype IN {subtypes}
src/gazet/search.py CHANGED
@@ -1,5 +1,5 @@
1
  import logging
2
- from functools import partial
3
 
4
  import duckdb
5
  import pandas as pd
@@ -30,6 +30,67 @@ def _readable(expr: str) -> str:
30
  return f"NULLIF(trim({expr}), '')"
31
 
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  def _divisions_area_paths(include_localities: bool) -> str | list[str]:
34
  """The divisions_area file, and the localities beside it where asked for.
35
 
@@ -46,6 +107,7 @@ def simple_fuzzy_search(
46
  path: str | list[str],
47
  source: str,
48
  place: Place,
 
49
  name_expr: str = PRIMARY_NAME,
50
  english_name_expr: str | None = None,
51
  extra_select: str = "",
@@ -56,12 +118,14 @@ def simple_fuzzy_search(
56
  ) -> pd.DataFrame:
57
  """Jaro-Winkler similarity search using only the place name.
58
 
59
- Each record is matched under two names: ``name_expr``, its own, and
60
- ``english_name_expr``, its English one where the source carries it. A
61
- record scores the better of the two, so a place is reachable by its
62
- English exonym ("Copenhagen") as well as by its local name ("Københavns
63
- Kommune"), and a record with no English name matches on its own name
64
- alone. ``matched_name`` is whichever of the two produced the score.
 
 
65
 
66
  ``name`` is the English name where there is one and the record's own name
67
  otherwise — the expression :func:`fetch_by_id` uses too, so a candidate
@@ -80,17 +144,13 @@ def simple_fuzzy_search(
80
  GeoJSON serialization), for lightweight context (e.g. ``ids_only``
81
  responses).
82
  """
83
- params = [path, place.place, place.place, place.place, place.place, limit]
84
-
85
  english_expr = english_name_expr or "CAST(NULL AS VARCHAR)"
86
  extra_clause = f", {extra_select}" if extra_select else ""
87
- geometry_clause = ", ST_AsGeoJSON(geometry) AS geometry" if include_geometry else ""
88
  bbox_clause = f", {bbox_expr} AS bbox" if include_bbox else ""
89
- # Geometry and bbox are computed in the first stage, so the final select
90
- # only has to name them.
91
- carried_clause = (", geometry" if include_geometry else "") + (
92
- ", bbox" if include_bbox else ""
93
- )
94
  rel = con.execute(
95
  f"""
96
  WITH named AS (
@@ -102,26 +162,35 @@ def simple_fuzzy_search(
102
  region,
103
  admin_level,
104
  is_land,
105
- is_territorial{extra_clause}{geometry_clause}{bbox_clause},
106
  {_readable(name_expr)} AS primary_name,
107
- {_readable(english_expr)} AS english_name
108
- FROM read_parquet(?)
 
109
  ),
110
  scored AS (
111
  SELECT
112
  *,
113
- COALESCE(
114
- jaro_winkler_similarity(lower(primary_name), lower(?)), 0.0
115
- ) AS primary_similarity,
116
- COALESCE(
117
- jaro_winkler_similarity(lower(english_name), lower(?)), 0.0
118
- ) AS english_similarity,
119
- COALESCE(contains(lower(primary_name), lower(?)), false)
120
- AS primary_substring,
121
- COALESCE(contains(lower(english_name), lower(?)), false)
122
- AS english_substring
 
 
 
123
  FROM named
124
- WHERE primary_name IS NOT NULL OR english_name IS NOT NULL
 
 
 
 
 
125
  )
126
  SELECT
127
  id,
@@ -132,22 +201,14 @@ def simple_fuzzy_search(
132
  region,
133
  admin_level,
134
  is_land,
135
- is_territorial{extra_clause}{carried_clause},
136
- -- Compared as a pair so the winning name is the one the ranking
137
- -- below would pick: a substring hit beats a higher raw score.
138
- CASE
139
- WHEN (english_substring, english_similarity)
140
- > (primary_substring, primary_similarity)
141
- THEN english_name
142
- ELSE COALESCE(primary_name, english_name)
143
- END AS matched_name,
144
- GREATEST(primary_similarity, english_similarity) AS similarity,
145
- primary_substring OR english_substring AS is_substring_match
146
- FROM scored
147
  ORDER BY is_substring_match DESC, similarity DESC, admin_level ASC
148
- LIMIT ?
149
  """,
150
- params,
151
  )
152
  df = rel.fetchdf()
153
  df.insert(0, "source", source)
@@ -168,20 +229,22 @@ def search_divisions_area(
168
  ) -> pd.DataFrame:
169
  """Fuzzy-match a place against divisions_area (Overture admin boundaries).
170
 
171
- Matched on ``names.primary`` and on ``names.common.en`` together: 77% of
172
- divisions_area rows have no English common name and would drop out of
173
- every search if English were the only match field, while ``names.primary``
174
- on its own leaves a place unreachable by the English name it is usually
175
- asked for.
176
 
177
  ``include_localities`` searches cities and towns as well. Only the fuzzy
178
  endpoint passes it; see :func:`_divisions_area_paths`.
179
  """
 
180
  return simple_fuzzy_search(
181
  con,
182
- _divisions_area_paths(include_localities),
183
  "divisions_area",
184
  place,
 
185
  english_name_expr=DIVISIONS_AREA_ENGLISH_NAME,
186
  extra_select="division_id",
187
  limit=limit,
@@ -204,6 +267,7 @@ def search_natural_earth(
204
  NATURAL_EARTH_PATH,
205
  "natural_earth",
206
  place,
 
207
  english_name_expr=NATURAL_EARTH_ENGLISH_NAME,
208
  limit=limit,
209
  include_geometry=include_geometry,
 
1
  import logging
2
+ from functools import cache, partial
3
 
4
  import duckdb
5
  import pandas as pd
 
30
  return f"NULLIF(trim({expr}), '')"
31
 
32
 
33
+ def _folded(expr: str) -> str:
34
+ """SQL that compares a name without case or accents: "Bogota" is "Bogotá"."""
35
+ return f"lower(strip_accents({expr}))"
36
+
37
+
38
+ def search_names_sql(
39
+ name_expr: str, english_name_expr: str, alternate_names_expr: str | None = None
40
+ ) -> str:
41
+ """SQL for every name a record answers to, each beside its folded form.
42
+
43
+ The record's own name comes first and its English one second, so they
44
+ win a tie; the alternates follow with exact repeats dropped. Blank names
45
+ are left out.
46
+ """
47
+
48
+ def entry(name: str) -> str:
49
+ return f"{{'name': trim({name}), 'folded': {_folded(f'trim({name})')}}}"
50
+
51
+ alternates = (
52
+ f"list_distinct(list_transform({alternate_names_expr}, n -> {entry('n')}))"
53
+ if alternate_names_expr
54
+ else "[]"
55
+ )
56
+ return (
57
+ f"list_filter(list_concat([{entry(name_expr)}, {entry(english_name_expr)}], "
58
+ f"{alternates}), s -> NULLIF(s.name, '') IS NOT NULL)"
59
+ )
60
+
61
+
62
+ #: The column normalize_geodata stores search_names_sql's result in, so a
63
+ #: search reads it instead of rebuilding and folding every name per query.
64
+ SEARCH_NAMES_COLUMN = "search_names"
65
+ #: Overture's translations (``names.common``) and its alternate, short and
66
+ #: official names (``names.rules``).
67
+ DIVISIONS_AREA_SEARCH_NAMES = search_names_sql(
68
+ PRIMARY_NAME,
69
+ DIVISIONS_AREA_ENGLISH_NAME,
70
+ "list_concat(COALESCE(map_values(names.common), []), "
71
+ "COALESCE(list_transform(names.rules, r -> r.value), []))",
72
+ )
73
+ NATURAL_EARTH_SEARCH_NAMES = search_names_sql(PRIMARY_NAME, NATURAL_EARTH_ENGLISH_NAME)
74
+
75
+
76
+ @cache
77
+ def _stores_search_names(path: str) -> bool:
78
+ """Whether a parquet file (or glob) carries the stored search names."""
79
+ columns = duckdb.connect().execute("DESCRIBE SELECT * FROM read_parquet(?)", [path])
80
+ return SEARCH_NAMES_COLUMN in {row[0] for row in columns.fetchall()}
81
+
82
+
83
+ def _search_names(path: str | list[str], computed: str) -> str:
84
+ """The stored search names where every file has them, else ``computed``.
85
+
86
+ Raw Overture downloads and the test fixtures have no stored column.
87
+ """
88
+ paths = [path] if isinstance(path, str) else path
89
+ if all(_stores_search_names(p) for p in paths):
90
+ return SEARCH_NAMES_COLUMN
91
+ return computed
92
+
93
+
94
  def _divisions_area_paths(include_localities: bool) -> str | list[str]:
95
  """The divisions_area file, and the localities beside it where asked for.
96
 
 
107
  path: str | list[str],
108
  source: str,
109
  place: Place,
110
+ search_names_expr: str,
111
  name_expr: str = PRIMARY_NAME,
112
  english_name_expr: str | None = None,
113
  extra_select: str = "",
 
118
  ) -> pd.DataFrame:
119
  """Jaro-Winkler similarity search using only the place name.
120
 
121
+ Each record is matched under every name in ``search_names_expr`` (see
122
+ :func:`search_names_sql`): its own, its English one, and any others the
123
+ source holds. A record scores its best name, so a place is reachable by
124
+ its English exonym ("Copenhagen") as well as by its local name
125
+ ("Københavns Kommune") or an alternate one ("Chittagong" for "Chattogram
126
+ District"). Names are compared without case or accents, so "Bogota"
127
+ finds "Bogotá". ``matched_name`` is whichever name produced the score;
128
+ on a tie, the record's own name.
129
 
130
  ``name`` is the English name where there is one and the record's own name
131
  otherwise — the expression :func:`fetch_by_id` uses too, so a candidate
 
144
  GeoJSON serialization), for lightweight context (e.g. ``ids_only``
145
  responses).
146
  """
 
 
147
  english_expr = english_name_expr or "CAST(NULL AS VARCHAR)"
148
  extra_clause = f", {extra_select}" if extra_select else ""
 
149
  bbox_clause = f", {bbox_expr} AS bbox" if include_bbox else ""
150
+ # Geometry is carried raw and only serialised for the rows returned.
151
+ carried_geometry = ", geometry" if include_geometry else ""
152
+ geometry_clause = ", ST_AsGeoJSON(geometry) AS geometry" if include_geometry else ""
153
+ query = _folded("$1")
 
154
  rel = con.execute(
155
  f"""
156
  WITH named AS (
 
162
  region,
163
  admin_level,
164
  is_land,
165
+ is_territorial{extra_clause}{bbox_clause}{carried_geometry},
166
  {_readable(name_expr)} AS primary_name,
167
+ {_readable(english_expr)} AS english_name,
168
+ {search_names_expr} AS search_names
169
+ FROM read_parquet($2)
170
  ),
171
  scored AS (
172
  SELECT
173
  *,
174
+ -- Struct order is the ranking: a substring hit beats a
175
+ -- higher raw score, and an earlier name breaks a tie.
176
+ list_max(
177
+ list_transform(
178
+ search_names,
179
+ (s, i) -> {{
180
+ 'substring': contains(s.folded, {query}),
181
+ 'similarity': jaro_winkler_similarity(s.folded, {query}),
182
+ 'rank': -i,
183
+ 'name': s.name
184
+ }}
185
+ )
186
+ ) AS best
187
  FROM named
188
+ WHERE len(search_names) > 0
189
+ ),
190
+ ranked AS (
191
+ SELECT * FROM scored
192
+ ORDER BY best.substring DESC, best.similarity DESC, admin_level ASC
193
+ LIMIT $3
194
  )
195
  SELECT
196
  id,
 
201
  region,
202
  admin_level,
203
  is_land,
204
+ is_territorial{extra_clause}{", bbox" if include_bbox else ""}{geometry_clause},
205
+ best.name AS matched_name,
206
+ best.similarity AS similarity,
207
+ best.substring AS is_substring_match
208
+ FROM ranked
 
 
 
 
 
 
 
209
  ORDER BY is_substring_match DESC, similarity DESC, admin_level ASC
 
210
  """,
211
+ [place.place, path, limit],
212
  )
213
  df = rel.fetchdf()
214
  df.insert(0, "source", source)
 
229
  ) -> pd.DataFrame:
230
  """Fuzzy-match a place against divisions_area (Overture admin boundaries).
231
 
232
+ Matched on ``names.primary``, ``names.common`` and ``names.rules``
233
+ together: 77% of divisions_area rows have no English common name and
234
+ would drop out of every search if English were the only match field,
235
+ while ``names.primary`` on its own leaves a place unreachable by the
236
+ English name it is usually asked for.
237
 
238
  ``include_localities`` searches cities and towns as well. Only the fuzzy
239
  endpoint passes it; see :func:`_divisions_area_paths`.
240
  """
241
+ path = _divisions_area_paths(include_localities)
242
  return simple_fuzzy_search(
243
  con,
244
+ path,
245
  "divisions_area",
246
  place,
247
+ _search_names(path, DIVISIONS_AREA_SEARCH_NAMES),
248
  english_name_expr=DIVISIONS_AREA_ENGLISH_NAME,
249
  extra_select="division_id",
250
  limit=limit,
 
267
  NATURAL_EARTH_PATH,
268
  "natural_earth",
269
  place,
270
+ NATURAL_EARTH_SEARCH_NAMES,
271
  english_name_expr=NATURAL_EARTH_ENGLISH_NAME,
272
  limit=limit,
273
  include_geometry=include_geometry,
tests/conftest.py CHANGED
@@ -6,6 +6,8 @@ from pathlib import Path
6
  import duckdb
7
  import pytest
8
 
 
 
9
  # Force data dir to project root for tests
10
  os.environ["GAZET_DATA_DIR"] = str(Path(__file__).resolve().parent.parent / "data")
11
  # Prefer original (non-normalized) paths — test suite ships without normalized copies
@@ -66,8 +68,17 @@ _EXONYM_ROWS = [
66
  ("ratnapura", None, "Ratnapura District", "LK", "county", 2, 80.4, 6.7),
67
  ("gva", "Genève", "", "CH", "county", 2, 6.1, 46.2),
68
  ("manas", "Manas", None, "KG", "county", 2, 72.9, 42.5),
 
 
 
 
 
 
69
  ]
70
 
 
 
 
71
  #: Cities, shaped like the localities file: never trained on, so only fuzzy
72
  #: search may return them.
73
  _LOCALITY_ROWS = [
@@ -85,13 +96,26 @@ def _name_map(english):
85
  return "MAP([], [])" if english is None else f"MAP(['en'], ['{english}'])"
86
 
87
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  def _write_divisions(path, rows):
89
- """Write rows to a parquet with the divisions_area schema."""
 
90
  c = duckdb.connect()
91
  c.execute("INSTALL spatial")
92
  c.execute("LOAD spatial")
93
  values = ",\n".join(
94
- f"({_literal(id)}, {_literal(primary)}, {_name_map(english)}, "
95
  f"{_literal(country)}, {_literal(subtype)}, "
96
  f"{'NULL' if admin_level is None else admin_level}, {lon}, {lat})"
97
  for id, primary, english, country, subtype, admin_level, lon, lat in rows
@@ -99,6 +123,8 @@ def _write_divisions(path, rows):
99
  c.execute(
100
  f"""
101
  COPY (
 
 
102
  SELECT
103
  id,
104
  ST_AsWKB(
@@ -114,7 +140,7 @@ def _write_divisions(path, rows):
114
  country,
115
  subtype,
116
  'land' AS class,
117
- {{'primary': primary_name, 'common': common}} AS names,
118
  NULL AS region,
119
  CAST(admin_level AS INTEGER) AS admin_level,
120
  true AS is_land,
@@ -122,7 +148,10 @@ def _write_divisions(path, rows):
122
  id AS division_id
123
  FROM (VALUES
124
  {values}
125
- ) AS t(id, primary_name, common, country, subtype, admin_level, lon, lat)
 
 
 
126
  ) TO '{path}' (FORMAT PARQUET)
127
  """
128
  )
 
6
  import duckdb
7
  import pytest
8
 
9
+ from gazet.search import DIVISIONS_AREA_SEARCH_NAMES, SEARCH_NAMES_COLUMN
10
+
11
  # Force data dir to project root for tests
12
  os.environ["GAZET_DATA_DIR"] = str(Path(__file__).resolve().parent.parent / "data")
13
  # Prefer original (non-normalized) paths — test suite ships without normalized copies
 
68
  ("ratnapura", None, "Ratnapura District", "LK", "county", 2, 80.4, 6.7),
69
  ("gva", "Genève", "", "CH", "county", 2, 6.1, 46.2),
70
  ("manas", "Manas", None, "KG", "county", 2, 72.9, 42.5),
71
+ # Reachable only by an alternate name, or only once accents are folded;
72
+ # the near-homographs beside them are what those queries used to hit.
73
+ ("ctg", "Chattogram District", None, "BD", "county", 2, 91.8, 22.3),
74
+ ("chitato", "Chitato", None, "AO", "county", 2, 20.8, -7.4),
75
+ ("bog", "Bogotá, D.c.", None, "CO", "county", 2, -74.1, 4.6),
76
+ ("bobota", "Bobota", None, "RO", "county", 2, 22.8, 47.4),
77
  ]
78
 
79
+ #: ``names.rules`` values: alternate, short and official names.
80
+ _EXONYM_RULES = {"ctg": ["Chittagong"]}
81
+
82
  #: Cities, shaped like the localities file: never trained on, so only fuzzy
83
  #: search may return them.
84
  _LOCALITY_ROWS = [
 
96
  return "MAP([], [])" if english is None else f"MAP(['en'], ['{english}'])"
97
 
98
 
99
+ def _rules(id):
100
+ """``names.rules``: the record's alternate names, as Overture shapes them."""
101
+ values = _EXONYM_RULES.get(id, [])
102
+ if not values:
103
+ return "CAST([] AS STRUCT(variant VARCHAR, language VARCHAR, value VARCHAR)[])"
104
+ items = ", ".join(
105
+ f"{{'variant': 'alternate', 'language': NULL, 'value': '{value}'}}"
106
+ for value in values
107
+ )
108
+ return f"[{items}]"
109
+
110
+
111
  def _write_divisions(path, rows):
112
+ """Write rows to a parquet with the normalised divisions_area schema,
113
+ search names stored as normalize_geodata stores them."""
114
  c = duckdb.connect()
115
  c.execute("INSTALL spatial")
116
  c.execute("LOAD spatial")
117
  values = ",\n".join(
118
+ f"({_literal(id)}, {_literal(primary)}, {_name_map(english)}, {_rules(id)}, "
119
  f"{_literal(country)}, {_literal(subtype)}, "
120
  f"{'NULL' if admin_level is None else admin_level}, {lon}, {lat})"
121
  for id, primary, english, country, subtype, admin_level, lon, lat in rows
 
123
  c.execute(
124
  f"""
125
  COPY (
126
+ SELECT *, {DIVISIONS_AREA_SEARCH_NAMES} AS {SEARCH_NAMES_COLUMN}
127
+ FROM (
128
  SELECT
129
  id,
130
  ST_AsWKB(
 
140
  country,
141
  subtype,
142
  'land' AS class,
143
+ {{'primary': primary_name, 'common': common, 'rules': rules}} AS names,
144
  NULL AS region,
145
  CAST(admin_level AS INTEGER) AS admin_level,
146
  true AS is_land,
 
148
  id AS division_id
149
  FROM (VALUES
150
  {values}
151
+ ) AS t(
152
+ id, primary_name, common, rules, country, subtype, admin_level, lon, lat
153
+ )
154
+ )
155
  ) TO '{path}' (FORMAT PARQUET)
156
  """
157
  )
tests/test_search.py CHANGED
@@ -2,6 +2,7 @@
2
 
3
  import pandas as pd
4
 
 
5
  from gazet.schemas import Place
6
  from gazet.search import (
7
  get_by_id,
@@ -266,6 +267,27 @@ class TestEnglishNames:
266
  assert bool(df.iloc[0]["is_substring_match"]) is False
267
 
268
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  class TestLocalities:
270
  """Cities are searchable by fuzzy name, and kept from the
271
  natural-language pipeline, whose model was never trained on them."""
@@ -292,3 +314,22 @@ class TestLocalities:
292
  def test_a_locality_id_resolves(self, con, localities_source):
293
  df = get_by_id(con, "manaus", include_geometry=False)
294
  assert df.iloc[0]["name"] == "Manaus"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  import pandas as pd
4
 
5
+ from gazet import search
6
  from gazet.schemas import Place
7
  from gazet.search import (
8
  get_by_id,
 
267
  assert bool(df.iloc[0]["is_substring_match"]) is False
268
 
269
 
270
+ class TestOtherNames:
271
+ """A place is reachable by any name Overture holds for it, with or
272
+ without accents: "Chittagong" is an alternate name of Chattogram
273
+ District, and "Bogota" is how "Bogotá" is typed without the accent."""
274
+
275
+ def test_alternate_name_finds_the_place(self, con, exonym_source):
276
+ df = search_divisions_area(con, Place(place="Chittagong"), limit=3)
277
+ assert df.iloc[0]["id"] == "ctg"
278
+ assert df.iloc[0]["matched_name"] == "Chittagong"
279
+ assert df.iloc[0]["name"] == "Chattogram District"
280
+
281
+ def test_unaccented_query_finds_the_accented_name(self, con, exonym_source):
282
+ df = search_divisions_area(con, Place(place="Bogota"), limit=3)
283
+ assert df.iloc[0]["id"] == "bog"
284
+ assert df.iloc[0]["matched_name"] == "Bogotá, D.c."
285
+
286
+ def test_accented_query_finds_the_unaccented_name(self, con, exonym_source):
287
+ df = search_divisions_area(con, Place(place="Chattógram"), limit=1)
288
+ assert df.iloc[0]["id"] == "ctg"
289
+
290
+
291
  class TestLocalities:
292
  """Cities are searchable by fuzzy name, and kept from the
293
  natural-language pipeline, whose model was never trained on them."""
 
314
  def test_a_locality_id_resolves(self, con, localities_source):
315
  df = get_by_id(con, "manaus", include_geometry=False)
316
  assert df.iloc[0]["name"] == "Manaus"
317
+
318
+
319
+ class TestStoredSearchNames:
320
+ """normalize_geodata stores each record's folded names; a file without
321
+ them, such as a raw Overture download, is searched all the same."""
322
+
323
+ def test_the_stored_names_are_read(self, exonym_source):
324
+ assert search._search_names(exonym_source, "computed") == "search_names"
325
+
326
+ def test_a_raw_file_falls_back_to_computing_them(self):
327
+ assert (
328
+ search._search_names(search.DIVISIONS_AREA_PATH, "computed") == "computed"
329
+ )
330
+
331
+ def test_both_find_the_same_record(self, con, exonym_source, monkeypatch):
332
+ stored = search_divisions_area(con, Place(place="Chittagong"), limit=1)
333
+ monkeypatch.setattr(search, "_search_names", lambda path, computed: computed)
334
+ computed = search_divisions_area(con, Place(place="Chittagong"), limit=1)
335
+ assert stored.iloc[0]["id"] == computed.iloc[0]["id"] == "ctg"