Skip to main content

soothfast_spec/probe/
baseline.rs

1//! The committed population baseline: `probes.lock`.
2//!
3//! Live market data makes some fields legitimately intermittent
4//! (pre-market prices outside pre-market hours, dividend fields on
5//! non-payers), so the lock records a *class* per field, not a snapshot:
6//!
7//! - `always`: populated in every accepted run; going null fails the gate
8//! - `sometimes`: observed both ways across accepted runs; never gated
9//!
10//! Accepting demotes a flapping `always` field to `sometimes` and keeps it
11//! there: repeated accepts converge on the stable contract instead of
12//! ratcheting on one lucky run. A manifest can pin a field's class when
13//! the operator knows better than the observations.
14//!
15//! Fields the spec declares but no accepted run has ever populated go in
16//! the probe's `uncovered` list: committed debt, visible in review, and
17//! promoted to a real class automatically once data appears. A declared
18//! field in neither place fails the gate: adding a schema field without
19//! wiring data to it is a finding, not a silence.
20
21use std::collections::{BTreeMap, BTreeSet};
22
23use serde_json::{Value, json};
24
25/// A field's population class across accepted runs.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Class {
28    Always,
29    Sometimes,
30}
31
32impl Class {
33    fn as_str(self) -> &'static str {
34        match self {
35            Class::Always => "always",
36            Class::Sometimes => "sometimes",
37        }
38    }
39}
40
41/// One probe's locked state.
42#[derive(Debug, Clone, Default, PartialEq)]
43pub struct ProbeLock {
44    pub fields: BTreeMap<String, Class>,
45    /// Declared by the spec, never yet populated in an accepted run.
46    pub uncovered: BTreeSet<String>,
47}
48
49/// The lock file: probe name → locked state.
50#[derive(Debug, Clone, Default, PartialEq)]
51pub struct Baseline {
52    pub probes: BTreeMap<String, ProbeLock>,
53}
54
55/// One probe's gate findings against the lock.
56#[derive(Debug, Default)]
57pub struct Findings {
58    /// Locked `always`, now null or absent.
59    pub regressed: Vec<String>,
60    /// Populated now, unknown to the lock: the lock is stale.
61    pub new_fields: Vec<String>,
62    /// Declared by the spec, not populated, and not in the accepted
63    /// `uncovered` list: a schema field nobody wired to data.
64    pub uncovered: Vec<String>,
65}
66
67impl Findings {
68    pub fn is_clean(&self) -> bool {
69        self.regressed.is_empty() && self.new_fields.is_empty() && self.uncovered.is_empty()
70    }
71}
72
73impl Baseline {
74    pub fn parse(text: &str) -> Result<Baseline, String> {
75        let doc: Value =
76            serde_json::from_str(text).map_err(|e| format!("probes.lock is not JSON: {e}"))?;
77        match doc.get("version").and_then(Value::as_u64) {
78            Some(1) => {}
79            other => return Err(format!("probes.lock version {other:?}, expected 1")),
80        }
81        let mut probes = BTreeMap::new();
82        let Some(entries) = doc.get("probes").and_then(Value::as_object) else {
83            return Err("probes.lock has no `probes` table".into());
84        };
85        for (name, entry) in entries {
86            let Some(fields) = entry.get("fields").and_then(Value::as_object) else {
87                return Err(format!("probe `{name}` has no `fields` table"));
88            };
89            let mut lock = ProbeLock::default();
90            for (path, class) in fields {
91                let class = match class.as_str() {
92                    Some("always") => Class::Always,
93                    Some("sometimes") => Class::Sometimes,
94                    other => {
95                        return Err(format!(
96                            "probe `{name}` field `{path}`: bad class {other:?}"
97                        ));
98                    }
99                };
100                lock.fields.insert(path.clone(), class);
101            }
102            if let Some(uncovered) = entry.get("uncovered").and_then(Value::as_array) {
103                lock.uncovered = uncovered
104                    .iter()
105                    .filter_map(Value::as_str)
106                    .map(String::from)
107                    .collect();
108            }
109            probes.insert(name.clone(), lock);
110        }
111        Ok(Baseline { probes })
112    }
113
114    pub fn render(&self) -> String {
115        let probes: BTreeMap<&str, Value> = self
116            .probes
117            .iter()
118            .map(|(name, lock)| {
119                let fields: BTreeMap<&str, &str> = lock
120                    .fields
121                    .iter()
122                    .map(|(path, class)| (path.as_str(), class.as_str()))
123                    .collect();
124                let entry = if lock.uncovered.is_empty() {
125                    json!({ "fields": fields })
126                } else {
127                    json!({ "fields": fields, "uncovered": lock.uncovered })
128                };
129                (name.as_str(), entry)
130            })
131            .collect();
132        let mut out = serde_json::to_string_pretty(&json!({
133            "version": 1,
134            "probes": probes,
135        }))
136        .expect("baseline is plain JSON");
137        out.push('\n');
138        out
139    }
140
141    /// Gate one probe's observed population against its locked classes.
142    /// `pinned_sometimes` are manifest-declared intermittent fields; they
143    /// gate like `sometimes` regardless of the locked class. `declared`
144    /// is the spec's field enumeration when coverage applies.
145    pub fn gate(
146        &self,
147        probe: &str,
148        observed: &BTreeMap<String, bool>,
149        pinned_sometimes: &[String],
150        declared: Option<&BTreeSet<String>>,
151    ) -> Findings {
152        let mut findings = Findings::default();
153        let locked = self.probes.get(probe).cloned().unwrap_or_default();
154        for (path, class) in &locked.fields {
155            if *class == Class::Always
156                && !pinned(pinned_sometimes, path)
157                && observed.get(path) != Some(&true)
158            {
159                findings.regressed.push(path.clone());
160            }
161        }
162        for (path, populated) in observed {
163            if *populated && !locked.fields.contains_key(path) {
164                findings.new_fields.push(path.clone());
165            }
166        }
167        if let Some(declared) = declared {
168            for path in declared {
169                if observed.get(path) != Some(&true)
170                    && !locked.fields.contains_key(path)
171                    && !locked.uncovered.contains(path)
172                    && !pinned(pinned_sometimes, path)
173                {
174                    findings.uncovered.push(path.clone());
175                }
176            }
177        }
178        findings
179    }
180
181    /// Fold one probe's observed population into the lock. First sight of
182    /// a populated field locks it `always`; any accepted run that sees a
183    /// locked field empty demotes it to `sometimes`, permanently. The
184    /// `uncovered` list is recomputed from `declared`: whatever still has
185    /// no class after this run is the probe's accepted coverage debt.
186    pub fn accept(
187        &mut self,
188        probe: &str,
189        observed: &BTreeMap<String, bool>,
190        declared: Option<&BTreeSet<String>>,
191    ) {
192        let lock = self.probes.entry(probe.to_string()).or_default();
193        for (path, populated) in observed {
194            match (lock.fields.get(path), populated) {
195                (None, true) => {
196                    lock.fields.insert(path.clone(), Class::Always);
197                }
198                (None, false) => {}
199                (Some(Class::Always), false) => {
200                    lock.fields.insert(path.clone(), Class::Sometimes);
201                }
202                _ => {}
203            }
204        }
205        // A locked field the response no longer mentions at all is the
206        // same evidence as seeing it empty.
207        let absent: Vec<String> = lock
208            .fields
209            .iter()
210            .filter(|(path, class)| **class == Class::Always && !observed.contains_key(*path))
211            .map(|(path, _)| path.clone())
212            .collect();
213        for path in absent {
214            lock.fields.insert(path, Class::Sometimes);
215        }
216        lock.uncovered = declared
217            .map(|declared| {
218                declared
219                    .iter()
220                    .filter(|path| !lock.fields.contains_key(*path))
221                    .cloned()
222                    .collect()
223            })
224            .unwrap_or_default();
225    }
226
227    /// Drop locked probes the manifest no longer declares. Returns the
228    /// dropped names so the gate can refuse them without `--allow-gone`.
229    pub fn retain_probes(&mut self, declared: &[String]) -> Vec<String> {
230        let gone: Vec<String> = self
231            .probes
232            .keys()
233            .filter(|name| !declared.contains(name))
234            .cloned()
235            .collect();
236        for name in &gone {
237            self.probes.remove(name);
238        }
239        gone
240    }
241}
242
243fn pinned(pins: &[String], path: &str) -> bool {
244    pins.iter().any(|pin| {
245        pin == path
246            || pin
247                .strip_suffix('*')
248                .is_some_and(|prefix| path.starts_with(prefix))
249    })
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    fn observed(pairs: &[(&str, bool)]) -> BTreeMap<String, bool> {
257        pairs
258            .iter()
259            .map(|(path, populated)| (path.to_string(), *populated))
260            .collect()
261    }
262
263    fn declared(paths: &[&str]) -> BTreeSet<String> {
264        paths.iter().map(|p| p.to_string()).collect()
265    }
266
267    #[test]
268    fn lock_round_trips_through_render_and_parse() {
269        let mut baseline = Baseline::default();
270        baseline.accept(
271            "quote",
272            &observed(&[("price", true), ("pe", true)]),
273            Some(&declared(&["price", "pe", "spark"])),
274        );
275        let reparsed = Baseline::parse(&baseline.render()).unwrap();
276        assert_eq!(reparsed, baseline);
277        assert!(baseline.probes["quote"].uncovered.contains("spark"));
278    }
279
280    #[test]
281    fn an_always_field_going_null_regresses() {
282        let mut baseline = Baseline::default();
283        baseline.accept("quote", &observed(&[("pe", true)]), None);
284        let findings = baseline.gate("quote", &observed(&[("pe", false)]), &[], None);
285        assert_eq!(findings.regressed, ["pe"]);
286    }
287
288    #[test]
289    fn accept_demotes_a_flapping_field_for_good() {
290        let mut baseline = Baseline::default();
291        baseline.accept("quote", &observed(&[("preMarket", true)]), None);
292        baseline.accept("quote", &observed(&[("preMarket", false)]), None);
293        baseline.accept("quote", &observed(&[("preMarket", true)]), None);
294        assert_eq!(
295            baseline.probes["quote"].fields["preMarket"],
296            Class::Sometimes
297        );
298        let findings = baseline.gate("quote", &observed(&[("preMarket", false)]), &[], None);
299        assert!(findings.is_clean());
300    }
301
302    #[test]
303    fn a_field_vanishing_entirely_demotes_on_accept_and_regresses_on_gate() {
304        let mut baseline = Baseline::default();
305        baseline.accept("quote", &observed(&[("pe", true)]), None);
306        let findings = baseline.gate("quote", &observed(&[]), &[], None);
307        assert_eq!(findings.regressed, ["pe"]);
308        baseline.accept("quote", &observed(&[]), None);
309        assert_eq!(baseline.probes["quote"].fields["pe"], Class::Sometimes);
310    }
311
312    #[test]
313    fn new_populated_fields_mark_the_lock_stale() {
314        let baseline = Baseline::default();
315        let findings = baseline.gate("quote", &observed(&[("spark", true)]), &[], None);
316        assert_eq!(findings.new_fields, ["spark"]);
317    }
318
319    #[test]
320    fn manifest_pins_silence_the_gate_with_and_without_wildcard() {
321        let mut baseline = Baseline::default();
322        baseline.accept(
323            "quote",
324            &observed(&[("preMarketPrice", true), ("preMarketTime", true)]),
325            None,
326        );
327        let now = observed(&[("preMarketPrice", false), ("preMarketTime", false)]);
328        let findings = baseline.gate("quote", &now, &["preMarket*".to_string()], None);
329        assert!(findings.is_clean());
330        let findings = baseline.gate("quote", &now, &["preMarketPrice".to_string()], None);
331        assert_eq!(findings.regressed, ["preMarketTime"]);
332    }
333
334    #[test]
335    fn gone_probes_are_reported_and_dropped() {
336        let mut baseline = Baseline::default();
337        baseline.accept("quote", &observed(&[("pe", true)]), None);
338        baseline.accept("chart", &observed(&[("meta", true)]), None);
339        let gone = baseline.retain_probes(&["quote".to_string()]);
340        assert_eq!(gone, ["chart"]);
341        assert!(!baseline.probes.contains_key("chart"));
342    }
343
344    #[test]
345    fn a_declared_field_nobody_populates_fails_until_accepted() {
346        let mut baseline = Baseline::default();
347        let spec_fields = declared(&["price", "cvar95"]);
348        let now = observed(&[("price", true)]);
349        baseline.accept("risk", &now, Some(&spec_fields));
350        let findings = baseline.gate("risk", &now, &[], Some(&spec_fields));
351        assert!(findings.is_clean(), "accepted uncovered debt passes");
352
353        let grown = declared(&["price", "cvar95", "omega"]);
354        let findings = baseline.gate("risk", &now, &[], Some(&grown));
355        assert_eq!(findings.uncovered, ["omega"]);
356    }
357
358    #[test]
359    fn covered_fields_drop_off_the_uncovered_list_on_accept() {
360        let mut baseline = Baseline::default();
361        let spec_fields = declared(&["price", "cvar95"]);
362        baseline.accept("risk", &observed(&[("price", true)]), Some(&spec_fields));
363        assert!(baseline.probes["risk"].uncovered.contains("cvar95"));
364        baseline.accept(
365            "risk",
366            &observed(&[("price", true), ("cvar95", true)]),
367            Some(&spec_fields),
368        );
369        assert!(baseline.probes["risk"].uncovered.is_empty());
370        assert_eq!(baseline.probes["risk"].fields["cvar95"], Class::Always);
371    }
372
373    #[test]
374    fn pinned_sometimes_fields_are_not_coverage_findings() {
375        let baseline = Baseline::default();
376        let spec_fields = declared(&["preMarketPrice"]);
377        let findings = baseline.gate(
378            "quote",
379            &observed(&[]),
380            &["preMarket*".to_string()],
381            Some(&spec_fields),
382        );
383        assert!(findings.is_clean());
384    }
385}