Skip to main content

aerocontext_core/
compare.rs

1//! Cross-source reconciliation of briefings with per-source attribution.
2//!
3//! [`compare`] groups the products of several [`Briefing`]s by
4//! (kind, location) and reports, for each group, what every source said
5//! and whether the sources agree. Every value keeps its source name, so a
6//! reader can always tell which source produced which text — including
7//! when two sources disagree.
8
9use std::collections::{BTreeSet, HashMap};
10use std::fmt;
11
12use crate::model::{Briefing, ProductKind};
13
14/// Identifies products that should be compared against each other:
15/// same product kind, same location.
16#[derive(Debug, Clone, PartialEq, Eq, Hash)]
17pub struct ProductKey {
18    /// Product kind, e.g. METAR.
19    pub kind: ProductKind,
20    /// Station/location identifier, when the product is tied to one.
21    pub location: Option<String>,
22}
23
24/// One source's text for a product.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct SourcedValue {
27    /// [`crate::ContextProvider::name`] of the source that reported this.
28    pub source: String,
29    /// Verbatim text exactly as that source published it.
30    pub raw_text: String,
31}
32
33/// Whether the sources agree on a product.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum Agreement {
36    /// Only one source reported this product.
37    Single,
38    /// Two or more sources reported it and, after normalization, their
39    /// text matches.
40    Agree,
41    /// Two or more sources reported it and their text differs.
42    Disagree,
43}
44
45/// A per-product comparison across all sources.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct ProductComparison {
48    /// What product this compares.
49    pub key: ProductKey,
50    /// Agreement across the reporting sources.
51    pub agreement: Agreement,
52    /// Each source's value, sorted by source name then text.
53    pub values: Vec<SourcedValue>,
54}
55
56/// The full cross-source comparison, ordered deterministically by
57/// location then kind.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct ComparisonReport {
60    /// One entry per distinct (kind, location).
61    pub comparisons: Vec<ProductComparison>,
62}
63
64impl ComparisonReport {
65    /// Comparisons where the reporting sources disagreed.
66    pub fn disagreements(&self) -> impl Iterator<Item = &ProductComparison> {
67        self.comparisons
68            .iter()
69            .filter(|c| c.agreement == Agreement::Disagree)
70    }
71}
72
73/// Reconcile several briefings into one attributed comparison.
74///
75/// Each [`Briefing`] contributes its [`crate::Product`]s under its own
76/// `source` name; a document-only source contributes nothing unless its
77/// adapter also fills `products`.
78pub fn compare(briefings: &[Briefing]) -> ComparisonReport {
79    let mut groups: HashMap<ProductKey, Vec<SourcedValue>> = HashMap::new();
80    for briefing in briefings {
81        for product in &briefing.products {
82            let key = ProductKey {
83                kind: product.kind.clone(),
84                location: product.location.clone(),
85            };
86            groups.entry(key).or_default().push(SourcedValue {
87                source: briefing.source.clone(),
88                raw_text: product.raw_text.clone(),
89            });
90        }
91    }
92    let mut comparisons: Vec<ProductComparison> = groups
93        .into_iter()
94        .map(|(key, mut values)| {
95            values.sort_by(|a, b| {
96                a.source
97                    .cmp(&b.source)
98                    .then_with(|| a.raw_text.cmp(&b.raw_text))
99            });
100            let agreement = classify(&values);
101            ProductComparison {
102                key,
103                agreement,
104                values,
105            }
106        })
107        .collect();
108    comparisons.sort_by_key(|c| sort_key(&c.key));
109    ComparisonReport { comparisons }
110}
111
112fn classify(values: &[SourcedValue]) -> Agreement {
113    let sources: BTreeSet<&str> = values.iter().map(|v| v.source.as_str()).collect();
114    if sources.len() < 2 {
115        return Agreement::Single;
116    }
117    let normalized: BTreeSet<String> = values.iter().map(|v| normalize(&v.raw_text)).collect();
118    if normalized.len() == 1 {
119        Agreement::Agree
120    } else {
121        Agreement::Disagree
122    }
123}
124
125/// Canonical form for agreement testing only — the displayed value stays
126/// verbatim. Strips a leading product keyword (one source labels the
127/// report, another does not), a trailing `=` separator, and collapses
128/// runs of whitespace.
129fn normalize(raw: &str) -> String {
130    let body = raw.trim().trim_end_matches('=').trim();
131    let mut tokens = body.split_whitespace().peekable();
132    if tokens
133        .peek()
134        .is_some_and(|t| matches!(*t, "METAR" | "SPECI" | "TAF"))
135    {
136        tokens.next();
137    }
138    tokens.collect::<Vec<_>>().join(" ")
139}
140
141fn sort_key(key: &ProductKey) -> (String, String) {
142    (
143        key.location.clone().unwrap_or_default(),
144        format!("{:?}", key.kind),
145    )
146}
147
148impl fmt::Display for ComparisonReport {
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        for comparison in &self.comparisons {
151            let location = comparison.key.location.as_deref().unwrap_or("(area)");
152            let tag = match comparison.agreement {
153                Agreement::Agree => "AGREE".to_owned(),
154                Agreement::Disagree => "DISAGREE".to_owned(),
155                Agreement::Single => format!(
156                    "only {}",
157                    comparison.values.first().map_or("?", |v| v.source.as_str())
158                ),
159            };
160            writeln!(f, "{location} {:?} [{tag}]", comparison.key.kind)?;
161            for value in &comparison.values {
162                writeln!(f, "  {}: {}", value.source, value.raw_text)?;
163            }
164        }
165        Ok(())
166    }
167}
168
169#[cfg(test)]
170mod tests;