Skip to main content

open_geocode/builder/
report.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::{
6    record::{AddressRecord, LocationPrecision, OsmObjectType, PlaceLayer},
7    util::geo::point_lon_lat,
8};
9
10pub const BUILD_REPORT_SCHEMA_VERSION: u32 = 13;
11
12#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
13pub struct BuilderReport {
14    pub schema_version: u32,
15    pub input: String,
16    pub pack: String,
17    pub input_bytes: u64,
18    pub scanned: ScannedCounts,
19    pub accepted: AcceptedCounts,
20    pub rejected: RejectedCounts,
21    pub disposition: CandidateDispositionCounts,
22    pub validation: ValidationAuditCounts,
23    pub quality: AcceptedQualityReport,
24    pub triage: RejectionTriageReport,
25    pub geometry_resolution: GeometryResolutionCounts,
26    pub completeness: CompletenessCounts,
27    pub phases: PhaseTimings,
28    pub throughput: Throughput,
29    pub scratch: ScratchReport,
30    pub output: PackOutputReport,
31}
32
33impl BuilderReport {
34    /// Derive build throughput from the final wall-clock and record counts.
35    /// Call once `phases.total_ms` is final.
36    pub(crate) fn finalize_throughput(&mut self) {
37        let secs = self.phases.total_ms as f64 / 1000.0;
38        if secs <= 0.0 {
39            return;
40        }
41        self.throughput.records_per_sec = self.output.record_count as f64 / secs;
42        self.throughput.addresses_per_sec =
43            *self.accepted.by_layer.get("address").unwrap_or(&0) as f64 / secs;
44    }
45
46    /// Fold a report produced by a parallel worker into this one. Workers cover
47    /// consecutive input ranges and are merged in input order, so capped sample
48    /// lists keep the same entries a sequential build would.
49    pub(crate) fn merge(&mut self, other: BuilderReport) {
50        let BuilderReport {
51            scanned,
52            accepted,
53            rejected,
54            disposition,
55            validation,
56            quality,
57            triage,
58            geometry_resolution,
59            completeness,
60            ..
61        } = other;
62        self.scanned.nodes += scanned.nodes;
63        self.scanned.dense_nodes += scanned.dense_nodes;
64        self.scanned.ways += scanned.ways;
65        self.scanned.relations += scanned.relations;
66
67        self.accepted.total += accepted.total;
68        merge_counts(&mut self.accepted.by_layer, accepted.by_layer);
69        self.accepted.node_addresses += accepted.node_addresses;
70        self.accepted.way_centroid_addresses += accepted.way_centroid_addresses;
71        self.accepted.interpolation_ranges += accepted.interpolation_ranges;
72        self.accepted.street_segments += accepted.street_segments;
73        self.accepted.postcode_records += accepted.postcode_records;
74        self.accepted.place_nodes += accepted.place_nodes;
75
76        self.rejected.total += rejected.total;
77        merge_counts(&mut self.rejected.by_reason, rejected.by_reason);
78
79        self.disposition.invalid += disposition.invalid;
80        self.disposition.out_of_scope += disposition.out_of_scope;
81        self.disposition.unsupported += disposition.unsupported;
82        self.disposition.unresolved_geometry += disposition.unresolved_geometry;
83        merge_counts(&mut self.disposition.by_reason, disposition.by_reason);
84
85        self.validation
86            .missing_housenumber
87            .merge(validation.missing_housenumber);
88        self.validation
89            .missing_street_or_place
90            .merge(validation.missing_street_or_place);
91        self.validation
92            .unsupported_relation
93            .merge(validation.unsupported_relation);
94        self.validation
95            .unresolved_geometry
96            .merge(validation.unresolved_geometry);
97        self.validation
98            .interpolation
99            .merge(validation.interpolation);
100
101        self.quality.addresses.merge(quality.addresses);
102        merge_samples(
103            &mut self.quality.samples,
104            quality.samples,
105            MAX_ACCEPTED_QUALITY_SAMPLES_PER_BUCKET,
106        );
107
108        merge_counts(&mut self.triage.by_bucket, triage.by_bucket);
109        for (reason, buckets) in triage.by_reason_and_bucket {
110            merge_counts(
111                self.triage.by_reason_and_bucket.entry(reason).or_default(),
112                buckets,
113            );
114        }
115        let gaps = triage.street_name_gaps;
116        merge_counts(
117            &mut self.triage.street_name_gaps.by_highway,
118            gaps.by_highway,
119        );
120        merge_counts(
121            &mut self.triage.street_name_gaps.by_service,
122            gaps.by_service,
123        );
124        merge_counts(&mut self.triage.street_name_gaps.by_bucket, gaps.by_bucket);
125        merge_samples(
126            &mut self.triage.samples,
127            triage.samples,
128            MAX_REJECTION_SAMPLES_PER_REASON_BUCKET,
129        );
130
131        let geometry = geometry_resolution;
132        self.geometry_resolution.address_way_stubs += geometry.address_way_stubs;
133        self.geometry_resolution.interpolation_way_stubs += geometry.interpolation_way_stubs;
134        self.geometry_resolution.street_way_stubs += geometry.street_way_stubs;
135        self.geometry_resolution.boundary_way_stubs += geometry.boundary_way_stubs;
136        self.geometry_resolution.required_node_refs += geometry.required_node_refs;
137        self.geometry_resolution.resolved_node_refs += geometry.resolved_node_refs;
138
139        self.completeness.city += completeness.city;
140        self.completeness.postcode += completeness.postcode;
141        self.completeness.state += completeness.state;
142        self.completeness.country += completeness.country;
143    }
144}
145
146fn merge_counts(target: &mut BTreeMap<String, u64>, source: BTreeMap<String, u64>) {
147    for (key, count) in source {
148        *target.entry(key).or_default() += count;
149    }
150}
151
152fn merge_samples<T>(
153    target: &mut BTreeMap<String, Vec<T>>,
154    source: BTreeMap<String, Vec<T>>,
155    cap: usize,
156) {
157    for (bucket, samples) in source {
158        let existing = target.entry(bucket).or_default();
159        let room = cap.saturating_sub(existing.len());
160        existing.extend(samples.into_iter().take(room));
161    }
162}
163
164/// Disk used for build scratch files and how often each sorter spilled.
165#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
166pub struct ScratchReport {
167    pub memory_budget_bytes: u64,
168    /// Most memory held at once by sort, merge and text indexing buffers.
169    pub peak_tracked_bytes: u64,
170    pub spilled_bytes: u64,
171    pub sorters: BTreeMap<String, SorterReport>,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
175pub struct SorterReport {
176    pub items: u64,
177    pub runs: u64,
178}
179
180/// What the build wrote.
181#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
182pub struct PackOutputReport {
183    pub pack_bytes: u64,
184    pub record_count: u64,
185    pub text_index_bytes: u64,
186    pub rejection_count: u64,
187    pub sections: BTreeMap<String, u64>,
188}
189
190/// Build throughput derived from `phases.total_ms` (records/addresses per second).
191#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
192pub struct Throughput {
193    pub records_per_sec: f64,
194    pub addresses_per_sec: f64,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
198pub struct ScannedCounts {
199    pub nodes: u64,
200    pub dense_nodes: u64,
201    pub ways: u64,
202    pub relations: u64,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
206pub struct AcceptedCounts {
207    pub total: u64,
208    pub by_layer: BTreeMap<String, u64>,
209    pub node_addresses: u64,
210    pub way_centroid_addresses: u64,
211    pub interpolation_ranges: u64,
212    pub street_segments: u64,
213    pub postcode_records: u64,
214    pub place_nodes: u64,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
218pub struct RejectedCounts {
219    pub total: u64,
220    pub by_reason: BTreeMap<String, u64>,
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
224pub struct CandidateDispositionCounts {
225    pub invalid: u64,
226    pub out_of_scope: u64,
227    pub unsupported: u64,
228    pub unresolved_geometry: u64,
229    pub by_reason: BTreeMap<String, u64>,
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
233pub struct ValidationAuditCounts {
234    pub missing_housenumber: IssueAuditCounts,
235    pub missing_street_or_place: IssueAuditCounts,
236    pub unsupported_relation: IssueAuditCounts,
237    pub unresolved_geometry: IssueAuditCounts,
238    pub interpolation: IssueAuditCounts,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
242pub struct AcceptedQualityReport {
243    pub addresses: AcceptedAddressQuality,
244    pub samples: BTreeMap<String, Vec<AcceptedRecordSample>>,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
248pub struct AcceptedAddressQuality {
249    pub total: u64,
250    pub has_street: u64,
251    pub has_place: u64,
252    pub has_unit: u64,
253    pub has_locality: u64,
254    pub missing_locality: u64,
255    pub has_region: u64,
256    pub missing_region: u64,
257    pub has_postcode: u64,
258    pub missing_postcode: u64,
259    pub has_country: u64,
260    pub missing_country: u64,
261    pub has_full_admin_context: u64,
262    pub missing_any_admin_context: u64,
263    pub enrichable_by_point: u64,
264    pub not_enrichable_by_point: u64,
265    pub by_location_precision: BTreeMap<String, u64>,
266    pub by_context_shape: BTreeMap<String, u64>,
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
270pub struct AcceptedRecordSample {
271    pub bucket: String,
272    pub layer: String,
273    pub id: String,
274    pub label: String,
275    pub source_id: String,
276    pub object_type: OsmObjectType,
277    pub object_id: i64,
278    #[serde(default, skip_serializing_if = "Vec::is_empty")]
279    pub missing_fields: Vec<String>,
280    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
281    pub tags: BTreeMap<String, String>,
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
285pub struct RejectionTriageReport {
286    pub by_bucket: BTreeMap<String, u64>,
287    pub by_reason_and_bucket: BTreeMap<String, BTreeMap<String, u64>>,
288    pub street_name_gaps: StreetNameGapCounts,
289    pub samples: BTreeMap<String, Vec<RejectionSample>>,
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
293pub struct StreetNameGapCounts {
294    pub by_highway: BTreeMap<String, u64>,
295    pub by_service: BTreeMap<String, u64>,
296    pub by_bucket: BTreeMap<String, u64>,
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
300pub struct RejectionSample {
301    pub reason: String,
302    pub bucket: String,
303    pub note: String,
304    pub object_type: OsmObjectType,
305    pub object_id: i64,
306    pub source_id: String,
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub layer_hint: Option<String>,
309    pub writes_rejection_record: bool,
310    pub tags: BTreeMap<String, String>,
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
314pub struct IssueAuditCounts {
315    pub by_shape: BTreeMap<String, u64>,
316    pub by_feature_context: BTreeMap<String, u64>,
317    pub by_object_type: BTreeMap<String, u64>,
318    pub by_addr_key: BTreeMap<String, u64>,
319}
320
321impl IssueAuditCounts {
322    fn merge(&mut self, other: IssueAuditCounts) {
323        merge_counts(&mut self.by_shape, other.by_shape);
324        merge_counts(&mut self.by_feature_context, other.by_feature_context);
325        merge_counts(&mut self.by_object_type, other.by_object_type);
326        merge_counts(&mut self.by_addr_key, other.by_addr_key);
327    }
328}
329
330impl AcceptedAddressQuality {
331    fn merge(&mut self, other: AcceptedAddressQuality) {
332        self.total += other.total;
333        self.has_street += other.has_street;
334        self.has_place += other.has_place;
335        self.has_unit += other.has_unit;
336        self.has_locality += other.has_locality;
337        self.missing_locality += other.missing_locality;
338        self.has_region += other.has_region;
339        self.missing_region += other.missing_region;
340        self.has_postcode += other.has_postcode;
341        self.missing_postcode += other.missing_postcode;
342        self.has_country += other.has_country;
343        self.missing_country += other.missing_country;
344        self.has_full_admin_context += other.has_full_admin_context;
345        self.missing_any_admin_context += other.missing_any_admin_context;
346        self.enrichable_by_point += other.enrichable_by_point;
347        self.not_enrichable_by_point += other.not_enrichable_by_point;
348        merge_counts(&mut self.by_location_precision, other.by_location_precision);
349        merge_counts(&mut self.by_context_shape, other.by_context_shape);
350    }
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
354pub struct GeometryResolutionCounts {
355    pub address_way_stubs: u64,
356    pub interpolation_way_stubs: u64,
357    pub street_way_stubs: u64,
358    pub boundary_way_stubs: u64,
359    pub required_node_refs: u64,
360    pub resolved_node_refs: u64,
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
364pub struct CompletenessCounts {
365    pub city: u64,
366    pub postcode: u64,
367    pub state: u64,
368    pub country: u64,
369}
370
371/// Wall-clock time per build phase, in order.
372#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
373pub struct PhaseTimings {
374    /// Pass 1: classify every OSM object.
375    pub scan_ms: u128,
376    /// Pass 2: read the ways that make up boundary relations.
377    pub boundary_member_scan_ms: u128,
378    /// Pass 3: join node coordinates onto way references.
379    pub node_join_ms: u128,
380    /// Assemble way geometry into records.
381    pub feature_emission_ms: u128,
382    /// Build admin boundary polygons.
383    pub boundary_build_ms: u128,
384    /// Write places, postcodes and boundaries.
385    pub context_record_write_ms: u128,
386    /// Write addresses, interpolations and streets in Hilbert order.
387    pub record_write_ms: u128,
388    /// Finish the text and spatial indexes and write the Pack file.
389    pub pack_seal_ms: u128,
390    pub total_ms: u128,
391}
392
393#[derive(Debug, Clone, Copy, PartialEq, Eq)]
394pub(crate) enum CandidateIssue {
395    MissingHouseNumber,
396    MissingStreetOrPlace,
397    UnsupportedRelation,
398    WayWithoutResolvedNodes,
399    InterpolationUnsupportedValue,
400    InterpolationUnsupportedObject,
401    InterpolationWayWithoutNodes,
402    InterpolationUnresolvedGeometry,
403    InterpolationMissingAnchors,
404    InterpolationInsufficientNumericAnchors,
405    InterpolationMissingStreetOrPlace,
406    InterpolationAnchorStreetMismatch,
407    InterpolationInvalidNumberRange,
408    InterpolationInvalidParity,
409    InterpolationNonNumericAnchor,
410    StreetMissingName,
411    StreetRefOnlyName,
412    StreetUnresolvedGeometry,
413    PlaceMissingName,
414    PlaceUnsupportedValue,
415}
416
417impl CandidateIssue {
418    pub(crate) const fn as_str(self) -> &'static str {
419        match self {
420            Self::MissingHouseNumber => "missing_housenumber",
421            Self::MissingStreetOrPlace => "missing_street_or_place",
422            Self::UnsupportedRelation => "unsupported_relation",
423            Self::WayWithoutResolvedNodes => "way_without_resolved_nodes",
424            Self::InterpolationUnsupportedValue => "interpolation_unsupported_value",
425            Self::InterpolationUnsupportedObject => "interpolation_unsupported_object",
426            Self::InterpolationWayWithoutNodes => "interpolation_way_without_nodes",
427            Self::InterpolationUnresolvedGeometry => "interpolation_unresolved_geometry",
428            Self::InterpolationMissingAnchors => "interpolation_missing_anchors",
429            Self::InterpolationInsufficientNumericAnchors => {
430                "interpolation_insufficient_numeric_anchors"
431            }
432            Self::InterpolationMissingStreetOrPlace => "interpolation_missing_street_or_place",
433            Self::InterpolationAnchorStreetMismatch => "interpolation_anchor_street_mismatch",
434            Self::InterpolationInvalidNumberRange => "interpolation_invalid_number_range",
435            Self::InterpolationInvalidParity => "interpolation_invalid_parity",
436            Self::InterpolationNonNumericAnchor => "interpolation_non_numeric_anchor",
437            Self::StreetMissingName => "street_missing_name",
438            Self::StreetRefOnlyName => "street_ref_only_name",
439            Self::StreetUnresolvedGeometry => "street_unresolved_geometry",
440            Self::PlaceMissingName => "place_missing_name",
441            Self::PlaceUnsupportedValue => "place_unsupported_value",
442        }
443    }
444
445    const fn disposition(self) -> CandidateDisposition {
446        match self {
447            Self::MissingHouseNumber | Self::MissingStreetOrPlace => CandidateDisposition::Invalid,
448            Self::UnsupportedRelation
449            | Self::InterpolationUnsupportedValue
450            | Self::InterpolationUnsupportedObject => CandidateDisposition::Unsupported,
451            Self::WayWithoutResolvedNodes
452            | Self::InterpolationWayWithoutNodes
453            | Self::InterpolationUnresolvedGeometry
454            | Self::StreetUnresolvedGeometry => CandidateDisposition::UnresolvedGeometry,
455            Self::InterpolationMissingAnchors
456            | Self::InterpolationInsufficientNumericAnchors
457            | Self::InterpolationMissingStreetOrPlace
458            | Self::InterpolationAnchorStreetMismatch
459            | Self::InterpolationInvalidNumberRange
460            | Self::InterpolationInvalidParity
461            | Self::InterpolationNonNumericAnchor => CandidateDisposition::Invalid,
462            Self::StreetMissingName
463            | Self::StreetRefOnlyName
464            | Self::PlaceMissingName
465            | Self::PlaceUnsupportedValue => CandidateDisposition::OutOfScope,
466        }
467    }
468}
469
470#[derive(Debug, Clone, Copy, PartialEq, Eq)]
471enum CandidateDisposition {
472    Invalid,
473    OutOfScope,
474    Unsupported,
475    UnresolvedGeometry,
476}
477
478struct RejectionTriage {
479    bucket: &'static str,
480    note: &'static str,
481}
482
483const MAX_REJECTION_SAMPLES_PER_REASON_BUCKET: usize = 5;
484const MAX_ACCEPTED_QUALITY_SAMPLES_PER_BUCKET: usize = 5;
485
486impl BuilderReport {
487    pub(crate) fn accept_address_with_tags(
488        &mut self,
489        address: &AddressRecord,
490        source_tags: Option<&BTreeMap<String, String>>,
491    ) {
492        self.accept_layer("address");
493        match address.location_precision() {
494            LocationPrecision::Point => self.accepted.node_addresses += 1,
495            LocationPrecision::Centroid => self.accepted.way_centroid_addresses += 1,
496        };
497
498        if address.address.locality.is_some() {
499            self.completeness.city += 1;
500        }
501        if address.address.postcode.is_some() {
502            self.completeness.postcode += 1;
503        }
504        if address.address.region.is_some() {
505            self.completeness.state += 1;
506        }
507        if address.address.country.is_some() {
508            self.completeness.country += 1;
509        }
510        self.record_address_quality(address, source_tags);
511    }
512
513    pub(crate) fn accept_interpolation(&mut self) {
514        self.accept_layer("interpolation");
515        self.accepted.interpolation_ranges += 1;
516    }
517
518    pub(crate) fn accept_street(&mut self) {
519        self.accept_layer("street");
520        self.accepted.street_segments += 1;
521    }
522
523    pub(crate) fn accept_postcode(&mut self) {
524        self.accept_layer("postcode");
525        self.accepted.postcode_records += 1;
526    }
527
528    pub(crate) fn accept_place(&mut self, layer: PlaceLayer) {
529        self.accept_layer(layer.as_str());
530        self.accepted.place_nodes += 1;
531    }
532
533    fn accept_layer(&mut self, layer: &str) {
534        self.accepted.total += 1;
535        *self.accepted.by_layer.entry(layer.to_string()).or_default() += 1;
536    }
537
538    fn record_address_quality(
539        &mut self,
540        address: &AddressRecord,
541        source_tags: Option<&BTreeMap<String, String>>,
542    ) {
543        let missing_admin_fields = missing_admin_context_fields(address);
544        let not_enrichable_by_point = point_lon_lat(&address.geometry).is_none();
545        let missing_postcode = address.address.postcode.is_none();
546        let has_complete_source_context = address.address.locality.is_some()
547            && address.address.region.is_some()
548            && address.address.postcode.is_some()
549            && address.address.country.is_some();
550
551        {
552            let quality = &mut self.quality.addresses;
553            quality.total += 1;
554
555            if address.address.street.is_some() {
556                quality.has_street += 1;
557            }
558            if address.address.place.is_some() {
559                quality.has_place += 1;
560            }
561            if address.address.unit.is_some() {
562                quality.has_unit += 1;
563            }
564            count_option(
565                address.address.locality.as_deref(),
566                &mut quality.has_locality,
567                &mut quality.missing_locality,
568            );
569            count_option(
570                address.address.region.as_deref(),
571                &mut quality.has_region,
572                &mut quality.missing_region,
573            );
574            count_option(
575                address.address.postcode.as_deref(),
576                &mut quality.has_postcode,
577                &mut quality.missing_postcode,
578            );
579            count_option(
580                address.address.country.as_deref(),
581                &mut quality.has_country,
582                &mut quality.missing_country,
583            );
584
585            if missing_admin_fields.is_empty() {
586                quality.has_full_admin_context += 1;
587            } else {
588                quality.missing_any_admin_context += 1;
589            }
590
591            if not_enrichable_by_point {
592                quality.not_enrichable_by_point += 1;
593            } else {
594                quality.enrichable_by_point += 1;
595            }
596
597            *quality
598                .by_location_precision
599                .entry(location_precision_name(address.location_precision()).to_string())
600                .or_default() += 1;
601            *quality
602                .by_context_shape
603                .entry(address_context_shape(address))
604                .or_default() += 1;
605        }
606
607        if not_enrichable_by_point {
608            self.accepted_sample(
609                "address/not_enrichable_by_point",
610                address,
611                source_tags,
612                vec!["point_geometry".to_string()],
613            );
614        }
615
616        if !missing_admin_fields.is_empty() {
617            self.accepted_sample(
618                "address/missing_admin_context",
619                address,
620                source_tags,
621                missing_admin_fields,
622            );
623        }
624        if missing_postcode {
625            self.accepted_sample(
626                "address/missing_postcode",
627                address,
628                source_tags,
629                vec!["postcode".to_string()],
630            );
631        }
632        if has_complete_source_context {
633            self.accepted_sample(
634                "address/complete_source_context",
635                address,
636                source_tags,
637                Vec::new(),
638            );
639        }
640    }
641
642    fn accepted_sample(
643        &mut self,
644        bucket: &str,
645        address: &AddressRecord,
646        source_tags: Option<&BTreeMap<String, String>>,
647        missing_fields: Vec<String>,
648    ) {
649        let samples = self.quality.samples.entry(bucket.to_string()).or_default();
650        if samples.len() >= MAX_ACCEPTED_QUALITY_SAMPLES_PER_BUCKET {
651            return;
652        }
653
654        samples.push(AcceptedRecordSample {
655            bucket: bucket.to_string(),
656            layer: "address".to_string(),
657            id: address.id(),
658            label: address.label(),
659            source_id: source_id(address.source.object_type, address.source.object_id),
660            object_type: address.source.object_type,
661            object_id: address.source.object_id,
662            missing_fields,
663            tags: source_tags.cloned().unwrap_or_default(),
664        });
665    }
666
667    // The rejection-audit path genuinely needs the full object/tag context to
668    // triage and record a rejection; bundling it into a struct would only move
669    // the arguments around.
670    #[allow(clippy::too_many_arguments)]
671    pub(crate) fn reject_with_context(
672        &mut self,
673        issue: CandidateIssue,
674        object_type: OsmObjectType,
675        object_id: i64,
676        all_tags: &BTreeMap<String, String>,
677        addr_tags: Option<&BTreeMap<String, String>>,
678        layer_hint: Option<&str>,
679        writes_rejection_record: bool,
680    ) {
681        self.record_rejection(issue);
682        if let Some(addr_tags) = addr_tags {
683            self.audit_rejection(issue, object_type, all_tags, addr_tags);
684        }
685        self.triage_rejection(
686            issue,
687            object_type,
688            object_id,
689            all_tags,
690            layer_hint,
691            writes_rejection_record,
692        );
693    }
694
695    fn record_rejection(&mut self, issue: CandidateIssue) {
696        self.rejected.total += 1;
697        *self
698            .rejected
699            .by_reason
700            .entry(issue.as_str().to_string())
701            .or_default() += 1;
702
703        *self
704            .disposition
705            .by_reason
706            .entry(issue.as_str().to_string())
707            .or_default() += 1;
708
709        match issue.disposition() {
710            CandidateDisposition::Invalid => self.disposition.invalid += 1,
711            CandidateDisposition::OutOfScope => self.disposition.out_of_scope += 1,
712            CandidateDisposition::Unsupported => self.disposition.unsupported += 1,
713            CandidateDisposition::UnresolvedGeometry => self.disposition.unresolved_geometry += 1,
714        }
715    }
716
717    fn audit_rejection(
718        &mut self,
719        issue: CandidateIssue,
720        object_type: OsmObjectType,
721        all_tags: &BTreeMap<String, String>,
722        addr_tags: &BTreeMap<String, String>,
723    ) {
724        let shape = address_shape(issue, addr_tags);
725        let audit = match issue {
726            CandidateIssue::MissingHouseNumber => &mut self.validation.missing_housenumber,
727            CandidateIssue::MissingStreetOrPlace => &mut self.validation.missing_street_or_place,
728            CandidateIssue::UnsupportedRelation => &mut self.validation.unsupported_relation,
729            CandidateIssue::WayWithoutResolvedNodes | CandidateIssue::StreetUnresolvedGeometry => {
730                &mut self.validation.unresolved_geometry
731            }
732            CandidateIssue::InterpolationUnsupportedValue
733            | CandidateIssue::InterpolationUnsupportedObject
734            | CandidateIssue::InterpolationWayWithoutNodes
735            | CandidateIssue::InterpolationUnresolvedGeometry
736            | CandidateIssue::InterpolationMissingAnchors
737            | CandidateIssue::InterpolationInsufficientNumericAnchors
738            | CandidateIssue::InterpolationMissingStreetOrPlace
739            | CandidateIssue::InterpolationAnchorStreetMismatch
740            | CandidateIssue::InterpolationInvalidNumberRange
741            | CandidateIssue::InterpolationInvalidParity
742            | CandidateIssue::InterpolationNonNumericAnchor => &mut self.validation.interpolation,
743            CandidateIssue::StreetMissingName | CandidateIssue::StreetRefOnlyName => {
744                &mut self.validation.unresolved_geometry
745            }
746            CandidateIssue::PlaceMissingName | CandidateIssue::PlaceUnsupportedValue => {
747                &mut self.validation.unsupported_relation
748            }
749        };
750
751        *audit.by_shape.entry(shape.to_string()).or_default() += 1;
752        *audit
753            .by_object_type
754            .entry(object_type_name(object_type).to_string())
755            .or_default() += 1;
756
757        for key in addr_tags.keys() {
758            *audit.by_addr_key.entry(key.clone()).or_default() += 1;
759        }
760
761        let mut recorded_feature_context = false;
762        for feature_context in feature_contexts(all_tags) {
763            recorded_feature_context = true;
764            *audit
765                .by_feature_context
766                .entry(feature_context.to_string())
767                .or_default() += 1;
768        }
769
770        if !recorded_feature_context {
771            *audit
772                .by_feature_context
773                .entry("no_feature_context".to_string())
774                .or_default() += 1;
775        }
776    }
777
778    fn triage_rejection(
779        &mut self,
780        issue: CandidateIssue,
781        object_type: OsmObjectType,
782        object_id: i64,
783        all_tags: &BTreeMap<String, String>,
784        layer_hint: Option<&str>,
785        writes_rejection_record: bool,
786    ) {
787        let triage = rejection_triage(issue, all_tags);
788        let reason = issue.as_str();
789        *self
790            .triage
791            .by_bucket
792            .entry(triage.bucket.to_string())
793            .or_default() += 1;
794        *self
795            .triage
796            .by_reason_and_bucket
797            .entry(reason.to_string())
798            .or_default()
799            .entry(triage.bucket.to_string())
800            .or_default() += 1;
801
802        if matches!(
803            issue,
804            CandidateIssue::StreetMissingName | CandidateIssue::StreetRefOnlyName
805        ) {
806            record_street_name_gap_counts(
807                &mut self.triage.street_name_gaps,
808                all_tags,
809                triage.bucket,
810            );
811        }
812
813        let sample_key = format!("{reason}/{}", triage.bucket);
814        let samples = self.triage.samples.entry(sample_key).or_default();
815        if samples.len() < MAX_REJECTION_SAMPLES_PER_REASON_BUCKET {
816            samples.push(RejectionSample {
817                reason: reason.to_string(),
818                bucket: triage.bucket.to_string(),
819                note: triage.note.to_string(),
820                object_type,
821                object_id,
822                source_id: source_id(object_type, object_id),
823                layer_hint: layer_hint.map(str::to_string),
824                writes_rejection_record,
825                tags: all_tags.clone(),
826            });
827        }
828    }
829}
830
831fn source_id(object_type: OsmObjectType, object_id: i64) -> String {
832    format!("osm:{}:{object_id}", object_type_name(object_type))
833}
834
835fn count_option(value: Option<&str>, present: &mut u64, missing: &mut u64) {
836    if value.is_some_and(|value| !value.trim().is_empty()) {
837        *present += 1;
838    } else {
839        *missing += 1;
840    }
841}
842
843fn missing_admin_context_fields(address: &AddressRecord) -> Vec<String> {
844    let mut missing = Vec::new();
845    if address.address.locality.is_none() {
846        missing.push("locality".to_string());
847    }
848    if address.address.region.is_none() {
849        missing.push("region".to_string());
850    }
851    if address.address.country.is_none() {
852        missing.push("country".to_string());
853    }
854    missing
855}
856
857fn address_context_shape(address: &AddressRecord) -> String {
858    let mut parts = vec!["number"];
859    if address.address.street.is_some() {
860        parts.push("street");
861    }
862    if address.address.place.is_some() {
863        parts.push("place");
864    }
865    if address.address.unit.is_some() {
866        parts.push("unit");
867    }
868    if address.address.locality.is_some() {
869        parts.push("locality");
870    }
871    if address.address.region.is_some() {
872        parts.push("region");
873    }
874    if address.address.postcode.is_some() {
875        parts.push("postcode");
876    }
877    if address.address.country.is_some() {
878        parts.push("country");
879    }
880    parts.join("+")
881}
882
883fn location_precision_name(precision: LocationPrecision) -> &'static str {
884    match precision {
885        LocationPrecision::Point => "point",
886        LocationPrecision::Centroid => "centroid",
887    }
888}
889
890fn rejection_triage(issue: CandidateIssue, tags: &BTreeMap<String, String>) -> RejectionTriage {
891    match issue {
892        CandidateIssue::StreetMissingName => unnamed_highway_triage(tags),
893        CandidateIssue::StreetRefOnlyName => RejectionTriage {
894            bucket: "needs_model_decision",
895            note: "highway has ref=* but no name=*; it may be useful for route-number search, but is not a reliable address street name",
896        },
897        CandidateIssue::MissingHouseNumber => RejectionTriage {
898            bucket: "likely_usable_missing_source_data",
899            note: "object has street/place address context but no house number; needs better address source data to become a specific address",
900        },
901        CandidateIssue::MissingStreetOrPlace => RejectionTriage {
902            bucket: "likely_usable_missing_source_data",
903            note: "object has a house number but no street/place; could be recoverable only with street inference or better source data",
904        },
905        CandidateIssue::UnsupportedRelation | CandidateIssue::InterpolationUnsupportedObject => {
906            RejectionTriage {
907                bucket: "needs_parser_support",
908                note: "source object may be useful, but the current builder does not support this OSM object shape for the layer",
909            }
910        }
911        CandidateIssue::WayWithoutResolvedNodes
912        | CandidateIssue::InterpolationWayWithoutNodes
913        | CandidateIssue::InterpolationUnresolvedGeometry
914        | CandidateIssue::StreetUnresolvedGeometry => RejectionTriage {
915            bucket: "needs_geometry_resolution",
916            note: "source object has useful tags but geometry could not be resolved into a usable point or line",
917        },
918        CandidateIssue::InterpolationUnsupportedValue
919        | CandidateIssue::InterpolationMissingAnchors
920        | CandidateIssue::InterpolationInsufficientNumericAnchors
921        | CandidateIssue::InterpolationMissingStreetOrPlace
922        | CandidateIssue::InterpolationAnchorStreetMismatch
923        | CandidateIssue::InterpolationInvalidNumberRange
924        | CandidateIssue::InterpolationInvalidParity
925        | CandidateIssue::InterpolationNonNumericAnchor => RejectionTriage {
926            bucket: "invalid_source_data",
927            note: "interpolation tags are incomplete or internally inconsistent; boundaries cannot repair this",
928        },
929        CandidateIssue::PlaceMissingName => RejectionTriage {
930            bucket: "likely_not_useful_for_geocoding",
931            note: "place=* without a usable name cannot be returned as a named geocoding result",
932        },
933        CandidateIssue::PlaceUnsupportedValue => RejectionTriage {
934            bucket: "likely_not_useful_for_geocoding",
935            note: "place=* value is outside the current address-first place/admin layers",
936        },
937    }
938}
939
940fn unnamed_highway_triage(tags: &BTreeMap<String, String>) -> RejectionTriage {
941    if tags.contains_key("addr:housenumber") {
942        return RejectionTriage {
943            bucket: "not_a_street_gap_address_processed_separately",
944            note: "unnamed highway also carries address tags; review the address rejection or acceptance separately",
945        };
946    }
947
948    let Some(highway) = tag(tags, "highway").map(normalize_tag_value) else {
949        return RejectionTriage {
950            bucket: "likely_not_useful_for_geocoding",
951            note: "street-name rejection without a highway value is not useful as a geocoding street",
952        };
953    };
954
955    if is_lifecycle_or_non_current(tags, &highway) {
956        return RejectionTriage {
957            bucket: "likely_not_useful_for_geocoding",
958            note: "non-current or construction highway without a name is not useful for address geocoding",
959        };
960    }
961
962    if highway == "service" {
963        return RejectionTriage {
964            bucket: "likely_not_useful_for_geocoding",
965            note: "unnamed service roads, driveways, alleys, and parking aisles are usually not addressable geocoding streets",
966        };
967    }
968
969    if is_minor_path_highway(&highway) {
970        return RejectionTriage {
971            bucket: "likely_not_useful_for_geocoding",
972            note: "unnamed path-like highway is usually not useful as an address geocoding street",
973        };
974    }
975
976    if is_addressable_road_highway(&highway) {
977        return RejectionTriage {
978            bucket: "likely_usable_missing_source_data",
979            note: "unnamed addressable-looking road; likely needs an OSM fix or external road centerline source",
980        };
981    }
982
983    RejectionTriage {
984        bucket: "needs_review",
985        note: "unnamed highway type is not clearly noise or clearly addressable; inspect samples before deciding",
986    }
987}
988
989fn record_street_name_gap_counts(
990    counts: &mut StreetNameGapCounts,
991    tags: &BTreeMap<String, String>,
992    bucket: &str,
993) {
994    *counts.by_bucket.entry(bucket.to_string()).or_default() += 1;
995    let highway = tag(tags, "highway")
996        .map(normalize_tag_value)
997        .unwrap_or_else(|| "missing".to_string());
998    *counts.by_highway.entry(highway).or_default() += 1;
999    if let Some(service) = tag(tags, "service").map(normalize_tag_value) {
1000        *counts.by_service.entry(service).or_default() += 1;
1001    }
1002}
1003
1004fn tag<'a>(tags: &'a BTreeMap<String, String>, key: &str) -> Option<&'a str> {
1005    tags.get(key)
1006        .map(String::as_str)
1007        .filter(|value| !value.is_empty())
1008}
1009
1010fn normalize_tag_value(value: &str) -> String {
1011    value.trim().to_ascii_lowercase()
1012}
1013
1014fn is_lifecycle_or_non_current(tags: &BTreeMap<String, String>, highway: &str) -> bool {
1015    matches!(highway, "construction" | "proposed")
1016        || tags.contains_key("construction")
1017        || tags.contains_key("proposed")
1018        || tags.contains_key("abandoned")
1019        || tags.contains_key("disused")
1020        || tags.contains_key("razed")
1021}
1022
1023fn is_minor_path_highway(highway: &str) -> bool {
1024    matches!(
1025        highway,
1026        "bridleway"
1027            | "bus_stop"
1028            | "corridor"
1029            | "crossing"
1030            | "cycleway"
1031            | "elevator"
1032            | "escape"
1033            | "footway"
1034            | "give_way"
1035            | "path"
1036            | "platform"
1037            | "raceway"
1038            | "rest_area"
1039            | "speed_camera"
1040            | "steps"
1041            | "stop"
1042            | "street_lamp"
1043            | "track"
1044            | "trailhead"
1045            | "traffic_mirror"
1046            | "traffic_signals"
1047    )
1048}
1049
1050fn is_addressable_road_highway(highway: &str) -> bool {
1051    matches!(
1052        highway,
1053        "living_street"
1054            | "motorway"
1055            | "primary"
1056            | "residential"
1057            | "road"
1058            | "secondary"
1059            | "tertiary"
1060            | "trunk"
1061            | "unclassified"
1062    )
1063}
1064
1065fn address_shape(issue: CandidateIssue, addr_tags: &BTreeMap<String, String>) -> &'static str {
1066    match issue {
1067        CandidateIssue::MissingHouseNumber => missing_housenumber_shape(addr_tags),
1068        CandidateIssue::MissingStreetOrPlace => missing_street_or_place_shape(addr_tags),
1069        CandidateIssue::UnsupportedRelation => {
1070            if addr_tags.contains_key("addr:interpolation") {
1071                "interpolation_relation"
1072            } else {
1073                "relation_with_addr_tags"
1074            }
1075        }
1076        CandidateIssue::WayWithoutResolvedNodes => "way_without_resolved_nodes",
1077        CandidateIssue::StreetUnresolvedGeometry => "street_unresolved_geometry",
1078        CandidateIssue::StreetMissingName => "street_missing_name",
1079        CandidateIssue::StreetRefOnlyName => "street_ref_only_name",
1080        CandidateIssue::PlaceMissingName => "place_missing_name",
1081        CandidateIssue::PlaceUnsupportedValue => "place_unsupported_value",
1082        CandidateIssue::InterpolationUnsupportedValue
1083        | CandidateIssue::InterpolationUnsupportedObject
1084        | CandidateIssue::InterpolationWayWithoutNodes
1085        | CandidateIssue::InterpolationUnresolvedGeometry
1086        | CandidateIssue::InterpolationMissingAnchors
1087        | CandidateIssue::InterpolationInsufficientNumericAnchors
1088        | CandidateIssue::InterpolationMissingStreetOrPlace
1089        | CandidateIssue::InterpolationAnchorStreetMismatch
1090        | CandidateIssue::InterpolationInvalidNumberRange
1091        | CandidateIssue::InterpolationInvalidParity
1092        | CandidateIssue::InterpolationNonNumericAnchor => "interpolation",
1093    }
1094}
1095
1096fn missing_housenumber_shape(addr_tags: &BTreeMap<String, String>) -> &'static str {
1097    if addr_tags.contains_key("addr:interpolation") {
1098        return "interpolation";
1099    }
1100
1101    let has_street = addr_tags.contains_key("addr:street");
1102    let has_place = addr_tags.contains_key("addr:place");
1103    let has_city = addr_tags.contains_key("addr:city");
1104    let has_postcode = addr_tags.contains_key("addr:postcode");
1105
1106    match (has_street, has_place, has_city, has_postcode) {
1107        (true, true, _, _) => "street_and_place_without_housenumber",
1108        (true, false, _, _) => "street_without_housenumber",
1109        (false, true, _, _) => "place_without_housenumber",
1110        (false, false, true, true) => "city_postcode_only",
1111        (false, false, true, false) => "locality_only",
1112        (false, false, false, true) => "postcode_only",
1113        _ => "other_partial_address",
1114    }
1115}
1116
1117fn missing_street_or_place_shape(addr_tags: &BTreeMap<String, String>) -> &'static str {
1118    let has_house_number = addr_tags.contains_key("addr:housenumber");
1119    let has_unit = addr_tags.contains_key("addr:unit");
1120    let has_city = addr_tags.contains_key("addr:city");
1121    let has_postcode = addr_tags.contains_key("addr:postcode");
1122
1123    match (has_house_number, has_unit, has_city, has_postcode) {
1124        (true, true, _, _) => "housenumber_unit_without_street_or_place",
1125        (true, false, true, true) => "housenumber_city_postcode_without_street_or_place",
1126        (true, false, true, false) => "housenumber_locality_without_street_or_place",
1127        (true, false, false, true) => "housenumber_postcode_without_street_or_place",
1128        (true, false, false, false) => "housenumber_only",
1129        _ => "other_missing_street_or_place",
1130    }
1131}
1132
1133fn feature_contexts(tags: &BTreeMap<String, String>) -> impl Iterator<Item = &'static str> {
1134    let mut contexts = Vec::new();
1135
1136    if tags.contains_key("building") {
1137        contexts.push("building");
1138    }
1139    if tags.contains_key("name") {
1140        contexts.push("named_feature");
1141    }
1142    if tags.contains_key("highway") {
1143        contexts.push("road_or_path");
1144    }
1145    if POI_CONTEXT_KEYS.iter().any(|key| tags.contains_key(*key)) {
1146        contexts.push("poi_or_venue");
1147    }
1148
1149    contexts.into_iter()
1150}
1151
1152fn object_type_name(object_type: OsmObjectType) -> &'static str {
1153    match object_type {
1154        OsmObjectType::Node => "node",
1155        OsmObjectType::Way => "way",
1156        OsmObjectType::Relation => "relation",
1157    }
1158}
1159
1160const POI_CONTEXT_KEYS: &[&str] = &[
1161    "aeroway",
1162    "amenity",
1163    "club",
1164    "craft",
1165    "emergency",
1166    "healthcare",
1167    "historic",
1168    "leisure",
1169    "man_made",
1170    "natural",
1171    "office",
1172    "public_transport",
1173    "railway",
1174    "shop",
1175    "sport",
1176    "tourism",
1177];
1178
1179#[cfg(test)]
1180mod tests {
1181    use crate::record::{AddressComponents, SourceProvenance, point_geometry};
1182
1183    #[test]
1184    fn merging_worker_reports_matches_one_sequential_report() {
1185        let tags = BTreeMap::from([("highway".to_string(), "residential".to_string())]);
1186        let mut sequential = BuilderReport::default();
1187        let mut first = BuilderReport::default();
1188        let mut second = BuilderReport::default();
1189        for object_id in 0..8 {
1190            let worker = if object_id < 3 {
1191                &mut first
1192            } else {
1193                &mut second
1194            };
1195            for report in [&mut sequential, worker] {
1196                report.reject_with_context(
1197                    CandidateIssue::StreetMissingName,
1198                    OsmObjectType::Way,
1199                    object_id,
1200                    &tags,
1201                    None,
1202                    Some("street"),
1203                    false,
1204                );
1205                report.accept_address_with_tags(
1206                    &address_record("", object_id, Some("King Street"), None, None, None, None),
1207                    None,
1208                );
1209            }
1210        }
1211        let mut merged = BuilderReport::default();
1212        merged.merge(first);
1213        merged.merge(second);
1214        assert_eq!(merged, sequential);
1215    }
1216
1217    use super::*;
1218
1219    #[test]
1220    fn records_accepted_address_quality_and_samples_missing_context() {
1221        let address = address_record(
1222            "osm:node:10",
1223            10,
1224            Some("King Street"),
1225            None,
1226            None,
1227            None,
1228            None,
1229        );
1230        let tags = BTreeMap::from([
1231            ("addr:housenumber".to_string(), "10".to_string()),
1232            ("addr:street".to_string(), "King Street".to_string()),
1233        ]);
1234        let mut report = BuilderReport::default();
1235
1236        report.accept_address_with_tags(&address, Some(&tags));
1237
1238        assert_eq!(report.quality.addresses.total, 1);
1239        assert_eq!(report.quality.addresses.has_street, 1);
1240        assert_eq!(report.quality.addresses.missing_locality, 1);
1241        assert_eq!(report.quality.addresses.missing_region, 1);
1242        assert_eq!(report.quality.addresses.missing_postcode, 1);
1243        assert_eq!(report.quality.addresses.missing_country, 1);
1244        assert_eq!(report.quality.addresses.missing_any_admin_context, 1);
1245        assert_eq!(report.quality.addresses.enrichable_by_point, 1);
1246        assert_eq!(
1247            report
1248                .quality
1249                .addresses
1250                .by_context_shape
1251                .get("number+street"),
1252            Some(&1)
1253        );
1254
1255        let admin_samples = report
1256            .quality
1257            .samples
1258            .get("address/missing_admin_context")
1259            .expect("missing admin sample");
1260        assert_eq!(admin_samples.len(), 1);
1261        assert_eq!(admin_samples[0].source_id, "osm:node:10");
1262        assert_eq!(
1263            admin_samples[0].missing_fields,
1264            vec![
1265                "locality".to_string(),
1266                "region".to_string(),
1267                "country".to_string()
1268            ]
1269        );
1270        assert_eq!(
1271            admin_samples[0].tags.get("addr:street"),
1272            Some(&"King Street".to_string())
1273        );
1274        assert!(
1275            report
1276                .quality
1277                .samples
1278                .contains_key("address/missing_postcode")
1279        );
1280    }
1281
1282    #[test]
1283    fn records_accepted_address_quality_for_complete_source_context() {
1284        let address = address_record(
1285            "osm:node:20",
1286            20,
1287            Some("King Street"),
1288            Some("Toronto"),
1289            Some("Ontario"),
1290            Some("M5V"),
1291            Some("CA"),
1292        );
1293        let mut report = BuilderReport::default();
1294
1295        report.accept_address_with_tags(&address, None);
1296
1297        assert_eq!(report.quality.addresses.total, 1);
1298        assert_eq!(report.quality.addresses.has_locality, 1);
1299        assert_eq!(report.quality.addresses.has_region, 1);
1300        assert_eq!(report.quality.addresses.has_postcode, 1);
1301        assert_eq!(report.quality.addresses.has_country, 1);
1302        assert_eq!(report.quality.addresses.has_full_admin_context, 1);
1303        assert_eq!(report.quality.addresses.missing_any_admin_context, 0);
1304        assert_eq!(
1305            report.quality.addresses.by_location_precision.get("point"),
1306            Some(&1)
1307        );
1308        assert!(
1309            report
1310                .quality
1311                .samples
1312                .contains_key("address/complete_source_context")
1313        );
1314    }
1315
1316    #[test]
1317    fn audits_missing_housenumber_by_shape_and_feature_context() {
1318        let all_tags = BTreeMap::from([
1319            ("addr:street".to_string(), "King Street".to_string()),
1320            ("amenity".to_string(), "school".to_string()),
1321            ("building".to_string(), "yes".to_string()),
1322            ("name".to_string(), "Example School".to_string()),
1323        ]);
1324        let addr_tags = BTreeMap::from([("addr:street".to_string(), "King Street".to_string())]);
1325        let mut report = BuilderReport::default();
1326
1327        report.reject_with_context(
1328            CandidateIssue::MissingHouseNumber,
1329            OsmObjectType::Way,
1330            77,
1331            &all_tags,
1332            Some(&addr_tags),
1333            Some("address"),
1334            true,
1335        );
1336
1337        assert_eq!(
1338            report
1339                .validation
1340                .missing_housenumber
1341                .by_shape
1342                .get("street_without_housenumber"),
1343            Some(&1)
1344        );
1345        assert_eq!(
1346            report
1347                .validation
1348                .missing_housenumber
1349                .by_feature_context
1350                .get("building"),
1351            Some(&1)
1352        );
1353        assert_eq!(
1354            report
1355                .validation
1356                .missing_housenumber
1357                .by_feature_context
1358                .get("poi_or_venue"),
1359            Some(&1)
1360        );
1361        assert_eq!(
1362            report
1363                .validation
1364                .missing_housenumber
1365                .by_feature_context
1366                .get("named_feature"),
1367            Some(&1)
1368        );
1369    }
1370
1371    #[test]
1372    fn triages_unnamed_service_highway_as_not_useful_with_sample() {
1373        let tags = BTreeMap::from([
1374            ("highway".to_string(), "service".to_string()),
1375            ("service".to_string(), "parking_aisle".to_string()),
1376        ]);
1377        let mut report = BuilderReport::default();
1378
1379        report.reject_with_context(
1380            CandidateIssue::StreetMissingName,
1381            OsmObjectType::Way,
1382            42,
1383            &tags,
1384            None,
1385            Some("street"),
1386            false,
1387        );
1388
1389        assert_eq!(
1390            report
1391                .triage
1392                .by_reason_and_bucket
1393                .get("street_missing_name")
1394                .and_then(|counts| counts.get("likely_not_useful_for_geocoding")),
1395            Some(&1)
1396        );
1397        assert_eq!(
1398            report.triage.street_name_gaps.by_highway.get("service"),
1399            Some(&1)
1400        );
1401        assert_eq!(
1402            report
1403                .triage
1404                .street_name_gaps
1405                .by_service
1406                .get("parking_aisle"),
1407            Some(&1)
1408        );
1409
1410        let samples = report
1411            .triage
1412            .samples
1413            .get("street_missing_name/likely_not_useful_for_geocoding")
1414            .expect("sample bucket");
1415        assert_eq!(samples.len(), 1);
1416        assert_eq!(samples[0].source_id, "osm:way:42");
1417        assert_eq!(samples[0].layer_hint.as_deref(), Some("street"));
1418        assert!(!samples[0].writes_rejection_record);
1419    }
1420
1421    #[test]
1422    fn triages_unnamed_residential_highway_as_recoverable_gap() {
1423        let tags = BTreeMap::from([("highway".to_string(), "residential".to_string())]);
1424        let mut report = BuilderReport::default();
1425
1426        report.reject_with_context(
1427            CandidateIssue::StreetMissingName,
1428            OsmObjectType::Way,
1429            99,
1430            &tags,
1431            None,
1432            Some("street"),
1433            false,
1434        );
1435
1436        assert_eq!(
1437            report
1438                .triage
1439                .by_bucket
1440                .get("likely_usable_missing_source_data"),
1441            Some(&1)
1442        );
1443        assert_eq!(
1444            report
1445                .triage
1446                .street_name_gaps
1447                .by_bucket
1448                .get("likely_usable_missing_source_data"),
1449            Some(&1)
1450        );
1451        assert_eq!(
1452            report
1453                .triage
1454                .samples
1455                .get("street_missing_name/likely_usable_missing_source_data")
1456                .and_then(|samples| samples.first())
1457                .map(|sample| sample.tags.get("highway")),
1458            Some(Some(&"residential".to_string()))
1459        );
1460    }
1461
1462    #[test]
1463    fn caps_rejection_samples_per_reason_bucket() {
1464        let tags = BTreeMap::from([("highway".to_string(), "residential".to_string())]);
1465        let mut report = BuilderReport::default();
1466
1467        for object_id in 0..10 {
1468            report.reject_with_context(
1469                CandidateIssue::StreetMissingName,
1470                OsmObjectType::Way,
1471                object_id,
1472                &tags,
1473                None,
1474                Some("street"),
1475                false,
1476            );
1477        }
1478
1479        assert_eq!(
1480            report
1481                .triage
1482                .samples
1483                .get("street_missing_name/likely_usable_missing_source_data")
1484                .map(Vec::len),
1485            Some(MAX_REJECTION_SAMPLES_PER_REASON_BUCKET)
1486        );
1487        assert_eq!(
1488            report
1489                .triage
1490                .by_bucket
1491                .get("likely_usable_missing_source_data"),
1492            Some(&10)
1493        );
1494    }
1495
1496    fn address_record(
1497        _id: &str,
1498        object_id: i64,
1499        street: Option<&str>,
1500        locality: Option<&str>,
1501        region: Option<&str>,
1502        postcode: Option<&str>,
1503        country: Option<&str>,
1504    ) -> AddressRecord {
1505        let street = street.map(str::to_string);
1506        AddressRecord {
1507            address: AddressComponents {
1508                number: "10".to_string(),
1509                street,
1510                place: None,
1511                unit: None,
1512                locality: locality.map(str::to_string),
1513                region: region.map(str::to_string),
1514                postcode: postcode.map(str::to_string),
1515                country: country.map(str::to_string),
1516            },
1517            geometry: point_geometry(-79.3832, 43.6532),
1518            location_precision: LocationPrecision::Point,
1519            source: SourceProvenance::osm(OsmObjectType::Node, object_id),
1520        }
1521    }
1522}