Skip to main content

open_geocode/builder/
osm.rs

1//! Streaming OSM build.
2//!
3//! Memory stays bounded by the sorter budget whatever the input size: features,
4//! node references, resolved coordinates and finished records all flow through
5//! scratch files and external sorts. A region fits in the budget and never
6//! touches disk; a planet spills and merges through the same code.
7//!
8//! ```text
9//! pass 1  scan every block (parallel)    -> records for nodes, way features,
10//!                                           node references, boundary relations
11//! pass 2  read way blocks                 -> boundary relation member ways
12//! pass 3  read node blocks, merge-join    -> coordinates per way position
13//! emit    ways + coordinates (parallel)   -> address, street, interpolation records
14//! write   context records, then the rest, each in Hilbert order
15//! ```
16
17mod address;
18mod boundary;
19mod emit;
20mod emitted;
21mod geometry;
22mod interpolation;
23mod nodes;
24mod pbf;
25mod place;
26mod postcode;
27mod scan;
28mod spill;
29mod street;
30mod tags;
31
32#[cfg(test)]
33mod test_pbf;
34
35use std::{
36    collections::{HashMap, HashSet},
37    fs::{self, File},
38    io::{BufWriter, Write},
39    path::{Path, PathBuf},
40    sync::Arc,
41    time::Instant,
42};
43
44use anyhow::{Context, Result, bail};
45use rayon::prelude::*;
46
47use crate::{
48    builder::{
49        progress::{item_progress_bar, stage_progress},
50        report::{BUILD_REPORT_SCHEMA_VERSION, BuilderReport, SorterReport},
51    },
52    extsort::{ExternalSorter, SortStats, Spool},
53    memory::MemoryBudget,
54    pack::{
55        AUDIT_DIR, BUILD_REPORT_FILE, PackWriter, PackWriterOptions, REJECTIONS_FILE, RecordContext,
56    },
57    record::{Record, RejectedRecord},
58    records::quantize,
59    util::hilbert::hilbert_key,
60};
61use boundary::{BoundaryIndex, ContextOrigin, Vertex, build_boundaries, record_context};
62use emit::{FeatureOutput, emit_features};
63use emitted::Emitted;
64use nodes::join_nodes;
65use pbf::{for_each_block, input_bytes};
66use postcode::PostcodeAccumulator;
67use scan::scan_block;
68use spill::{NodeRequest, PendingRecord, ResolvedRef, WayFeature, WayKind};
69
70/// Default memory for build sorters before they spill to disk.
71pub const DEFAULT_MEMORY_BUDGET_BYTES: usize = 1 << 30;
72/// Records written per parallel batch.
73const WRITE_BATCH_RECORDS: usize = 16_384;
74
75#[derive(Debug, Clone)]
76pub struct BuildOsmOptions {
77    pub input: PathBuf,
78    pub pack: PathBuf,
79    /// Memory for sort buffers. Inputs that need more spill to scratch files.
80    pub memory_budget_bytes: usize,
81    /// Directory for scratch files; defaults to inside the new Pack generation.
82    pub scratch_dir: Option<PathBuf>,
83}
84
85impl Default for BuildOsmOptions {
86    fn default() -> Self {
87        Self {
88            input: PathBuf::new(),
89            pack: PathBuf::new(),
90            memory_budget_bytes: DEFAULT_MEMORY_BUDGET_BYTES,
91            scratch_dir: None,
92        }
93    }
94}
95
96pub fn build_osm_pack(options: BuildOsmOptions) -> Result<BuilderReport> {
97    let started = Instant::now();
98    if !options.input.is_file() {
99        bail!("input {} does not exist", options.input.display());
100    }
101    // Up to four sorters hold memory at the same time (node references or
102    // resolved coordinates, the two record sorters, the spatial pairs); the text
103    // index buffers take the same share as one sorter while records are written.
104    let sorter_budget = (options.memory_budget_bytes / 4).max(1);
105    let memory = MemoryBudget::new(options.memory_budget_bytes);
106    let mut writer = PackWriter::create_with(
107        &options.pack,
108        PackWriterOptions {
109            scratch_dir: options.scratch_dir.clone(),
110            memory: Arc::clone(&memory),
111            sort_budget_bytes: sorter_budget,
112            text_index_memory_bytes: sorter_budget,
113        },
114    )?;
115    let scratch = Arc::clone(writer.scratch());
116    let mut build = Build {
117        report: BuilderReport {
118            schema_version: BUILD_REPORT_SCHEMA_VERSION,
119            input: options.input.display().to_string(),
120            pack: options.pack.display().to_string(),
121            input_bytes: input_bytes(&options.input)?,
122            ..BuilderReport::default()
123        },
124        audit: Audit::create(writer.generation())?,
125        postcodes: PostcodeAccumulator::default(),
126        records: ExternalSorter::new(&scratch, "records", &memory, sorter_budget),
127        context_records: ExternalSorter::new(
128            &scratch,
129            "context-records",
130            &memory,
131            sorter_budget / 4,
132        ),
133        seq: 0,
134    };
135    build.report.scratch.memory_budget_bytes = options.memory_budget_bytes as u64;
136
137    // Pass 1: classify everything.
138    let phase = Instant::now();
139    let mut requests = ExternalSorter::new(&scratch, "node-requests", &memory, sorter_budget);
140    let mut features = Spool::<WayFeature>::create(&scratch, "way-features")?;
141    let mut relations = Vec::new();
142    // Boundary ways kept by pass 1. When one is also a relation member, its
143    // vertices are reused instead of being requested a second time.
144    let mut boundary_feature_ids = HashSet::new();
145    let mut node_blobs = Vec::new();
146    let mut way_blobs = Vec::new();
147    for_each_block(
148        &options.input,
149        "1/7 scan OSM objects",
150        None,
151        |offset, block| Ok((offset, scan_block(&block))),
152        |(offset, output)| {
153            if output.has_nodes {
154                node_blobs.push(offset);
155            }
156            if output.has_ways {
157                way_blobs.push(offset);
158            }
159            for (feature, refs) in output.ways {
160                let resolution = &mut build.report.geometry_resolution;
161                match feature.kind {
162                    WayKind::Address => resolution.address_way_stubs += 1,
163                    WayKind::Street => resolution.street_way_stubs += 1,
164                    WayKind::Interpolation => resolution.interpolation_way_stubs += 1,
165                    WayKind::Boundary => {
166                        resolution.boundary_way_stubs += 1;
167                        boundary_feature_ids.insert(feature.way_id);
168                    }
169                }
170                request_nodes(
171                    &mut requests,
172                    features.written(),
173                    &refs,
174                    feature.kind == WayKind::Interpolation,
175                )?;
176                features.write(&feature)?;
177            }
178            relations.extend(output.relations);
179            build.absorb(output.emitted)
180        },
181    )?;
182    build.report.phases.scan_ms = phase.elapsed().as_millis();
183
184    // Pass 2: the ways boundary relations are made of. They often carry no
185    // tags of their own, so pass 1 could not know to keep them.
186    let phase = Instant::now();
187    let member_base = features.written();
188    let member_ids = boundary::required_boundary_way_ids(&relations)
189        .into_iter()
190        .filter(|way_id| !boundary_feature_ids.contains(way_id))
191        .collect::<HashSet<_>>();
192    let mut members: HashMap<i64, (u64, usize)> = HashMap::new();
193    if !member_ids.is_empty() {
194        for_each_block(
195            &options.input,
196            "2/7 read boundary member ways",
197            Some(&way_blobs),
198            |_, block| Ok(member_ways(&block, &member_ids)),
199            |found| {
200                for (way_id, refs) in found {
201                    if members.contains_key(&way_id) {
202                        continue;
203                    }
204                    let owner = member_base + members.len() as u64;
205                    request_nodes(&mut requests, owner, &refs, false)?;
206                    members.insert(way_id, (owner, refs.len()));
207                }
208                Ok(())
209            },
210        )?;
211    }
212    build.report.phases.boundary_member_scan_ms = phase.elapsed().as_millis();
213
214    // Pass 3: coordinates for every requested node.
215    let phase = Instant::now();
216    let request_stats = requests.stats();
217    build.report.geometry_resolution.required_node_refs = request_stats.items;
218    let mut resolved =
219        ExternalSorter::<ResolvedRef>::new(&scratch, "resolved-nodes", &memory, sorter_budget);
220    let join = join_nodes(
221        &options.input,
222        &node_blobs,
223        requests.finish()?,
224        &mut resolved,
225    )?;
226    build.report.geometry_resolution.resolved_node_refs = join.resolved;
227    build.report.phases.node_join_ms = phase.elapsed().as_millis();
228
229    // Assemble ways into records.
230    let phase = Instant::now();
231    let resolved_stats = resolved.stats();
232    let mut boundary_ways = Vec::new();
233    let progress = item_progress_bar(features.written(), "4/7 assemble ways");
234    let mut member_vertices = emit_features(
235        features.into_reader()?,
236        resolved.finish()?,
237        |output: FeatureOutput| {
238            progress.inc(1);
239            boundary_ways.extend(output.boundary_ways);
240            build.absorb(output.emitted)
241        },
242    )?;
243    progress.finish_with_message("4/7 assemble ways complete");
244    build.report.phases.feature_emission_ms = phase.elapsed().as_millis();
245
246    // Admin boundaries, their place records and postcode centroids.
247    let phase = Instant::now();
248    let member_vertices: HashMap<i64, Option<Vec<Vertex>>> = members
249        .into_iter()
250        .map(|(way_id, (owner, count))| {
251            let line = member_vertices
252                .remove(&owner)
253                .filter(|vertices| vertices.len() == count);
254            (way_id, line)
255        })
256        .collect();
257    // Boundary features that were incomplete have no entry in `boundary_ways`
258    // and resolve to `None`, like an incomplete member.
259    let mut member_lines: HashMap<i64, Option<&[Vertex]>> = boundary_feature_ids
260        .iter()
261        .map(|way_id| (*way_id, None))
262        .collect();
263    for way in &boundary_ways {
264        member_lines.insert(way.object_id, Some(way.vertices.as_slice()));
265    }
266    for (way_id, line) in &member_vertices {
267        member_lines.insert(*way_id, line.as_deref());
268    }
269    let boundaries = build_boundaries(&boundary_ways, &relations, &member_lines);
270    drop(member_lines);
271    drop((
272        boundary_ways,
273        member_vertices,
274        relations,
275        boundary_feature_ids,
276    ));
277    for (origin, record) in boundaries.place_records(&mut build.report) {
278        build.push(record, Some(origin))?;
279    }
280    for record in std::mem::take(&mut build.postcodes).into_records() {
281        build.report.accept_postcode();
282        build.push(Record::Postcode(record), None)?;
283    }
284    build.report.phases.boundary_build_ms = phase.elapsed().as_millis();
285
286    // Context records first: once their ids are known, the boundary index can
287    // describe every other record.
288    let phase = Instant::now();
289    let Build {
290        mut report,
291        audit,
292        records,
293        context_records,
294        ..
295    } = build;
296    let context_stats = context_records.stats();
297    let record_stats = records.stats();
298    let mut ordered = Spool::<PendingRecord>::create(&scratch, "context-ordered")?;
299    let mut boundary_ids = vec![0; boundaries.len()];
300    let mut country_ids = HashMap::new();
301    let first_id = writer.record_count();
302    for (index, item) in context_records.finish()?.enumerate() {
303        let item = item?;
304        let record_id = first_id + index as u64;
305        match &item.origin {
306            Some(ContextOrigin::Boundary(boundary)) => boundary_ids[*boundary as usize] = record_id,
307            Some(ContextOrigin::DerivedCountry(code)) => {
308                country_ids.insert(code.clone(), record_id);
309            }
310            None => {}
311        }
312        ordered.write(&item)?;
313    }
314    let index = boundaries.into_index(&boundary_ids, &country_ids);
315    let context_count = ordered.written();
316    write_ordered(
317        &mut writer,
318        ordered.into_reader()?,
319        context_count,
320        &index,
321        "5/7 write context records",
322    )?;
323    report.phases.context_record_write_ms = phase.elapsed().as_millis();
324
325    let phase = Instant::now();
326    write_ordered(
327        &mut writer,
328        records.finish()?,
329        record_stats.items,
330        &index,
331        "6/7 write records",
332    )?;
333    drop(index);
334    report.phases.record_write_ms = phase.elapsed().as_millis();
335
336    let phase = Instant::now();
337    report.scratch.spilled_bytes = scratch.spilled_bytes();
338    report.scratch.peak_tracked_bytes = memory.peak() as u64;
339    drop(scratch);
340    let progress = stage_progress("7/7 finish Pack indexes");
341    let (sealed, stats) = writer.seal()?;
342    progress.finish_with_message("7/7 finish Pack indexes complete");
343    report.phases.pack_seal_ms = phase.elapsed().as_millis();
344
345    for (name, stats) in [
346        ("node_requests", request_stats),
347        ("resolved_nodes", resolved_stats),
348        ("records", record_stats),
349        ("context_records", context_stats),
350        (
351            "spatial_pairs",
352            SortStats {
353                items: 0,
354                runs: stats.spatial_pair_runs,
355            },
356        ),
357    ] {
358        report.scratch.sorters.insert(
359            name.to_string(),
360            SorterReport {
361                items: stats.items,
362                runs: stats.runs,
363            },
364        );
365    }
366    report.output.pack_bytes = stats.bytes;
367    report.output.record_count = sealed.manifest().record_count;
368    report.output.text_index_bytes = stats.text_index_bytes;
369    report.output.sections = stats.sections;
370    report.output.rejection_count = audit.count;
371    report.phases.total_ms = started.elapsed().as_millis();
372    report.finalize_throughput();
373    audit.finish(&report)?;
374    sealed.publish()?;
375    Ok(report)
376}
377
378/// Build state shared by the passes.
379struct Build {
380    report: BuilderReport,
381    audit: Audit,
382    postcodes: PostcodeAccumulator,
383    /// Addresses, interpolations and streets.
384    records: ExternalSorter<PendingRecord>,
385    /// Places and postcodes: everything a context tuple can point at.
386    context_records: ExternalSorter<PendingRecord>,
387    seq: u64,
388}
389
390impl Build {
391    /// Merge one worker's output, in input order.
392    fn absorb(&mut self, emitted: Emitted) -> Result<()> {
393        let Emitted {
394            report,
395            records,
396            rejections,
397            postcodes,
398        } = emitted;
399        self.report.merge(report);
400        self.postcodes.merge(postcodes);
401        for rejection in &rejections {
402            self.audit.write(rejection)?;
403        }
404        for record in records {
405            self.push(record, None)?;
406        }
407        Ok(())
408    }
409
410    fn push(&mut self, record: Record, origin: Option<ContextOrigin>) -> Result<()> {
411        let [lon, lat] = record
412            .display_point()
413            .with_context(|| format!("record {} has no display point", record.id()))?;
414        let pending = PendingRecord {
415            key: hilbert_key(quantize(lon)?, quantize(lat)?),
416            seq: self.seq,
417            origin,
418            record,
419        };
420        self.seq += 1;
421        if pending.record.layer().is_context() {
422            self.context_records.push(pending)
423        } else {
424            self.records.push(pending)
425        }
426    }
427}
428
429fn request_nodes(
430    requests: &mut ExternalSorter<NodeRequest>,
431    owner: u64,
432    refs: &[i64],
433    want_tags: bool,
434) -> Result<()> {
435    for (pos, node_id) in refs.iter().enumerate() {
436        requests.push(NodeRequest {
437            node_id: *node_id,
438            owner,
439            pos: u32::try_from(pos).context("way has too many nodes")?,
440            want_tags,
441        })?;
442    }
443    Ok(())
444}
445
446fn member_ways(block: &osmpbf::PrimitiveBlock, member_ids: &HashSet<i64>) -> Vec<(i64, Vec<i64>)> {
447    block
448        .elements()
449        .filter_map(|element| match element {
450            osmpbf::Element::Way(way) if member_ids.contains(&way.id()) => {
451                Some((way.id(), way.refs().collect()))
452            }
453            _ => None,
454        })
455        .collect()
456}
457
458/// Write sorted records in batches, computing admin context in parallel.
459/// Boundary and derived-country records describe context and get none.
460fn write_ordered(
461    writer: &mut PackWriter,
462    records: impl Iterator<Item = Result<PendingRecord>>,
463    total: u64,
464    index: &BoundaryIndex,
465    message: &'static str,
466) -> Result<()> {
467    let progress = item_progress_bar(total, message);
468    let mut records = records.peekable();
469    while records.peek().is_some() {
470        let batch = records
471            .by_ref()
472            .take(WRITE_BATCH_RECORDS)
473            .collect::<Result<Vec<_>>>()?;
474        let batch = batch
475            .into_par_iter()
476            .map(|pending| {
477                let context: Option<RecordContext> = match pending.origin {
478                    Some(_) => None,
479                    None => record_context(index, &pending.record),
480                };
481                (pending.record, context)
482            })
483            .collect::<Vec<_>>();
484        writer.write_batch(&batch)?;
485        progress.inc(batch.len() as u64);
486    }
487    progress.finish_with_message(format!("{message} complete"));
488    Ok(())
489}
490
491/// Build audit outputs written next to the Pack file, outside it.
492struct Audit {
493    dir: PathBuf,
494    rejections: BufWriter<File>,
495    count: u64,
496}
497
498impl Audit {
499    fn create(generation: &Path) -> Result<Self> {
500        let dir = generation.join(AUDIT_DIR);
501        fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
502        let path = dir.join(REJECTIONS_FILE);
503        let file =
504            File::create(&path).with_context(|| format!("failed to create {}", path.display()))?;
505        Ok(Self {
506            dir,
507            rejections: BufWriter::with_capacity(1 << 20, file),
508            count: 0,
509        })
510    }
511
512    fn write(&mut self, rejection: &RejectedRecord) -> Result<()> {
513        serde_json::to_writer(&mut self.rejections, rejection)?;
514        self.rejections.write_all(b"\n")?;
515        self.count += 1;
516        Ok(())
517    }
518
519    fn finish(mut self, report: &BuilderReport) -> Result<()> {
520        self.rejections.flush()?;
521        let path = self.dir.join(BUILD_REPORT_FILE);
522        let file =
523            File::create(&path).with_context(|| format!("failed to create {}", path.display()))?;
524        serde_json::to_writer_pretty(BufWriter::new(file), report)
525            .with_context(|| format!("failed to write {}", path.display()))
526    }
527}
528
529#[cfg(test)]
530mod tests {
531    use crate::{
532        pack::PackReader,
533        reverse::{PackReverseGeocoder, ReverseGeocodeOptions, ReverseMatchKind},
534        search::{PackTextSearcher, TextSearchOptions},
535    };
536
537    use super::{
538        test_pbf::{TestElement, node, relation, way, write_pbf},
539        *,
540    };
541
542    fn temp_dir(name: &str) -> PathBuf {
543        let dir = std::env::temp_dir().join(format!(
544            "open-geocode-build-{name}-{}",
545            uuid::Uuid::new_v4()
546        ));
547        fs::create_dir_all(&dir).expect("dir");
548        dir
549    }
550
551    /// A small town: a locality inside a region, addresses, a street, a
552    /// building, an interpolation range, and a few objects that must be
553    /// rejected. Split over several blocks like a real extract.
554    fn town() -> Vec<Vec<TestElement>> {
555        vec![
556            vec![
557                node(
558                    1,
559                    43.60,
560                    -79.40,
561                    &[
562                        ("addr:housenumber", "10"),
563                        ("addr:street", "King Street"),
564                        ("addr:city", "Toronto"),
565                        ("addr:postcode", "M5V 1A1"),
566                    ],
567                ),
568                node(
569                    2,
570                    43.6001,
571                    -79.4001,
572                    &[
573                        ("addr:housenumber", "12"),
574                        ("addr:street", "King Street"),
575                        ("addr:postcode", "m5v  1a1"),
576                        // Complete source context: its report sample has no missing fields.
577                        ("addr:city", "Toronto"),
578                        ("addr:state", "ON"),
579                        ("addr:country", "CA"),
580                    ],
581                ),
582                node(3, 43.6002, -79.4002, &[("addr:housenumber", "14")]),
583                node(
584                    4,
585                    43.67,
586                    -79.40,
587                    &[("place", "neighbourhood"), ("name", "Annex")],
588                ),
589                node(20, 43.61, -79.42, &[]),
590                node(21, 43.61, -79.41, &[]),
591                node(30, 43.6200, -79.4300, &[]),
592                node(31, 43.6200, -79.4298, &[]),
593                node(32, 43.6202, -79.4298, &[]),
594                node(33, 43.6202, -79.4300, &[]),
595            ],
596            vec![
597                node(
598                    40,
599                    43.630,
600                    -79.44,
601                    &[("addr:housenumber", "2"), ("addr:street", "Elm Street")],
602                ),
603                node(
604                    41,
605                    43.631,
606                    -79.44,
607                    &[("addr:housenumber", "10"), ("addr:street", "Elm Street")],
608                ),
609                node(1001, 43.5, -79.5, &[]),
610                node(1002, 43.5, -79.3, &[]),
611                node(1003, 43.7, -79.3, &[]),
612                node(1004, 43.7, -79.5, &[]),
613                node(1011, 43.0, -80.0, &[]),
614                node(1012, 43.0, -79.0, &[]),
615                node(1013, 44.0, -79.0, &[]),
616                node(1014, 44.0, -80.0, &[]),
617            ],
618            vec![
619                way(500, &[1001, 1002, 1003], &[]),
620                way(501, &[1003, 1004, 1001], &[]),
621                way(502, &[1011, 1012, 1013, 1014, 1011], &[]),
622                way(
623                    600,
624                    &[20, 21],
625                    &[("highway", "residential"), ("name", "Queen Street")],
626                ),
627                way(601, &[20, 21], &[("highway", "service")]),
628                way(
629                    602,
630                    &[30, 31, 32, 33, 30],
631                    &[
632                        ("building", "yes"),
633                        ("addr:housenumber", "5"),
634                        ("addr:street", "Bay Street"),
635                    ],
636                ),
637                way(
638                    603,
639                    &[40, 41],
640                    &[
641                        ("addr:interpolation", "even"),
642                        ("addr:street", "Elm Street"),
643                    ],
644                ),
645                way(
646                    604,
647                    &[20, 9999],
648                    &[("highway", "residential"), ("name", "Ghost Road")],
649                ),
650                // A named boundary way that is also a relation member.
651                way(
652                    605,
653                    &[30, 31, 32, 33, 30],
654                    &[
655                        ("boundary", "administrative"),
656                        ("admin_level", "10"),
657                        ("name", "Bay Ward"),
658                    ],
659                ),
660            ],
661            vec![
662                relation(
663                    900,
664                    &[(500, "outer"), (501, "outer")],
665                    &[
666                        ("boundary", "administrative"),
667                        ("admin_level", "8"),
668                        ("name", "Toronto"),
669                    ],
670                ),
671                relation(
672                    902,
673                    &[(605, "outer")],
674                    &[
675                        ("boundary", "administrative"),
676                        ("admin_level", "10"),
677                        ("name", "Bay Ward Relation"),
678                    ],
679                ),
680                relation(
681                    901,
682                    &[(502, "outer")],
683                    &[
684                        ("boundary", "administrative"),
685                        ("admin_level", "4"),
686                        ("name", "Ontario"),
687                        ("ISO3166-2", "CA-ON"),
688                    ],
689                ),
690            ],
691        ]
692    }
693
694    fn build(root: &Path, input: &Path, memory_budget_bytes: usize) -> BuilderReport {
695        build_osm_pack(BuildOsmOptions {
696            input: input.to_path_buf(),
697            pack: root.to_path_buf(),
698            memory_budget_bytes,
699            scratch_dir: None,
700        })
701        .expect("build")
702    }
703
704    #[test]
705    fn builds_a_searchable_reverse_geocodable_pack_from_pbf() {
706        let dir = temp_dir("town");
707        let input = dir.join("town.osm.pbf");
708        write_pbf(&input, &town());
709        let report = build(&dir.join("pack"), &input, DEFAULT_MEMORY_BUDGET_BYTES);
710
711        let reader = PackReader::open(dir.join("pack")).expect("reader");
712        let counts = &reader.manifest().layer_counts;
713        let count = |layer: &str| counts.get(layer).copied().unwrap_or_default();
714        assert_eq!(
715            count("address"),
716            5,
717            "four nodes (two are anchors) and one building"
718        );
719        assert_eq!(count("street"), 1);
720        assert_eq!(count("interpolation"), 1);
721        assert_eq!(count("postcode"), 1);
722        assert_eq!(
723            count("neighbourhood"),
724            3,
725            "Annex, the Bay Ward way and its relation"
726        );
727        assert_eq!(count("locality"), 1);
728        assert_eq!(count("region"), 1);
729        assert_eq!(count("country"), 1, "derived from ISO3166-2");
730        assert_eq!(report.output.record_count, 14);
731        // Each way's nodes are requested once, even when a boundary way is also
732        // a relation member: 3 + 3 + 5 + 2 + 5 + 2 + 2 + 5.
733        assert_eq!(report.geometry_resolution.required_node_refs, 27);
734        assert_eq!(report.accepted.postcode_records, 1);
735        assert_eq!(report.geometry_resolution.street_way_stubs, 2);
736        assert_eq!(
737            report.scratch.sorters["records"].runs, 0,
738            "a small input never spills"
739        );
740        assert!(report.scratch.peak_tracked_bytes > 0);
741        assert!(report.scratch.peak_tracked_bytes <= report.scratch.memory_budget_bytes);
742
743        // Context records come first, and each group is in Hilbert order.
744        let headers = (0..reader.manifest().record_count)
745            .map(|id| reader.records().header(id).expect("header"))
746            .collect::<Vec<_>>();
747        let first_other = headers
748            .iter()
749            .position(|header| !header.layer.is_context())
750            .expect("non-context records");
751        assert!(
752            headers[..first_other]
753                .iter()
754                .all(|header| header.layer.is_context())
755        );
756        assert!(
757            headers[first_other..]
758                .iter()
759                .all(|header| !header.layer.is_context())
760        );
761        for group in [&headers[..first_other], &headers[first_other..]] {
762            let keys = group
763                .iter()
764                .map(|header| hilbert_key(header.lon_e7, header.lat_e7))
765                .collect::<Vec<_>>();
766            assert!(keys.windows(2).all(|pair| pair[0] <= pair[1]));
767        }
768
769        // Boundary-derived context.
770        let king = reader
771            .find_by_source_id("osm:node:1")
772            .expect("lookup")
773            .expect("address node 1");
774        let context = reader
775            .boundary_context(king)
776            .expect("context")
777            .expect("has context");
778        let name = |id: Option<u64>| {
779            reader
780                .context_record(id.expect("id"))
781                .expect("record")
782                .expect("context")
783                .name
784        };
785        assert_eq!(name(context.admin_context.locality_record_id), "Toronto");
786        assert_eq!(name(context.admin_context.region_record_id), "Ontario");
787        assert_eq!(name(context.admin_context.country_record_id), "Canada");
788
789        let searcher = PackTextSearcher::open(dir.join("pack")).expect("searcher");
790        let hits = searcher
791            .search(TextSearchOptions {
792                query: "10 King Street".into(),
793                limit: 1,
794                layer: Some("address".into()),
795            })
796            .expect("search");
797        assert_eq!(hits[0].record.id, "osm:node:1");
798        assert_eq!(hits[0].record.label, "10 King Street, Toronto, M5V 1A1");
799
800        let reverse = PackReverseGeocoder::open(dir.join("pack")).expect("reverse");
801        let result = |lon, lat| {
802            reverse
803                .reverse(ReverseGeocodeOptions { lon, lat })
804                .expect("reverse")
805                .result
806                .expect("result")
807        };
808        let building = result(-79.4299, 43.6201);
809        assert_eq!(building.match_kind, ReverseMatchKind::ExplicitAddress);
810        assert_eq!(building.id.as_deref(), Some("osm:way:602"));
811        let estimated = result(-79.44001, 43.6305);
812        assert_eq!(estimated.match_kind, ReverseMatchKind::EstimatedAddress);
813        assert!(
814            estimated.label.starts_with("6 Elm Street"),
815            "{}",
816            estimated.label
817        );
818        let street = result(-79.415, 43.61001);
819        assert_eq!(street.match_kind, ReverseMatchKind::NearestStreet);
820        assert!(
821            street.label.starts_with("Queen Street, ") && street.label.contains("Toronto"),
822            "{}",
823            street.label
824        );
825
826        // Rejections go to the build audit, not the Pack.
827        let rejections = reader.rejections(0).expect("rejections");
828        let reasons = rejections
829            .iter()
830            .map(|rejection| rejection.reason.as_str())
831            .collect::<Vec<_>>();
832        assert!(reasons.contains(&"missing_street_or_place"), "{reasons:?}");
833        assert!(
834            reasons.contains(&"street_unresolved_geometry"),
835            "{reasons:?}"
836        );
837        assert!(
838            !reader
839                .section_sizes()
840                .keys()
841                .any(|name| name.contains("reject"))
842        );
843        assert_eq!(report.output.rejection_count, rejections.len() as u64);
844        let generation = reader.path().parent().expect("generation");
845        assert!(generation.join(AUDIT_DIR).join(BUILD_REPORT_FILE).is_file());
846
847        // The benchmark reads the build report back.
848        let bench = crate::bench::benchmark_pack(crate::bench::PackBenchmarkOptions {
849            pack: dir.join("pack"),
850            queries: None,
851            iterations: 1,
852            warmup: 0,
853        })
854        .expect("bench");
855        let build_metrics = bench.pack.build.expect("build metrics");
856        assert_eq!(build_metrics.accepted_records, report.accepted.total);
857        assert_eq!(build_metrics.scratch, report.scratch);
858    }
859
860    #[test]
861    fn spilling_to_disk_builds_the_same_pack() {
862        let dir = temp_dir("spill");
863        let input = dir.join("town.osm.pbf");
864        write_pbf(&input, &town());
865        let in_memory = build(&dir.join("memory"), &input, DEFAULT_MEMORY_BUDGET_BYTES);
866        // A 4-byte budget leaves every sorter one item of memory, so everything
867        // goes through run files and multi-round merges.
868        let spilled = build(&dir.join("spilled"), &input, 4);
869        let spilling_sorters = spilled
870            .scratch
871            .sorters
872            .values()
873            .filter(|sorter| sorter.runs > 0)
874            .count();
875        assert!(spilling_sorters >= 4, "{:?}", spilled.scratch);
876        assert!(spilled.scratch.spilled_bytes > 0);
877        assert_eq!(in_memory.accepted, spilled.accepted);
878        assert_eq!(in_memory.rejected, spilled.rejected);
879        assert_eq!(in_memory.triage, spilled.triage);
880
881        let memory = PackReader::open(dir.join("memory")).expect("memory pack");
882        let spilled = PackReader::open(dir.join("spilled")).expect("spilled pack");
883        assert_eq!(
884            memory.manifest().record_count,
885            spilled.manifest().record_count
886        );
887        for id in 0..memory.manifest().record_count {
888            assert_eq!(
889                memory.record_json(id).expect("memory record"),
890                spilled.record_json(id).expect("spilled record")
891            );
892            assert_eq!(
893                memory.boundary_context(id).expect("memory context"),
894                spilled.boundary_context(id).expect("spilled context")
895            );
896        }
897    }
898
899    #[test]
900    fn rejects_inputs_whose_nodes_are_not_sorted() {
901        let dir = temp_dir("unsorted");
902        let input = dir.join("unsorted.osm.pbf");
903        write_pbf(
904            &input,
905            &[
906                vec![node(5, 43.6, -79.4, &[]), node(3, 43.6, -79.4, &[])],
907                vec![way(
908                    10,
909                    &[3, 5],
910                    &[("highway", "residential"), ("name", "Loop")],
911                )],
912            ],
913        );
914        let error = build_osm_pack(BuildOsmOptions {
915            input,
916            pack: dir.join("pack"),
917            ..BuildOsmOptions::default()
918        })
919        .unwrap_err();
920        assert!(format!("{error:#}").contains("osmium sort"), "{error:#}");
921        assert!(!dir.join("pack").join("CURRENT").exists());
922    }
923}