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)]
35#[non_exhaustive]
36pub enum Agreement {
37    /// Only one source reported this product.
38    Single,
39    /// Two or more sources reported it and, after normalization, their
40    /// text matches.
41    Agree,
42    /// Two or more sources reported it and their text differs.
43    Disagree,
44}
45
46/// A per-product comparison across all sources.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct ProductComparison {
49    /// What product this compares.
50    pub key: ProductKey,
51    /// Agreement across the reporting sources.
52    pub agreement: Agreement,
53    /// Each source's value, sorted by source name then text.
54    pub values: Vec<SourcedValue>,
55}
56
57/// The full cross-source comparison, ordered deterministically by
58/// location then kind.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct ComparisonReport {
61    /// One entry per distinct (kind, location).
62    pub comparisons: Vec<ProductComparison>,
63}
64
65impl ComparisonReport {
66    /// Comparisons where the reporting sources disagreed.
67    pub fn disagreements(&self) -> impl Iterator<Item = &ProductComparison> {
68        self.comparisons
69            .iter()
70            .filter(|c| c.agreement == Agreement::Disagree)
71    }
72}
73
74/// Reconcile several briefings into one attributed comparison.
75///
76/// Each [`Briefing`] contributes its [`crate::Product`]s under its own
77/// `source` name; a document-only source contributes nothing unless its
78/// adapter also fills `products`.
79pub fn compare(briefings: &[Briefing]) -> ComparisonReport {
80    let mut groups: HashMap<ProductKey, Vec<SourcedValue>> = HashMap::new();
81    for briefing in briefings {
82        for product in &briefing.products {
83            let key = ProductKey {
84                kind: product.kind.clone(),
85                location: product.location.clone(),
86            };
87            groups.entry(key).or_default().push(SourcedValue {
88                source: briefing.source.clone(),
89                raw_text: product.raw_text.clone(),
90            });
91        }
92    }
93    let mut comparisons: Vec<ProductComparison> = groups
94        .into_iter()
95        .map(|(key, mut values)| {
96            values.sort_by(|a, b| {
97                a.source
98                    .cmp(&b.source)
99                    .then_with(|| a.raw_text.cmp(&b.raw_text))
100            });
101            let agreement = classify(&values);
102            ProductComparison {
103                key,
104                agreement,
105                values,
106            }
107        })
108        .collect();
109    comparisons.sort_by_key(|c| sort_key(&c.key));
110    ComparisonReport { comparisons }
111}
112
113fn classify(values: &[SourcedValue]) -> Agreement {
114    let sources: BTreeSet<&str> = values.iter().map(|v| v.source.as_str()).collect();
115    if sources.len() < 2 {
116        return Agreement::Single;
117    }
118    let normalized: BTreeSet<String> = values.iter().map(|v| normalize(&v.raw_text)).collect();
119    if normalized.len() == 1 {
120        Agreement::Agree
121    } else {
122        Agreement::Disagree
123    }
124}
125
126/// Canonical form for agreement testing only — the displayed value stays
127/// verbatim. Strips a leading product keyword (one source labels the
128/// report, another does not), a trailing `=` separator, and collapses
129/// runs of whitespace.
130fn normalize(raw: &str) -> String {
131    let body = raw.trim().trim_end_matches('=').trim();
132    let mut tokens = body.split_whitespace().peekable();
133    if tokens
134        .peek()
135        .is_some_and(|t| matches!(*t, "METAR" | "SPECI" | "TAF"))
136    {
137        tokens.next();
138    }
139    tokens.collect::<Vec<_>>().join(" ")
140}
141
142fn sort_key(key: &ProductKey) -> (String, String) {
143    (
144        key.location.clone().unwrap_or_default(),
145        format!("{:?}", key.kind),
146    )
147}
148
149impl fmt::Display for ComparisonReport {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        for comparison in &self.comparisons {
152            let location = comparison.key.location.as_deref().unwrap_or("(area)");
153            let tag = match comparison.agreement {
154                Agreement::Agree => "AGREE".to_owned(),
155                Agreement::Disagree => "DISAGREE".to_owned(),
156                Agreement::Single => format!(
157                    "only {}",
158                    comparison.values.first().map_or("?", |v| v.source.as_str())
159                ),
160            };
161            writeln!(f, "{location} {:?} [{tag}]", comparison.key.kind)?;
162            for value in &comparison.values {
163                writeln!(f, "  {}: {}", value.source, value.raw_text)?;
164            }
165        }
166        Ok(())
167    }
168}
169
170#[cfg(test)]
171mod tests;