Skip to main content

corescout_mirror/
schema.rs

1//! The schema of a reflection: what was observed, how, and how well.
2//!
3//! # Why observation metadata is part of the representation
4//!
5//! A number in the mirror is not self-describing. `47.0` in a temperature cell
6//! could be a fresh reading, a value from 400 ms ago, or a cell that has never
7//! been filled because this machine has no such sensor and never will. Those
8//! are different facts and a consumer that cannot tell them apart will learn
9//! something false.
10//!
11//! So the mirror carries, alongside the numbers:
12//!
13//! - **why** a cell is empty, if it is ([`Availability`]),
14//! - **what it cost** to fill the ones that are ([`SensorReport`]),
15//! - **how far** observing perturbed the thing observed ([`Perturbation`]),
16//! - **how wrong** the value may be even when everything worked
17//!   ([`Uncertainty`]).
18//!
19//! # Absence is a measurement
20//!
21//! The rule this module exists to enforce: **never fabricate a value**. A
22//! machine with no RAPL support does not report zero watts, and a mirror
23//! running unprivileged does not report zero energy. Both report
24//! [`Availability::PermissionDenied`] or [`Availability::Unsupported`], and a
25//! learner can then treat "I cannot see this" as the information it is, rather
26//! than modelling a constant zero as a physical fact.
27
28use serde::{Deserialize, Serialize};
29
30/// Why a cell of the state matrix holds no value.
31///
32/// Stored in a parallel matrix to the numbers themselves, so every cell has
33/// both a value and a reason. `Observed` cells carry a real number; every other
34/// code means the value is `NaN` and says why.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37#[repr(u8)]
38pub enum Availability {
39    /// A real observation from this pass.
40    Observed = 0,
41    /// No sensor claims this cell. The usual case for the large empty regions
42    /// of a sparse matrix: a cache has no temperature, a thermal zone has no
43    /// instruction count.
44    NotApplicable = 1,
45    /// A sensor claims it and did not produce a value this pass, for a reason
46    /// it could not determine.
47    Unknown = 2,
48    /// This machine cannot produce the value at all: the hardware lacks the
49    /// counter, the kernel lacks the driver, the interface does not exist.
50    Unsupported = 3,
51    /// The interface exists and did not answer: a file vanished under a
52    /// hotplug, a device is asleep, a read failed transiently.
53    Unavailable = 4,
54    /// The interface exists and refused. Distinguished from `Unsupported`
55    /// because it is *actionable*: the same mirror run with more privilege
56    /// would fill this cell.
57    PermissionDenied = 5,
58    /// A value exists but is older than the sensor's declared useful lifetime.
59    Stale = 6,
60}
61
62impl Availability {
63    pub fn as_u8(self) -> u8 {
64        self as u8
65    }
66
67    pub fn from_u8(value: u8) -> Availability {
68        match value {
69            0 => Availability::Observed,
70            1 => Availability::NotApplicable,
71            2 => Availability::Unknown,
72            3 => Availability::Unsupported,
73            4 => Availability::Unavailable,
74            5 => Availability::PermissionDenied,
75            6 => Availability::Stale,
76            // An unknown code from a newer writer is itself unknown, which is
77            // the honest degradation.
78            _ => Availability::Unknown,
79        }
80    }
81
82    pub fn label(self) -> &'static str {
83        match self {
84            Availability::Observed => "observed",
85            Availability::NotApplicable => "not_applicable",
86            Availability::Unknown => "unknown",
87            Availability::Unsupported => "unsupported",
88            Availability::Unavailable => "unavailable",
89            Availability::PermissionDenied => "permission_denied",
90            Availability::Stale => "stale",
91        }
92    }
93
94    /// Why the cell is empty, in a sentence an operator can act on.
95    ///
96    /// The distinctions matter: "unsupported" means buy different hardware,
97    /// "permission denied" means run it differently, and "not applicable" means
98    /// the question was wrong. Collapsing them into "missing" throws away the
99    /// only part of a gap that is useful.
100    pub fn explain(self) -> &'static str {
101        match self {
102            Availability::Observed => "the value was read",
103            Availability::NotApplicable => "this measurement does not apply to this entity",
104            Availability::Unknown => "no reason was recorded",
105            Availability::Unsupported => "this hardware or kernel does not expose it",
106            Availability::Unavailable => "the source exists but returned nothing",
107            Availability::PermissionDenied => "this process lacks the privilege to read it",
108            Availability::Stale => "the last reading is too old to be trusted",
109        }
110    }
111
112    /// Whether the corresponding cell holds a usable number.
113    pub fn is_observed(self) -> bool {
114        self == Availability::Observed
115    }
116
117    /// Whether more privilege would plausibly fix this.
118    pub fn is_privilege_problem(self) -> bool {
119        self == Availability::PermissionDenied
120    }
121}
122
123/// Per-cell reasons, parallel to the state matrix.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct AvailabilityMatrix {
126    rows: usize,
127    cols: usize,
128    codes: Vec<u8>,
129}
130
131impl AvailabilityMatrix {
132    /// A matrix where nothing has been claimed by any sensor.
133    pub fn new(rows: usize, cols: usize) -> AvailabilityMatrix {
134        AvailabilityMatrix {
135            rows,
136            cols,
137            codes: vec![Availability::NotApplicable.as_u8(); rows * cols],
138        }
139    }
140
141    pub fn rows(&self) -> usize {
142        self.rows
143    }
144
145    pub fn cols(&self) -> usize {
146        self.cols
147    }
148
149    /// Reset to "no sensor claims anything", which is what a new pass starts
150    /// from before sensors declare what they attempted.
151    pub fn clear(&mut self) {
152        self.codes.fill(Availability::NotApplicable.as_u8());
153    }
154
155    #[inline]
156    pub fn get(&self, row: usize, col: usize) -> Availability {
157        if row >= self.rows || col >= self.cols {
158            return Availability::NotApplicable;
159        }
160        Availability::from_u8(self.codes[row * self.cols + col])
161    }
162
163    #[inline]
164    pub fn set(&mut self, row: usize, col: usize, availability: Availability) {
165        if row < self.rows && col < self.cols {
166            self.codes[row * self.cols + col] = availability.as_u8();
167        }
168    }
169
170    pub fn as_slice(&self) -> &[u8] {
171        &self.codes
172    }
173
174    pub fn copy_from_slice(&mut self, codes: &[u8]) {
175        assert_eq!(codes.len(), self.codes.len(), "availability shape mismatch");
176        self.codes.copy_from_slice(codes);
177    }
178
179    /// Count of cells with each reason, for a summary.
180    pub fn tally(&self) -> Vec<(Availability, usize)> {
181        let mut counts = [0usize; 8];
182        for code in &self.codes {
183            counts[(*code as usize).min(7)] += 1;
184        }
185        (0..7u8)
186            .map(|code| (Availability::from_u8(code), counts[code as usize]))
187            .filter(|(_, count)| *count > 0)
188            .collect()
189    }
190}
191
192/// Identifies a sensor within one mirror.
193#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
194#[serde(transparent)]
195pub struct SensorId(pub u16);
196
197/// How much sampling a channel disturbs the thing it is sampling.
198///
199/// This is the axis that separates a mirror from a probe, so it is a required
200/// declaration rather than documentation.
201#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
202#[serde(rename_all = "snake_case")]
203#[repr(u8)]
204pub enum Perturbation {
205    /// Reading touches only memory the observing CPU already owns. The observed
206    /// entity does not notice.
207    None = 0,
208    /// A kernel interface read on the observing CPU. Costs cycles and cache
209    /// footprint here; does not reach across to the observed entity.
210    Negligible = 1,
211    /// Observation reaches the observed entity: an IPI, a cross-CPU MSR read, a
212    /// bus transaction, or occupying a shared hardware resource. The observed
213    /// core does measurably less work because it was observed.
214    Low = 2,
215    /// Observation changes hardware or kernel state in order to read it. **No
216    /// sensor in the mirror may declare this.** It exists so the type can
217    /// express the boundary it is enforcing: anything here is an experiment.
218    Material = 3,
219}
220
221impl Perturbation {
222    pub fn as_u8(self) -> u8 {
223        self as u8
224    }
225
226    pub fn from_u8(value: u8) -> Perturbation {
227        match value {
228            1 => Perturbation::Negligible,
229            2 => Perturbation::Low,
230            3 => Perturbation::Material,
231            _ => Perturbation::None,
232        }
233    }
234
235    pub fn label(self) -> &'static str {
236        match self {
237            Perturbation::None => "none",
238            Perturbation::Negligible => "negligible",
239            Perturbation::Low => "low",
240            Perturbation::Material => "material",
241        }
242    }
243
244    /// Whether a sensor with this class is admissible in the mirror.
245    pub fn is_passive(self) -> bool {
246        self != Perturbation::Material
247    }
248}
249
250/// How wrong a reading may be when nothing has gone wrong.
251#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
252pub struct Uncertainty {
253    /// Absolute error in the channel's own unit, where it is known.
254    pub absolute: Option<f64>,
255    /// Fractional error, where that is the better description.
256    pub relative: Option<f64>,
257    /// Where the uncertainty comes from, in one line.
258    pub basis: &'static str,
259}
260
261impl Uncertainty {
262    pub const fn absolute(value: f64, basis: &'static str) -> Uncertainty {
263        Uncertainty {
264            absolute: Some(value),
265            relative: None,
266            basis,
267        }
268    }
269
270    pub const fn relative(value: f64, basis: &'static str) -> Uncertainty {
271        Uncertainty {
272            absolute: None,
273            relative: Some(value),
274            basis,
275        }
276    }
277
278    pub const fn unknown(basis: &'static str) -> Uncertainty {
279        Uncertainty {
280            absolute: None,
281            relative: None,
282            basis,
283        }
284    }
285}
286
287/// What one observation pass cost and produced, per sensor.
288///
289/// Published inside the snapshot: the mirror describing its own act of looking.
290#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
291pub struct SensorReport {
292    pub id: SensorId,
293    pub key: String,
294    pub perturbation: Perturbation,
295    /// Wall time this sensor took on the most recent pass. The observation cost.
296    pub last_cost_ns: u64,
297    /// How long between asking and the value being available, where that
298    /// differs from the cost. Zero when they are the same.
299    pub sampling_latency_ns: u64,
300    /// Age of the underlying value at publication time. Non-zero for sensors
301    /// whose source updates more slowly than the mirror ticks.
302    pub sample_age_ns: u64,
303    /// Cells it filled on the most recent pass.
304    pub samples: u32,
305    /// Reads that failed on the most recent pass.
306    pub errors: u32,
307    /// Confidence in this pass, `0.0 ..= 1.0`. Falls when reads fail.
308    pub confidence: f64,
309    /// Why this sensor's cells are empty, when they are.
310    pub availability: Availability,
311    /// True when the sensor could not bind at all, so its columns will be
312    /// unobserved for this whole epoch.
313    pub inactive: bool,
314}
315
316impl SensorReport {
317    /// A report for a sensor that bound and has not yet run.
318    pub fn pending(
319        id: SensorId,
320        key: impl Into<String>,
321        perturbation: Perturbation,
322    ) -> SensorReport {
323        SensorReport {
324            id,
325            key: key.into(),
326            perturbation,
327            last_cost_ns: 0,
328            sampling_latency_ns: 0,
329            sample_age_ns: 0,
330            samples: 0,
331            errors: 0,
332            confidence: 1.0,
333            availability: Availability::Unknown,
334            inactive: false,
335        }
336    }
337
338    /// A report for a sensor that could not bind, and why.
339    pub fn unavailable(
340        id: SensorId,
341        key: impl Into<String>,
342        perturbation: Perturbation,
343        availability: Availability,
344    ) -> SensorReport {
345        SensorReport {
346            inactive: true,
347            confidence: 0.0,
348            availability,
349            ..SensorReport::pending(id, key, perturbation)
350        }
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn availability_codes_round_trip_and_degrade() {
360        for availability in [
361            Availability::Observed,
362            Availability::NotApplicable,
363            Availability::Unknown,
364            Availability::Unsupported,
365            Availability::Unavailable,
366            Availability::PermissionDenied,
367            Availability::Stale,
368        ] {
369            assert_eq!(Availability::from_u8(availability.as_u8()), availability);
370        }
371        // A code from a newer writer is unknown, not silently "observed".
372        assert_eq!(Availability::from_u8(200), Availability::Unknown);
373    }
374
375    #[test]
376    fn permission_denied_is_distinguishable_from_unsupported() {
377        // The distinction that makes the difference actionable: one of these
378        // is fixed by running with more privilege, the other never is.
379        assert!(Availability::PermissionDenied.is_privilege_problem());
380        assert!(!Availability::Unsupported.is_privilege_problem());
381        assert!(!Availability::Observed.is_privilege_problem());
382    }
383
384    #[test]
385    fn a_new_availability_matrix_claims_nothing() {
386        let matrix = AvailabilityMatrix::new(3, 4);
387        assert_eq!(matrix.get(0, 0), Availability::NotApplicable);
388        assert_eq!(matrix.tally(), vec![(Availability::NotApplicable, 12)]);
389    }
390
391    #[test]
392    fn reasons_are_recorded_per_cell() {
393        let mut matrix = AvailabilityMatrix::new(2, 2);
394        matrix.set(0, 0, Availability::Observed);
395        matrix.set(0, 1, Availability::PermissionDenied);
396        matrix.set(1, 0, Availability::Unsupported);
397        assert_eq!(matrix.get(0, 0), Availability::Observed);
398        assert_eq!(matrix.get(0, 1), Availability::PermissionDenied);
399        assert_eq!(matrix.get(1, 0), Availability::Unsupported);
400        assert_eq!(matrix.get(1, 1), Availability::NotApplicable);
401
402        let tally = matrix.tally();
403        assert_eq!(tally.len(), 4, "four distinct reasons: {tally:?}");
404    }
405
406    #[test]
407    fn out_of_range_access_does_not_panic() {
408        let mut matrix = AvailabilityMatrix::new(2, 2);
409        matrix.set(99, 99, Availability::Observed);
410        assert_eq!(matrix.get(99, 99), Availability::NotApplicable);
411    }
412
413    #[test]
414    fn a_failed_sensor_reports_why_rather_than_zero() {
415        let report = SensorReport::unavailable(
416            SensorId(4),
417            "power",
418            Perturbation::Negligible,
419            Availability::PermissionDenied,
420        );
421        assert!(report.inactive);
422        assert_eq!(report.confidence, 0.0);
423        assert!(report.availability.is_privilege_problem());
424        assert_eq!(report.samples, 0);
425    }
426
427    #[test]
428    fn material_perturbation_is_not_passive() {
429        assert!(!Perturbation::Material.is_passive());
430        assert!(Perturbation::Low.is_passive());
431        assert!(Perturbation::None < Perturbation::Negligible);
432        assert!(Perturbation::Low < Perturbation::Material);
433    }
434}