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 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 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#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
166pub struct ScratchReport {
167 pub memory_budget_bytes: u64,
168 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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
373pub struct PhaseTimings {
374 pub scan_ms: u128,
376 pub boundary_member_scan_ms: u128,
378 pub node_join_ms: u128,
380 pub feature_emission_ms: u128,
382 pub boundary_build_ms: u128,
384 pub context_record_write_ms: u128,
386 pub record_write_ms: u128,
388 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 #[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}