Skip to main content

px_native/events/
calibrate.rs

1//! Field-grammar calibration: compare a captured `[{t,d},…]` batch
2//! against what [`super::default_batch`] would synthesise, and emit a
3//! diff (missing tags, extra tags, per-tag field overlap).
4//!
5//! This is the consumer of ground-truth JSON produced by
6//! `px-camoufox::capture_sensor` (ADR-0024 v1.8.0 P2). Use it to drive
7//! P3 — closing the gap between the synthetic batch and what the
8//! runtime actually emits.
9
10use std::collections::{BTreeMap, BTreeSet};
11
12use serde::{Deserialize, Serialize};
13
14use crate::events::model::SensorEvent;
15
16#[derive(Debug, Clone, Serialize, Deserialize, Default)]
17pub struct CalibrationReport {
18    /// Tags the runtime emitted but `default_batch` does not.
19    pub missing_tags: Vec<String>,
20    /// Tags `default_batch` emits but the runtime did not in this capture.
21    pub extra_tags: Vec<String>,
22    /// Per-tag breakdown: { tag → { observed_keys, synthesised_keys, missing_keys } }.
23    pub per_tag: BTreeMap<String, TagDiff>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, Default)]
27pub struct TagDiff {
28    pub observed_keys: Vec<String>,
29    pub synthesised_keys: Vec<String>,
30    pub missing_keys: Vec<String>,
31}
32
33pub fn calibrate(observed: &[SensorEvent], synthesised: &[SensorEvent]) -> CalibrationReport {
34    let observed_by_tag = group_by_tag(observed);
35    let synth_by_tag = group_by_tag(synthesised);
36
37    let observed_tags: BTreeSet<&String> = observed_by_tag.keys().collect();
38    let synth_tags: BTreeSet<&String> = synth_by_tag.keys().collect();
39
40    let mut report = CalibrationReport::default();
41    for t in observed_tags.difference(&synth_tags) {
42        report.missing_tags.push((*t).clone());
43    }
44    for t in synth_tags.difference(&observed_tags) {
45        report.extra_tags.push((*t).clone());
46    }
47    for tag in observed_tags.intersection(&synth_tags) {
48        let obs_keys = union_keys(observed_by_tag.get(*tag).unwrap_or(&Vec::new()));
49        let syn_keys = union_keys(synth_by_tag.get(*tag).unwrap_or(&Vec::new()));
50        let mut missing: Vec<String> = obs_keys.difference(&syn_keys).cloned().collect();
51        missing.sort();
52        report.per_tag.insert(
53            (*tag).clone(),
54            TagDiff {
55                observed_keys: sorted(obs_keys),
56                synthesised_keys: sorted(syn_keys),
57                missing_keys: missing,
58            },
59        );
60    }
61    report
62}
63
64fn group_by_tag(events: &[SensorEvent]) -> BTreeMap<String, Vec<&SensorEvent>> {
65    let mut by_tag: BTreeMap<String, Vec<&SensorEvent>> = BTreeMap::new();
66    for ev in events {
67        by_tag.entry(ev.t.clone()).or_default().push(ev);
68    }
69    by_tag
70}
71
72fn union_keys(events: &[&SensorEvent]) -> BTreeSet<String> {
73    let mut keys: BTreeSet<String> = BTreeSet::new();
74    for ev in events {
75        for k in ev.d.keys() {
76            keys.insert(k.clone());
77        }
78    }
79    keys
80}
81
82fn sorted(s: BTreeSet<String>) -> Vec<String> {
83    s.into_iter().collect()
84}
85
86#[cfg(test)]
87#[allow(clippy::expect_used)]
88mod tests {
89    use super::*;
90    use crate::events::SensorEvent;
91
92    #[test]
93    fn empty_captures_yield_empty_report() {
94        let r = calibrate(&[], &[]);
95        assert!(r.missing_tags.is_empty());
96        assert!(r.extra_tags.is_empty());
97    }
98
99    #[test]
100    fn reports_missing_and_extra_tags() {
101        let obs = vec![SensorEvent::new("PXobs").with("k1", "v")];
102        let syn = vec![SensorEvent::new("PXsyn").with("k2", "v")];
103        let r = calibrate(&obs, &syn);
104        assert_eq!(r.missing_tags, vec!["PXobs"]);
105        assert_eq!(r.extra_tags, vec!["PXsyn"]);
106    }
107
108    #[test]
109    fn flags_missing_keys_within_shared_tag() {
110        let obs = vec![
111            SensorEvent::new("PX561")
112                .with("AzNweUZUfEs=", 1u64)
113                .with("EwNgCVZlZDw=", "ua")
114                .with("MISSING_KEY", "x"),
115        ];
116        let syn = vec![
117            SensorEvent::new("PX561")
118                .with("AzNweUZUfEs=", 1u64)
119                .with("EwNgCVZlZDw=", "ua"),
120        ];
121        let r = calibrate(&obs, &syn);
122        let diff = r.per_tag.get("PX561").expect("PX561 entry");
123        assert_eq!(diff.missing_keys, vec!["MISSING_KEY"]);
124    }
125}