-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathforward.rs
More file actions
3011 lines (2819 loc) · 126 KB
/
Copy pathforward.rs
File metadata and controls
3011 lines (2819 loc) · 126 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//! Forward geocoding: text → coordinates. Built on tantivy (Lucene-in-Rust).
//!
//! The tantivy index carries per-document admin context (suburb/city, state,
//! country code) gathered at build time by running a reverse-geocoding
//! lookup at each street's / place's coordinate. That lets the query side
//! match across multiple fields — a user typing "alysse close baulkham
//! hills nsw" finds the street whose registered suburb is Baulkham Hills,
//! not just any document that happens to mention one of the tokens.
//!
//! ## Scope
//!
//! Indexes two sources from the reverse-geocoding binary index:
//! - **place points** (city/town/village/suburb/hamlet) from `place_points.bin`
//! - **streets** (way names with a node centroid) from `street_ways.bin` +
//! `street_nodes.bin`, enriched with the suburb they sit in.
//!
//! Admin polygons (states, countries) aren't indexed as hit candidates
//! themselves — their geometry isn't useful for returning a single
//! coordinate. They serve only as enrichment context for streets/places.
use crate::{
as_typed_slice, Index, NodeCoord, PlacePoint, WayHeader, DEFAULT_ADMIN_CELL_LEVEL,
DEFAULT_SEARCH_DISTANCE, DEFAULT_STREET_CELL_LEVEL,
};
use std::path::Path;
use tantivy::collector::TopDocs;
use tantivy::query::{AllQuery, BooleanQuery, BoostQuery, FuzzyTermQuery, Occur, Query, TermQuery};
use tantivy::schema::{
Field, IndexRecordOption, Schema, Value, FAST, INDEXED, STORED, STRING,
};
use tantivy::tokenizer::{AsciiFoldingFilter, LowerCaser, SimpleTokenizer, TextAnalyzer};
use tantivy::{
doc, Index as TIndex, IndexReader, IndexWriter, ReloadPolicy, TantivyDocument, Term,
};
pub const KIND_PLACE: u64 = 1;
pub const KIND_STREET: u64 = 2;
pub const KIND_POI: u64 = 3;
/// Schema handle — kept together so build + query code agree on field ids.
pub struct ForwardSchema {
pub schema: Schema,
pub name: Field,
/// Multilingual aliases — `name:xx` translations and Latin
/// transliterations for non-Latin scripts. Indexed in a separate
/// field so the canonical `name` field stays short and BM25
/// length normalization doesn't tank the canonical term's score
/// for major cities (Sydney has 81 `name:xx` tags; without this
/// split, the place_point's "sydney" token scored 0.28 vs 8.0
/// for "Sydney Street", and never appeared in top-K).
pub alt_name: Field,
pub name_raw: Field,
pub suburb: Field,
pub state: Field,
pub country_code: Field,
pub kind: Field,
/// POI category — `<key>:<value>` interned at build time
/// (`amenity:cafe`, `tourism:attraction`, ...). Indexed (tokenised
/// on `:`) so callers can pass e.g. `category=amenity` to filter to
/// any amenity, or `category=cafe` to filter to cafes specifically.
/// Empty for non-POI docs.
pub category: Field,
pub rank: Field,
/// Prominence importance score, 0..255. Sourced from the place
/// builder's `compute_place_importance` (population log + wikidata +
/// wikipedia bonuses). Stored & FAST so `boosted_score` can read it
/// at re-rank time. Always present on place docs; 0 for streets,
/// admins, and POIs (those have separate prominence signals).
pub importance: Field,
pub lat: Field,
pub lng: Field,
}
/// Name of the tokenizer we register on the tantivy index. Inline
/// unicode folding + lowercase so query/index tokens match even across
/// diacritic variants ("Zürich" ≡ "Zurich", "São Paulo" ≡ "Sao Paulo").
/// Must match between build time and query time — register the same
/// pipeline on any `TIndex` we open.
const TOKENIZER_NAME: &str = "geocoder";
fn register_tokenizer(idx: &TIndex) {
let analyzer = TextAnalyzer::builder(SimpleTokenizer::default())
.filter(AsciiFoldingFilter)
.filter(LowerCaser)
.build();
idx.tokenizers().register(TOKENIZER_NAME, analyzer);
}
// --- Abbreviation expansion ---
//
// Small, conservative table keyed by the abbreviation (lowercased, post-ASCII-
// fold). Only unambiguous road-type abbreviations go here — "St" stays out
// because it can mean Street or Saint. We canonicalise to the long form at
// both index time (via `normalize_street_text` pre-processing the source) and
// query time (via `canonicalise_token`). That way "Albert Hwy" and
// "Albert Highway" produce the same token set `{albert, highway}`.
//
// Based on AU/UK/US common street-type abbreviations, deliberately leaving
// out country-specific rarities. Expanding the table later is safe — it's
// additive, but requires re-running `build-forward-index` so stored docs
// pick up the new canonicalisations.
const STREET_TYPE_ABBREVIATIONS: &[(&str, &str)] = &[
("hwy", "highway"),
("pde", "parade"),
("tce", "terrace"),
("ter", "terrace"),
("cres", "crescent"),
("blvd", "boulevard"),
("blv", "boulevard"),
("bvd", "boulevard"),
("ln", "lane"),
("ave", "avenue"),
("av", "avenue"),
("rd", "road"),
("dr", "drive"),
("ct", "court"),
("cl", "close"),
("pl", "place"),
];
fn canonicalise_token(token: &str) -> &str {
for (abbr, full) in STREET_TYPE_ABBREVIATIONS {
if token == *abbr {
return full;
}
}
token
}
impl ForwardSchema {
pub fn build() -> Self {
use tantivy::schema::{TextFieldIndexing, TextOptions};
let mut schema = Schema::builder();
let text_indexed = TextFieldIndexing::default()
.set_tokenizer(TOKENIZER_NAME)
.set_index_option(IndexRecordOption::WithFreqs);
let text_opts = TextOptions::default()
.set_indexing_options(text_indexed.clone())
.set_stored();
let name = schema.add_text_field("name", text_opts.clone());
let alt_name = schema.add_text_field("alt_name", text_opts.clone());
let name_raw = schema.add_text_field("name_raw", STRING | STORED);
// Enrichment fields — tokenized so "baulkham hills" matches both tokens.
let suburb = schema.add_text_field("suburb", text_opts.clone());
let state = schema.add_text_field("state", text_opts.clone());
let country_code = schema.add_text_field("country_code", STRING | STORED);
let kind = schema.add_u64_field("kind", INDEXED | FAST | STORED);
// category is tokenised (the colon in `amenity:cafe` becomes a
// word break) so a freeform query for "cafe" matches POIs of
// type amenity:cafe, and a structured `category=amenity` filter
// matches every amenity:* doc.
let category = schema.add_text_field("category", text_opts);
let rank = schema.add_u64_field("rank", FAST | STORED);
let importance = schema.add_u64_field("importance", FAST | STORED);
let lat = schema.add_f64_field("lat", STORED | FAST);
let lng = schema.add_f64_field("lng", STORED | FAST);
ForwardSchema {
schema: schema.build(),
name,
alt_name,
name_raw,
suburb,
state,
country_code,
kind,
category,
rank,
importance,
lat,
lng,
}
}
}
// --- Build path ---
#[derive(Default, Debug, Clone, Copy)]
pub struct BuildStats {
pub places: usize,
pub streets: usize,
}
/// Signed-area-weighted polygon centroid (lat, lng). Falls back to the
/// arithmetic mean when the polygon has fewer than 3 vertices or is
/// degenerate (collinear vertices, zero signed area). Vertex-density
/// independent — important for admin polygons whose boundary is densely
/// sampled along coast / mountain edges and sparse along straight
/// inland sections.
fn polygon_centroid(verts: &[NodeCoord]) -> (f64, f64) {
let n = verts.len();
if n == 0 {
return (0.0, 0.0);
}
let mean = || -> (f64, f64) {
let sum_lat: f64 = verts.iter().map(|v| v.lat as f64).sum();
let sum_lng: f64 = verts.iter().map(|v| v.lng as f64).sum();
(sum_lat / n as f64, sum_lng / n as f64)
};
if n < 3 {
return mean();
}
let mut a2 = 0.0f64;
let mut cx = 0.0f64;
let mut cy = 0.0f64;
for i in 0..n {
let j = (i + 1) % n;
let xi = verts[i].lng as f64;
let yi = verts[i].lat as f64;
let xj = verts[j].lng as f64;
let yj = verts[j].lat as f64;
let cross = xi * yj - xj * yi;
a2 += cross;
cx += (xi + xj) * cross;
cy += (yi + yj) * cross;
}
if a2.abs() < 1e-12 {
return mean();
}
let factor = 1.0 / (3.0 * a2);
(cy * factor, cx * factor)
}
/// Bucket a (lat, lng) into a 0.1°-grid cell (~11 km at the equator) for
/// the place/admin-polygon dedup pass. Coarse enough that a city's
/// `place=*` point and its `boundary=administrative` polygon centroid
/// land in the same bucket; fine enough that two distinct cities sharing
/// a name (Springfield IL vs Springfield MO ~ 290 km apart) stay distinct.
#[inline]
fn coord_bucket(lat: f64, lng: f64) -> (i32, i32) {
((lat * 10.0).round() as i32, (lng * 10.0).round() as i32)
}
/// Wide bucket (~44 km) for admin-vs-admin metro dedup. The C++
/// builder emits one `AdminPolygon` per outer ring of an OSM
/// boundary relation; for cities like Greensboro NC whose boundary
/// relation has 41 outer rings (annexation parcels + ETJ + city
/// limits), that produces 41 same-name admin polygons scattered
/// across the metro that all match BM25 equally for `q=Greensboro`.
/// The bias re-rank picks whichever fragment happens to combine best
/// importance + distance instead of the canonical place_point.
///
/// First attempt used 0.2° (~22 km), but the regression run with
/// PR #31 deployed showed Greensboro NC still failing: the place_point
/// at (36.072, -79.792) and a failing admin centroid at (36.054,
/// -79.641) — 13.5 km apart — landed in different buckets in *both*
/// dimensions because they straddled the bucket boundary. Widening
/// to 0.4° (~44 km) puts a typical city metro entirely in one bucket
/// regardless of how its centroid sits relative to the boundary.
///
/// Distinct same-name metros stay separate: Greensboro NC vs
/// Greensboro NY (~700 km), Springfield MO vs IL (~290 km),
/// Cambridge UK vs MA (~5 500 km) all sit in different buckets.
#[inline]
fn wide_coord_bucket(lat: f64, lng: f64) -> (i32, i32) {
((lat * 2.5).round() as i32, (lng * 2.5).round() as i32)
}
/// Synthesize a short form from a compound canonical name. OSM
/// convention puts `short_name` on the boundary relation rather than
/// the `place=city` node for many big cities, so the place_point's
/// i18n alternates often don't include the obvious shortening even
/// when the city has one. Confirmed against the deployed planet
/// build: Frankfurt am Main (entity_id 12084) has `short_name=Frankfurt`
/// only on the admin polygon — its place_point's i18n_names rows
/// carry `name:en=Frankfurt` and friends but no language-less
/// "Frankfurt" short alias from a `short_name` tag on the node.
///
/// Heuristic: when the canonical name matches `<X> <prep> <Y>` for
/// a known compound preposition, emit `<X>` as a synthetic short
/// alt. Captures Frankfurt am Main, Newcastle upon Tyne, Stoke on
/// Trent, Frankfurt an der Oder, etc. Returns `None` when no
/// pattern matches — caller should fall back to whatever short
/// alias the i18n alternates table provides (which already covers
/// `name:en=Frankfurt` for the same row, so the synthesis is a
/// belt-and-braces measure on top of i18n).
///
/// Does NOT split on hyphens — French compound names like
/// "Boulogne-sur-Mer" use hyphenated prepositions, but tantivy's
/// SimpleTokenizer already splits hyphens into separate tokens, so
/// the leading part is matchable as-is via the standard name field.
fn synthesize_short_form(name: &str) -> Option<&str> {
let lower = name.to_ascii_lowercase();
// Compound prepositions surrounded by spaces. The first match
// wins (left-to-right scan), so we order by likely frequency in
// place names. Lowercase only — the input was already
// ascii-folded for the lookup.
static COMPOUND_PREPS: &[&str] = &[
" am ", // Frankfurt am Main, Halle (Saale) am Saale
" an ", // Frankfurt an der Oder
" upon ", // Newcastle upon Tyne, Stratford upon Avon
" on ", // Stoke on Trent, Newcastle on Tyne (hyphenated form lost on hyphen-split anyway)
" under ", // Newcastle under Lyme
" im ", // Speyer im Rhein
" bei ", // Munich bei München
" sur ", // Boulogne sur Mer (space form)
" of ", // Isle of Wight, City of London
" near ",
" by ",
];
for prep in COMPOUND_PREPS {
if let Some(idx) = lower.find(prep) {
// Slice the original `name` (preserves case) up to the
// preposition. Trim incidental trailing whitespace.
let leading = name[..idx].trim();
if !leading.is_empty() && leading.len() < name.len() {
return Some(leading);
}
}
}
None
}
/// For each `(name_id, ~22 km bucket)` cluster of admin polygons at
/// admin_level 4-10, keep only the polygon with the largest `area` —
/// that's typically the canonical city-proper outline (not an
/// annexation parcel or ETJ extension). Returns the set of `poly_id`s
/// to keep.
///
/// Skipped:
/// - admin_level < 4 (countries) — multi-polygon countries like
/// Indonesia / Russia / Greece legitimately have many outer rings
/// for islands; the country-level lookup needs all of them.
/// - admin_level > 10 (postal codes) — already gated out below; the
/// range mirrors the admin doc loop's filter.
///
/// Tradeoff: at admin_level 4-6, sub-national admins with legitimate
/// multi-island geometry (Hawaii Maui County = Maui + Lanai +
/// Kahoolawe) will lose their secondary islands. That's acceptable for
/// forward-search ranking — a query for "Maui County" still matches
/// the main island's polygon and resolves to the right county. The
/// reverse-geocode path uses `admin_polygons.bin` directly (not this
/// dedup), so reverse-lookup of a Lanai-island coord still works.
fn build_admin_metro_keep_set(
polys: &[crate::AdminPolygon],
admin_vertices: &[NodeCoord],
idx: &crate::Index,
) -> std::collections::HashSet<u32> {
let mut best_per_cluster: std::collections::HashMap<(u32, i32, i32), (u32, f32)> =
std::collections::HashMap::new();
let mut considered = 0usize;
for (poly_id, poly) in polys.iter().enumerate() {
if poly.admin_level < 4 || poly.admin_level > 10 {
continue;
}
if idx.get_string(poly.name_id).is_empty() {
continue;
}
let off = poly.vertex_offset as usize;
let cnt = poly.vertex_count as usize;
if cnt == 0 || off + cnt > admin_vertices.len() {
continue;
}
considered += 1;
let (lat, lng) = polygon_centroid(&admin_vertices[off..off + cnt]);
let (b_lat, b_lng) = wide_coord_bucket(lat, lng);
let key = (poly.name_id, b_lat, b_lng);
let entry = best_per_cluster
.entry(key)
.or_insert((poly_id as u32, poly.area));
if poly.area > entry.1 {
*entry = (poly_id as u32, poly.area);
}
}
let kept = best_per_cluster.len();
eprintln!(
"[forward] admin_metro_dedup: considered {} admin polygons (level 4-10), kept {} unique (name, ~44 km bucket) clusters — dropped {} duplicates",
considered,
kept,
considered.saturating_sub(kept)
);
best_per_cluster.values().map(|v| v.0).collect()
}
/// Build a single monolithic tantivy index at `dest`. Everything goes in
/// one bucket — queries are filtered by the indexed `country_code` field
/// rather than dispatched to a different tantivy per country.
///
/// Use this when a deployment only serves one or two countries. For
/// worldwide / many-country deployments, `build_partitioned` produces
/// per-country indexes that query faster (smaller term dicts, tighter
/// BM25) — see `tantivy_<cc>/` layout consumed by `Forward::open`.
pub fn build(source: &Path, dest: &Path) -> Result<BuildStats, String> {
build_with_heap(source, dest, default_heap_bytes())
}
/// Default tantivy `IndexWriter` heap budget. 512 MB is sized to keep
/// AU's ~100 MB tantivy dataset entirely in one in-memory segment,
/// with substantial headroom; planet operators should bump this via
/// `--tantivy-heap-mb` until the build's [stage] timing for the
/// commit phase stops dominating wallclock. See
/// docs/performance/tantivy-heap-2026-04-25.md for the analysis.
pub fn default_heap_bytes() -> usize {
512 * 1024 * 1024
}
pub fn build_with_heap(source: &Path, dest: &Path, heap_bytes: usize) -> Result<BuildStats, String> {
let source_str = source
.to_str()
.ok_or_else(|| format!("non-utf8 source path: {}", source.display()))?;
let idx = Index::load(
source_str,
DEFAULT_STREET_CELL_LEVEL,
DEFAULT_ADMIN_CELL_LEVEL,
DEFAULT_SEARCH_DISTANCE,
)?;
let schema_handle = ForwardSchema::build();
if dest.exists() {
std::fs::remove_dir_all(dest).map_err(|e| format!("clear {}: {}", dest.display(), e))?;
}
std::fs::create_dir_all(dest).map_err(|e| format!("mkdir {}: {}", dest.display(), e))?;
let t_index = TIndex::create_in_dir(dest, schema_handle.schema.clone())
.map_err(|e| format!("create tantivy index: {e}"))?;
register_tokenizer(&t_index);
let mut writer: IndexWriter = t_index
.writer(heap_bytes)
.map_err(|e| format!("tantivy writer: {e}"))?;
let mut stats = BuildStats::default();
// Places
if let Some(pp) = idx.place_points.as_ref() {
let points: &[PlacePoint] = as_typed_slice(pp);
for (place_id, p) in points.iter().enumerate() {
let name = idx.get_string(p.name_id);
if name.is_empty() {
continue;
}
let lat = p.lat as f64;
let lng = p.lng as f64;
let admin = idx.find_admin(lat, lng);
// Gather `name:xx` alternates so an English query for
// `Cologne` finds the Köln entry (canonical name unchanged
// — the alternates are appended to the indexed `name`
// field only). entity_type=1, entity_id=index in
// place_points (matches the C++ builder; see
// builder/src/build_index.cpp:633).
let alternates: Vec<&str> = idx
.i18n_names
.as_ref()
.map(|i| {
i.alternates_for(crate::i18n::ENTITY_PLACE, place_id as u32)
.map(|(_, _, name_id)| idx.get_string(name_id))
.filter(|alt| !alt.is_empty() && *alt != name)
.collect()
})
.unwrap_or_default();
writer
.add_document(tantivy_doc(
&schema_handle,
name,
&alternates,
KIND_PLACE,
p.rank as u64,
p.importance as u64,
lat,
lng,
admin.city,
admin.state,
admin.country_code,
"",
))
.map_err(|e| format!("index place: {e}"))?;
stats.places += 1;
}
}
// Build a (name_id, ~11km bucket) signature set from place_points
// so admin polygons that duplicate an existing place=* doc can be
// dropped. Without dedup, a city with both place=town AND
// boundary=administrative ends up as two near-identical Tantivy
// docs at the same coord — BM25 ranking then becomes order-of-
// insertion-dependent under bias.
let mut place_signature: std::collections::HashSet<(u32, i32, i32)> =
std::collections::HashSet::new();
if let Some(pp) = idx.place_points.as_ref() {
let points: &[PlacePoint] = as_typed_slice(pp);
for p in points {
if idx.get_string(p.name_id).is_empty() {
continue;
}
let (b_lat, b_lng) = coord_bucket(p.lat as f64, p.lng as f64);
place_signature.insert((p.name_id, b_lat, b_lng));
}
}
// Admin polygons (levels 4-10). Indexed as place-kind docs so
// admin-unit queries like "Saint-Quentin-en-Yvelines",
// "Hansestadt Stade", "Marburg an der Lahn" — formal names sitting
// on `boundary=administrative` polygons rather than `place=*`
// points — return a result. Country (2-3) and postal code (11)
// are skipped: too generic, or non-name codes.
let polys: &[crate::AdminPolygon] = as_typed_slice(&idx.admin_polygons);
let admin_vertices: &[NodeCoord] = as_typed_slice(&idx.admin_vertices);
let admin_metro_keep = build_admin_metro_keep_set(polys, admin_vertices, &idx);
for (poly_id, poly) in polys.iter().enumerate() {
if poly.admin_level < 4 || poly.admin_level > 10 {
continue;
}
let name = idx.get_string(poly.name_id);
if name.is_empty() {
continue;
}
// Metro-cluster dedup: skip same-name polygon fragments that
// aren't the largest in their (~22 km) cluster. See
// `build_admin_metro_keep_set` for rationale.
if !admin_metro_keep.contains(&(poly_id as u32)) {
continue;
}
let off = poly.vertex_offset as usize;
let cnt = poly.vertex_count as usize;
if cnt == 0 || off + cnt > admin_vertices.len() {
continue;
}
let (lat, lng) = polygon_centroid(&admin_vertices[off..off + cnt]);
// Drop when an existing place_point already covers this
// (name, ~11km bucket) cell. The place_point is more
// authoritative (carries the OSM-curated `place=*` rank).
let (b_lat, b_lng) = coord_bucket(lat, lng);
if place_signature.contains(&(poly.name_id, b_lat, b_lng)) {
continue;
}
let admin = idx.find_admin(lat, lng);
// entity_type=0 for admin polygons (mirrors the C++ builder's
// i18n_names emission at builder/src/build_index.cpp:816).
let alternates: Vec<&str> = match idx.i18n_names.as_ref() {
Some(i) => i
.alternates_for(crate::i18n::ENTITY_ADMIN, poly_id as u32)
.map(|(_, _, name_id)| idx.get_string(name_id))
.filter(|alt| !alt.is_empty() && *alt != name)
.collect(),
None => Vec::new(),
};
// Rank derived from admin_level: level 4 (state) → 12, level 8
// (municipality) → 16, level 10 (suburb) → 18. Lower = more
// prominent. Mirrors PlacePoint.rank semantics so the bias
// re-rank treats both kinds uniformly.
let rank = (poly.admin_level as u64) + 8;
writer
.add_document(tantivy_doc(
&schema_handle,
name,
&alternates,
KIND_PLACE,
rank,
poly.importance as u64,
lat,
lng,
admin.city,
admin.state,
admin.country_code,
"",
))
.map_err(|e| format!("index admin polygon: {e}"))?;
stats.places += 1;
}
// Streets
let ways: &[WayHeader] = as_typed_slice(&idx.street_ways);
let nodes: &[NodeCoord] = as_typed_slice(&idx.street_nodes);
// Dedup by (name_id, enriched suburb) to keep distinct "Main Street"s in
// different suburbs without inflating the index with a row per OSM way.
let mut seen: std::collections::HashSet<(u32, String)> = std::collections::HashSet::new();
for way in ways {
let name = idx.get_string(way.name_id);
if name.is_empty() {
continue;
}
let offset = way.node_offset as usize;
let count = way.node_count as usize;
if count == 0 || offset + count > nodes.len() {
continue;
}
let mid = nodes[offset + count / 2];
let lat = mid.lat as f64;
let lng = mid.lng as f64;
let admin = idx.find_admin(lat, lng);
let suburb_key = admin.city.unwrap_or("").to_string();
if !seen.insert((way.name_id, suburb_key)) {
continue;
}
// The C++ builder doesn't emit i18n_names entries for streets,
// so there are no alternates to feed in here.
writer
.add_document(tantivy_doc(
&schema_handle,
name,
&[],
KIND_STREET,
26,
0,
lat,
lng,
admin.city,
admin.state,
admin.country_code,
"",
))
.map_err(|e| format!("index street: {e}"))?;
stats.streets += 1;
}
// POIs (commit 5). Index every named amenity/shop/tourism/etc.
// alongside places and streets so /search returns "Sydney Opera
// House" for the tourism POI in Sydney. Category is the interned
// `<key>:<value>` string. Tagged parent_place_id (from
// addr:city/suburb/locality) wins over geometric find_admin
// enrichment when set — matches the AddrPoint behaviour.
if let Some(pois_mmap) = idx.poi_points.as_ref() {
let pois: &[crate::PoiPoint] = as_typed_slice(pois_mmap);
for (poi_id, poi) in pois.iter().enumerate() {
let name = idx.get_string(poi.name_id);
if name.is_empty() {
continue;
}
let lat = poi.lat as f64;
let lng = poi.lng as f64;
let geo_admin = idx.find_admin(lat, lng);
let tagged_parent: Option<&str> = if poi.parent_place_id != 0 {
Some(idx.get_string(poi.parent_place_id))
} else {
None
};
let suburb = tagged_parent.or(geo_admin.city);
let category = idx.get_string(poi.category_id);
let alternates: Vec<&str> = idx
.i18n_names
.as_ref()
.map(|i| {
i.alternates_for(crate::i18n::ENTITY_POI, poi_id as u32)
.map(|(_, _, name_id)| idx.get_string(name_id))
.filter(|alt| !alt.is_empty() && *alt != name)
.collect()
})
.unwrap_or_default();
writer
.add_document(tantivy_doc(
&schema_handle,
name,
&alternates,
KIND_POI,
poi.rank as u64,
poi.importance as u64,
lat,
lng,
suburb,
geo_admin.state,
geo_admin.country_code,
category,
))
.map_err(|e| format!("index poi: {e}"))?;
}
}
writer
.commit()
.map_err(|e| format!("tantivy commit: {e}"))?;
Ok(stats)
}
/// Build per-country tantivy indexes at `<dest_root>/tantivy_<cc>/`. Docs
/// whose enriched `country_code` is empty (offshore, unknown) are dropped
/// — they wouldn't be reachable by any meaningful forward query anyway.
///
/// For a worldwide deployment this splits ~2 GB of tantivy into ~60
/// smaller per-country indexes (AU ~42 MB, US ~300 MB, etc.), letting a
/// deployment that only serves N countries mount only their N indexes
/// and skip the rest. Queries with a country filter dispatch directly
/// to the right index — smaller term dictionaries, accurate BM25 IDF.
///
/// Returns per-country stats keyed by the ISO alpha-2 code.
pub fn build_partitioned(
source: &Path,
dest_root: &Path,
) -> Result<std::collections::HashMap<[u8; 2], BuildStats>, String> {
build_partitioned_with_heap(source, dest_root, default_heap_bytes())
}
pub fn build_partitioned_with_heap(
source: &Path,
dest_root: &Path,
heap_bytes: usize,
) -> Result<std::collections::HashMap<[u8; 2], BuildStats>, String> {
use rayon::prelude::*;
use std::collections::HashMap as Map;
use std::time::Instant;
let source_str = source
.to_str()
.ok_or_else(|| format!("non-utf8 source path: {}", source.display()))?;
let idx = Index::load(
source_str,
DEFAULT_STREET_CELL_LEVEL,
DEFAULT_ADMIN_CELL_LEVEL,
DEFAULT_SEARCH_DISTANCE,
)?;
let schema_handle = ForwardSchema::build();
std::fs::create_dir_all(dest_root)
.map_err(|e| format!("mkdir {}: {}", dest_root.display(), e))?;
// Phase 1: classify all docs into per-country buckets. The expensive
// step here is `idx.find_admin(lat, lng)` (point-in-polygon test);
// PendingDoc borrows `&str` from the loaded Index so the intermediate
// is cheap — just (lat, lng, refs) × N docs.
//
// Streets dedup happens here so each country bucket is already
// distinct on (name_id, suburb, cc) before we hand it to tantivy.
// Same dedup key as the previous serial implementation; behaviour
// is preserved.
struct PendingDoc<'a> {
name: &'a str,
/// `name:xx` alternates from i18n_names.bin. Empty for streets
/// (the C++ builder doesn't emit street entries) and for
/// places without name:xx tags. Concatenated into the indexed
/// `name` field at writer time so a query in any tagged
/// language matches.
alternates: Vec<&'a str>,
kind: u64,
rank: u64,
/// Prominence importance, 0..255. Filled from PlacePoint
/// `importance` for places; 0 for streets, admin polygons, and
/// POIs (no signal collected for those at index time).
importance: u64,
lat: f64,
lng: f64,
suburb: Option<&'a str>,
state: Option<&'a str>,
country_code: [u8; 2],
/// POI category — `<key>:<value>` interned in strings.bin
/// (e.g. `amenity:cafe`). Empty for non-POI docs.
category: &'a str,
}
// Helper: resolve a doc's country code from find_admin-derived bytes.
// Drops docs without a country (offshore, unknown, or level-2 admin
// that didn't store a country_code).
let country_bytes = |raw: Option<[u8; 2]>| -> Option<[u8; 2]> {
let cc = raw?;
if cc[0] == 0 || cc[1] == 0 {
return None;
}
Some(cc)
};
let phase1 = Instant::now();
eprintln!(
"[stage] forward_classify: starting (rayon threads = {}, RAYON_NUM_THREADS = {:?})",
rayon::current_num_threads(),
std::env::var("RAYON_NUM_THREADS").ok(),
);
let mut buckets: Map<[u8; 2], Vec<PendingDoc<'_>>> = Map::new();
// Places. par_iter parallelises the per-doc find_admin (the
// expensive point-in-polygon step) across rayon's thread pool;
// the post-collect bucketing is cheap.
if let Some(pp) = idx.place_points.as_ref() {
let points: &[PlacePoint] = as_typed_slice(pp);
let place_candidates: Vec<([u8; 2], PendingDoc<'_>)> = points
.par_iter()
.enumerate()
.filter_map(|(place_id, p)| {
let name = idx.get_string(p.name_id);
if name.is_empty() {
return None;
}
let lat = p.lat as f64;
let lng = p.lng as f64;
let admin = idx.find_admin(lat, lng);
let cc = country_bytes(admin.country_code)?;
// entity_type=1 for place points; entity_id matches
// the slice index into place_points.bin (the C++
// builder uses `place_points.size()` as the id pre-
// push; see builder/src/build_index.cpp:633).
let alternates: Vec<&str> = idx
.i18n_names
.as_ref()
.map(|i| {
i.alternates_for(crate::i18n::ENTITY_PLACE, place_id as u32)
.map(|(_, _, name_id)| idx.get_string(name_id))
.filter(|alt| !alt.is_empty() && *alt != name)
.collect()
})
.unwrap_or_default();
Some((
cc,
PendingDoc {
name,
alternates,
kind: KIND_PLACE,
rank: p.rank as u64,
importance: p.importance as u64,
lat,
lng,
suburb: admin.city,
state: admin.state,
country_code: cc,
category: "",
},
))
})
.collect();
for (cc, doc) in place_candidates {
buckets.entry(cc).or_default().push(doc);
}
}
// Admin polygons (levels 4-10). Indexed as place-kind docs so
// admin-unit queries like "Saint-Quentin-en-Yvelines",
// "Hansestadt Stade", "Marburg an der Lahn" — formal names that
// live on `boundary=administrative` polygons rather than `place=*`
// points — return a result. Country (2-3) and postal code (11)
// are skipped: too generic, or non-name codes.
// Build a (name_id, ~11km bucket) signature from place_points so
// admin polygons duplicating an existing place=* doc can be
// dropped — see the monolithic path for rationale.
let mut place_signature: std::collections::HashSet<(u32, i32, i32)> =
std::collections::HashSet::new();
if let Some(pp) = idx.place_points.as_ref() {
let points: &[PlacePoint] = as_typed_slice(pp);
for p in points {
if idx.get_string(p.name_id).is_empty() {
continue;
}
let (b_lat, b_lng) = coord_bucket(p.lat as f64, p.lng as f64);
place_signature.insert((p.name_id, b_lat, b_lng));
}
}
{
let polys: &[crate::AdminPolygon] = as_typed_slice(&idx.admin_polygons);
let admin_vertices: &[NodeCoord] = as_typed_slice(&idx.admin_vertices);
let admin_metro_keep = build_admin_metro_keep_set(polys, admin_vertices, &idx);
let admin_metro_keep_ref = &admin_metro_keep;
let place_signature_ref = &place_signature;
let admin_candidates: Vec<([u8; 2], PendingDoc<'_>)> = polys
.par_iter()
.enumerate()
.filter_map(|(poly_id, poly)| {
if poly.admin_level < 4 || poly.admin_level > 10 {
return None;
}
let name = idx.get_string(poly.name_id);
if name.is_empty() {
return None;
}
// Metro-cluster dedup: skip same-name polygon fragments
// that aren't the largest in their (~22 km) cluster.
// See `build_admin_metro_keep_set`.
if !admin_metro_keep_ref.contains(&(poly_id as u32)) {
return None;
}
let off = poly.vertex_offset as usize;
let cnt = poly.vertex_count as usize;
if cnt == 0 || off + cnt > admin_vertices.len() {
return None;
}
let (lat, lng) = polygon_centroid(&admin_vertices[off..off + cnt]);
let (b_lat, b_lng) = coord_bucket(lat, lng);
if place_signature_ref.contains(&(poly.name_id, b_lat, b_lng)) {
return None;
}
let admin = idx.find_admin(lat, lng);
let cc = country_bytes(admin.country_code)?;
// entity_type=0 for admin polygons (mirrors the C++
// builder at builder/src/build_index.cpp:816).
let alternates: Vec<&str> = match idx.i18n_names.as_ref() {
Some(i) => i
.alternates_for(crate::i18n::ENTITY_ADMIN, poly_id as u32)
.map(|(_, _, name_id)| idx.get_string(name_id))
.filter(|alt| !alt.is_empty() && *alt != name)
.collect(),
None => Vec::new(),
};
// Rank from admin_level: level 4 (state) → 12, level
// 8 (municipality) → 16, level 10 (suburb) → 18.
// Lower = more prominent. Same scale as PlacePoint.rank
// so bias re-rank treats both kinds uniformly.
let rank = (poly.admin_level as u64) + 8;
Some((
cc,
PendingDoc {
name,
alternates,
kind: KIND_PLACE,
rank,
importance: poly.importance as u64,
lat,
lng,
suburb: admin.city,
state: admin.state,
country_code: cc,
category: "",
},
))
})
.collect();
for (cc, doc) in admin_candidates {
buckets.entry(cc).or_default().push(doc);
}
}
// Streets — same pattern, then a sequential dedup-and-bucket pass
// on the results. Materialising the intermediate Vec is the price
// of doing the dedup correctly across all threads (per-thread
// local dedup would let cross-thread duplicates survive merge).
// For planet that's ~48M candidates × ~80 bytes ≈ ~3.8 GB peak;
// the find_admin parallelism more than pays for it.
let ways: &[WayHeader] = as_typed_slice(&idx.street_ways);
let nodes: &[NodeCoord] = as_typed_slice(&idx.street_nodes);
let way_candidates: Vec<(u32, String, [u8; 2], PendingDoc<'_>)> = ways
.par_iter()
.filter_map(|way| {
let name = idx.get_string(way.name_id);
if name.is_empty() {
return None;
}
let offset = way.node_offset as usize;
let count = way.node_count as usize;
if count == 0 || offset + count > nodes.len() {
return None;
}
let mid = nodes[offset + count / 2];
let lat = mid.lat as f64;
let lng = mid.lng as f64;
let admin = idx.find_admin(lat, lng);
let cc = country_bytes(admin.country_code)?;
let suburb_key = admin.city.unwrap_or("").to_string();
Some((
way.name_id,
suburb_key,
cc,
PendingDoc {
name,
// No street entries in i18n_names.bin (the C++
// builder only emits admin polygons and place
// points), so nothing to add here.
alternates: Vec::new(),
kind: KIND_STREET,
rank: 26,
importance: 0,
lat,
lng,
suburb: admin.city,
state: admin.state,
country_code: cc,
category: "",
},
))
})
.collect();
let mut seen: std::collections::HashSet<(u32, String, [u8; 2])> =
std::collections::HashSet::with_capacity(way_candidates.len());
for (name_id, suburb, cc, doc) in way_candidates {
if seen.insert((name_id, suburb, cc)) {
buckets.entry(cc).or_default().push(doc);
}
}
drop(seen);
// POIs (commit 5). Same partition + dedup pattern as places —
// par_iter the find_admin enrichment then bucket by country.
if let Some(pois_mmap) = idx.poi_points.as_ref() {
let pois: &[crate::PoiPoint] = as_typed_slice(pois_mmap);
let poi_candidates: Vec<([u8; 2], PendingDoc<'_>)> = pois
.par_iter()
.enumerate()
.filter_map(|(poi_id, poi)| {
let name = idx.get_string(poi.name_id);
if name.is_empty() {
return None;
}
let lat = poi.lat as f64;
let lng = poi.lng as f64;
let geo_admin = idx.find_admin(lat, lng);
let cc = country_bytes(geo_admin.country_code)?;
let tagged_parent: Option<&str> = if poi.parent_place_id != 0 {
Some(idx.get_string(poi.parent_place_id))
} else {
None