corescout_substrate/observation/mod.rs
1//! Observation: how the machine samples itself, and what that costs.
2//!
3//! # The rule this layer enforces
4//!
5//! > Observation belongs to the mirror. Intentional perturbation belongs to
6//! > experimentation.
7//!
8//! Every sensor here is passive: it reads state the machine is maintaining
9//! anyway, for its own reasons, whether or not anyone is looking. Nothing in
10//! this module runs a workload, moves a thread, changes a frequency or requests
11//! an idle state. Code that wants to do those things lives in
12//! `corescout-experiment` or `corescout-agency`, and is not part of `M(t)`.
13//!
14//! # Observation is not free
15//!
16//! A computational mirror is unusual among mirrors: the act of looking consumes
17//! the thing being looked at. Reading a sensor costs cycles on some CPU, evicts
18//! cache lines, may take a kernel lock, and in several real cases sends an
19//! inter-processor interrupt to the very core whose state is in question.
20//!
21//! So every sensor must declare, and this is enforced by the type system rather
22//! than by convention:
23//!
24//! | property | why it is required |
25//! |---|---|
26//! | `physical_fact` | what real quantity the number approximates |
27//! | `source` | where it comes from, so a reader can go and check |
28//! | `max_rate_hz` | above this, sampling returns no new information |
29//! | `perturbation` | how much observing changes what is observed |
30//! | `uncertainty` | how wrong the number may be even when everything works |
31//! | `requires_privilege` | whether it will be absent for ordinary users |
32//!
33//! # Never fabricate
34//!
35//! A sensor that cannot read something records **why** through
36//! [`StateWriter::missing`], and the reason reaches the consumer in the
37//! snapshot's availability matrix. A machine with no RAPL does not report zero
38//! watts. The types make the honest path the easy one: writing a value and
39//! marking it observed are the same call, and there is no way to write a value
40//! without claiming you observed it.
41
42#[cfg(target_os = "windows")]
43pub mod windows;
44
45pub mod counters;
46pub mod frequency;
47pub mod idle;
48pub mod interrupts;
49pub mod power;
50pub mod scheduler;
51pub mod source;
52pub mod thermal;
53
54use corescout_core::Result;
55use corescout_mirror::entity::Entity;
56use corescout_mirror::relation::Relation;
57use corescout_mirror::schema::{Availability, AvailabilityMatrix};
58use corescout_mirror::state::{ChannelId, ChannelSpec, Semantics, StateMatrix, Unit};
59
60use crate::discovery::Substrate;
61
62// The metadata a sensor declares is part of the *representation*, so it lives
63// in `corescout-mirror` where a consumer that never links this crate can still
64// read it. Re-exported here because this is where sensors are written.
65pub use corescout_mirror::schema::{Perturbation, SensorId, SensorReport, Uncertainty};
66
67/// Everything a sensor must declare about itself.
68#[derive(Debug, Clone, PartialEq)]
69pub struct SensorDescriptor {
70 pub id: SensorId,
71 /// Stable short name, e.g. `frequency`.
72 pub key: &'static str,
73 /// The physical quantity being approximated, in one sentence.
74 pub physical_fact: &'static str,
75 /// The kernel interface or instruction the value comes from.
76 pub source: &'static str,
77 /// Above this rate, sampling returns no new information.
78 pub max_rate_hz: f64,
79 pub perturbation: Perturbation,
80 pub uncertainty: Uncertainty,
81 /// True when the sensor needs privileges an ordinary user may not have, and
82 /// will therefore be absent rather than wrong.
83 pub requires_privilege: bool,
84}
85
86/// The result of one sensor's observation pass.
87#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
88pub struct SensorOutcome {
89 pub samples: u32,
90 pub errors: u32,
91 /// Age of the underlying values at the time they were read, for sensors
92 /// whose source updates more slowly than the mirror ticks.
93 pub sample_age_ns: u64,
94}
95
96impl SensorOutcome {
97 pub fn sample(&mut self) {
98 self.samples += 1;
99 }
100
101 pub fn error(&mut self) {
102 self.errors += 1;
103 }
104
105 pub fn aged(&mut self, age_ns: u64) {
106 self.sample_age_ns = self.sample_age_ns.max(age_ns);
107 }
108}
109
110/// Handed to a sensor during binding so it can declare what it will observe.
111///
112/// Sensors may declare **entities of their own**, not only channels. A thermal
113/// zone and a RAPL power domain are real parts of the machine that the CPU
114/// topology knows nothing about, and forcing them to be attributes of a core
115/// would be exactly the human-ontology imposition this architecture avoids.
116pub struct BindContext<'a> {
117 substrate: &'a Substrate,
118 sensor: SensorId,
119 entities: &'a mut Vec<Entity>,
120 channels: &'a mut Vec<ChannelSpec>,
121 relations: &'a mut Vec<Relation>,
122}
123
124impl<'a> BindContext<'a> {
125 pub fn new(
126 substrate: &'a Substrate,
127 sensor: SensorId,
128 entities: &'a mut Vec<Entity>,
129 channels: &'a mut Vec<ChannelSpec>,
130 relations: &'a mut Vec<Relation>,
131 ) -> BindContext<'a> {
132 BindContext {
133 substrate,
134 sensor,
135 entities,
136 channels,
137 relations,
138 }
139 }
140
141 /// The structural machine description, including filesystem roots.
142 pub fn substrate(&self) -> &Substrate {
143 self.substrate
144 }
145
146 /// Row index of an already-declared entity, by natural key.
147 pub fn row_of(&self, key: &str) -> Option<u32> {
148 self.entities
149 .iter()
150 .position(|e| e.key == key)
151 .map(|i| i as u32)
152 }
153
154 /// Declare an entity, or return the existing row if its key is already
155 /// present. Idempotent, so two sensors observing the same physical thing
156 /// agree about which row it is rather than duplicating it.
157 pub fn declare_entity(&mut self, entity: Entity) -> u32 {
158 if let Some(row) = self.row_of(&entity.key) {
159 return row;
160 }
161 self.entities.push(entity);
162 (self.entities.len() - 1) as u32
163 }
164
165 /// Declare a channel and get its column index.
166 pub fn declare_channel(
167 &mut self,
168 key: impl Into<String>,
169 unit: Unit,
170 semantics: Semantics,
171 ) -> ChannelId {
172 let key = key.into();
173 if let Some(existing) = self.channels.iter().find(|c| c.key == key) {
174 return existing.id;
175 }
176 let id = ChannelId(self.channels.len() as u16);
177 self.channels.push(ChannelSpec {
178 id,
179 key,
180 unit,
181 semantics,
182 sensor: self.sensor,
183 });
184 id
185 }
186
187 /// Declare an edge.
188 pub fn declare_relation(&mut self, relation: Relation) {
189 self.relations.push(relation);
190 }
191}
192
193/// Records *why* cells are empty. Held by [`StateWriter`].
194pub struct AvailabilityWriter<'a> {
195 matrix: &'a mut AvailabilityMatrix,
196}
197
198impl<'a> AvailabilityWriter<'a> {
199 pub fn new(matrix: &'a mut AvailabilityMatrix) -> AvailabilityWriter<'a> {
200 AvailabilityWriter { matrix }
201 }
202
203 #[inline]
204 pub fn set(&mut self, row: u32, channel: ChannelId, availability: Availability) {
205 self.matrix.set(row as usize, channel.index(), availability);
206 }
207}
208
209/// Handed to a sensor during observation. The only things it can do are record
210/// a number, or record why there is not one.
211pub struct StateWriter<'a> {
212 matrix: &'a mut StateMatrix,
213 availability: AvailabilityWriter<'a>,
214}
215
216impl<'a> StateWriter<'a> {
217 pub fn new(
218 matrix: &'a mut StateMatrix,
219 availability: AvailabilityWriter<'a>,
220 ) -> StateWriter<'a> {
221 StateWriter {
222 matrix,
223 availability,
224 }
225 }
226
227 /// Record an observation.
228 ///
229 /// Out-of-range rows and columns are ignored rather than panicking: a
230 /// sensor racing a CPU hotplug event should degrade to a missing cell, not
231 /// take the whole mirror down.
232 #[inline]
233 pub fn set(&mut self, row: u32, channel: ChannelId, value: f64) {
234 let (r, c) = (row as usize, channel.index());
235 if r < self.matrix.rows() && c < self.matrix.cols() {
236 self.matrix.set(r, c, value);
237 self.availability.set(row, channel, Availability::Observed);
238 }
239 }
240
241 /// Record that a cell has no value, and why.
242 ///
243 /// The alternative, leaving it silently `NaN`, loses the distinction
244 /// between "this machine cannot tell you" and "nobody asked".
245 #[inline]
246 pub fn missing(&mut self, row: u32, channel: ChannelId, why: Availability) {
247 let (r, c) = (row as usize, channel.index());
248 if r < self.matrix.rows() && c < self.matrix.cols() {
249 self.availability.set(row, channel, why);
250 }
251 }
252}
253
254/// A passive source of observations.
255///
256/// Implementors must be honest about [`Perturbation`]. The reflector checks
257/// that no registered sensor declares [`Perturbation::Material`], but it cannot
258/// check that a sensor declaring `Negligible` is telling the truth. That is a
259/// review obligation, and it is why each sensor's module documentation states
260/// the physical basis for its claim.
261pub trait Sensor: Send {
262 fn descriptor(&self) -> SensorDescriptor;
263
264 /// Resolve everything resolvable once: which entities exist, which files to
265 /// read, which descriptors to open, which columns to fill.
266 ///
267 /// This is where CoreScout earns its keep as a normalisation layer. Path
268 /// construction, parsing setup and capability probing happen here, once per
269 /// epoch, so that [`Sensor::observe`] is close to pure I/O.
270 ///
271 /// Returning `Err` means the sensor is unavailable on this machine, which
272 /// is a normal outcome. The error's kind decides what the mirror reports:
273 /// permission denied, unsupported, or merely unavailable.
274 fn bind(&mut self, ctx: &mut BindContext<'_>) -> Result<()>;
275
276 /// Sample the machine once.
277 fn observe(&mut self, out: &mut StateWriter<'_>) -> SensorOutcome;
278}
279
280/// The default passive sensor set for this machine.
281///
282/// Ordered cheapest-first, so a mirror running under a tight tick budget
283/// degrades by dropping the expensive sensors at the end rather than by
284/// randomly missing whichever ones ran late.
285pub fn default_sensors() -> Vec<Box<dyn Sensor>> {
286 // Windows exposes a different, smaller set through entirely different
287 // interfaces. See `linux_sensors` for the set that reads sysfs and procfs,
288 // which a test with a synthetic tree wants by name rather than by default.
289 #[cfg(target_os = "windows")]
290 {
291 return windows::sensors();
292 }
293 #[allow(unreachable_code)]
294 linux_sensors()
295}
296
297/// The sysfs and procfs sensor set.
298///
299/// Named separately from [`default_sensors`] because it is meaningful on any
300/// platform: the sensors read files by path, so a test can point them at a
301/// synthetic tree and exercise them anywhere. What varies by platform is which
302/// set is the *default*, not which set can be constructed.
303pub fn linux_sensors() -> Vec<Box<dyn Sensor>> {
304 vec![
305 Box::new(frequency::FrequencySensor::new()),
306 Box::new(idle::IdleSensor::new()),
307 Box::new(thermal::ThermalSensor::new()),
308 Box::new(power::PowerSensor::new()),
309 Box::new(scheduler::SchedulerSensor::new()),
310 Box::new(interrupts::InterruptSensor::new()),
311 Box::new(counters::CounterSensor::new()),
312 ]
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318
319 #[test]
320 fn no_default_sensor_claims_to_be_material() {
321 // The architectural invariant, asserted rather than trusted.
322 for sensor in default_sensors() {
323 let d = sensor.descriptor();
324 assert!(
325 d.perturbation.is_passive(),
326 "sensor `{}` declares Material perturbation and is an experiment",
327 d.key
328 );
329 }
330 }
331
332 #[test]
333 fn every_sensor_documents_its_physical_basis() {
334 for sensor in default_sensors() {
335 let d = sensor.descriptor();
336 assert!(
337 !d.physical_fact.is_empty(),
338 "{} has no physical_fact",
339 d.key
340 );
341 assert!(!d.source.is_empty(), "{} has no source", d.key);
342 assert!(
343 !d.uncertainty.basis.is_empty(),
344 "{} has no uncertainty basis",
345 d.key
346 );
347 assert!(
348 d.max_rate_hz > 0.0,
349 "{} declares no sampling ceiling",
350 d.key
351 );
352 }
353 }
354
355 #[test]
356 fn sensor_ids_and_keys_are_unique() {
357 let sensors = default_sensors();
358 let mut ids: Vec<u16> = sensors.iter().map(|s| s.descriptor().id.0).collect();
359 let mut keys: Vec<&str> = sensors.iter().map(|s| s.descriptor().key).collect();
360 ids.sort_unstable();
361 keys.sort_unstable();
362 let unique_ids = {
363 let mut v = ids.clone();
364 v.dedup();
365 v.len()
366 };
367 let unique_keys = {
368 let mut v = keys.clone();
369 v.dedup();
370 v.len()
371 };
372 assert_eq!(unique_ids, ids.len(), "duplicate sensor id");
373 assert_eq!(unique_keys, keys.len(), "duplicate sensor key");
374 }
375
376 #[test]
377 fn writing_a_value_marks_it_observed_and_missing_records_a_reason() {
378 let mut matrix = StateMatrix::new(2, 2);
379 let mut availability = AvailabilityMatrix::new(2, 2);
380 {
381 let mut writer =
382 StateWriter::new(&mut matrix, AvailabilityWriter::new(&mut availability));
383 writer.set(0, ChannelId(0), 5.0);
384 writer.missing(1, ChannelId(1), Availability::PermissionDenied);
385 // Out of range must not panic.
386 writer.set(99, ChannelId(0), 1.0);
387 writer.missing(0, ChannelId(99), Availability::Unknown);
388 }
389 assert_eq!(matrix.get(0, 0), 5.0);
390 assert_eq!(availability.get(0, 0), Availability::Observed);
391 assert!(matrix.get(1, 1).is_nan());
392 assert_eq!(availability.get(1, 1), Availability::PermissionDenied);
393 assert_eq!(matrix.observed_cells(), 1);
394 }
395}