Skip to main content

eredu_core/
observation.rs

1//! Portable, explicitly requested execution observations.
2
3use std::collections::{btree_map::Entry, BTreeMap};
4
5use serde::{Deserialize, Serialize};
6
7/// Canonical observation path for a model's output logits.
8///
9/// Every execution mode uses this path so resident, layerwise, tensor-parallel,
10/// pipeline-parallel, and architecture-erased sessions expose the same
11/// semantic observation.
12pub const MODEL_LOGITS_OBSERVATION_PATH: &str = "model.logits";
13
14/// Canonical prefix for tensors emitted by architecture processor execution.
15pub const PROCESSOR_OUTPUT_OBSERVATION_PATH: &str = "model.processor.output";
16
17/// Canonical observation path for projected visual features.
18pub const VISION_PROJECTOR_OUTPUT_OBSERVATION_PATH: &str = "model.vision.projector.output";
19
20/// Canonical observation path for projected audio features.
21pub const AUDIO_PROJECTOR_OUTPUT_OBSERVATION_PATH: &str = "model.audio.projector.output";
22
23/// Canonical observation path for the decoder-width multimodal assembly.
24pub const MODALITY_MERGE_OUTPUT_OBSERVATION_PATH: &str = "model.modality.merge.output";
25
26/// Materialized row-major tensor values.
27///
28/// Backends may normalize native storage such as F16 or BF16 to F32 when
29/// crossing the observation boundary. This type describes the host values,
30/// not checkpoint storage.
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32#[serde(tag = "dtype", content = "values", rename_all = "snake_case")]
33pub enum TensorObservationData {
34    /// IEEE F32 values.
35    F32(Vec<f32>),
36    /// Signed 64-bit values.
37    I64(Vec<i64>),
38    /// Unsigned 64-bit values.
39    U64(Vec<u64>),
40    /// Boolean values.
41    Bool(Vec<bool>),
42}
43
44impl TensorObservationData {
45    /// Number of materialized values.
46    pub fn len(&self) -> usize {
47        match self {
48            Self::F32(values) => values.len(),
49            Self::I64(values) => values.len(),
50            Self::U64(values) => values.len(),
51            Self::Bool(values) => values.len(),
52        }
53    }
54
55    /// Whether no values are present.
56    pub fn is_empty(&self) -> bool {
57        self.len() == 0
58    }
59}
60
61/// One complete host-materialized tensor observation.
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63pub struct TensorObservation {
64    shape: Vec<usize>,
65    data: TensorObservationData,
66}
67
68impl TensorObservation {
69    /// Validates a tensor shape and its row-major values.
70    pub fn new(shape: Vec<usize>, data: TensorObservationData) -> Result<Self, ObservationError> {
71        let elements = shape.iter().try_fold(1usize, |count, dimension| {
72            count
73                .checked_mul(*dimension)
74                .ok_or(ObservationError::ShapeOverflow)
75        })?;
76        if elements != data.len() {
77            return Err(ObservationError::ElementCount {
78                shape,
79                expected: elements,
80                actual: data.len(),
81            });
82        }
83        Ok(Self { shape, data })
84    }
85
86    /// Logical row-major shape.
87    pub fn shape(&self) -> &[usize] {
88        &self.shape
89    }
90
91    /// Materialized row-major values.
92    pub const fn data(&self) -> &TensorObservationData {
93        &self.data
94    }
95
96    /// Consumes this observation into its shape and values.
97    pub fn into_parts(self) -> (Vec<usize>, TensorObservationData) {
98        (self.shape, self.data)
99    }
100}
101
102/// One portable observation value.
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
104#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
105pub enum ObservationValue {
106    /// Materialized tensor.
107    Tensor(TensorObservation),
108    /// Floating-point scalar, including timings and ratios.
109    Float(f64),
110    /// Signed integer scalar.
111    Integer(i64),
112    /// Unsigned integer scalar.
113    Unsigned(u64),
114    /// Boolean scalar.
115    Boolean(bool),
116    /// Textual identity, label, or diagnostic.
117    Text(String),
118}
119
120/// Deterministically ordered, path-addressed observations from one operation.
121#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
122pub struct ObservationSet {
123    values: BTreeMap<String, ObservationValue>,
124}
125
126impl ObservationSet {
127    /// Creates an empty set.
128    pub const fn new() -> Self {
129        Self {
130            values: BTreeMap::new(),
131        }
132    }
133
134    /// Inserts one uniquely named observation.
135    pub fn insert(
136        &mut self,
137        path: impl Into<String>,
138        value: ObservationValue,
139    ) -> Result<(), ObservationError> {
140        let path = path.into();
141        if path.is_empty() {
142            return Err(ObservationError::EmptyPath);
143        }
144        match self.values.entry(path) {
145            Entry::Vacant(entry) => {
146                entry.insert(value);
147            }
148            Entry::Occupied(entry) => {
149                return Err(ObservationError::DuplicatePath(entry.key().clone()));
150            }
151        }
152        Ok(())
153    }
154
155    /// Looks up an observation by its stable path.
156    pub fn get(&self, path: &str) -> Option<&ObservationValue> {
157        self.values.get(path)
158    }
159
160    /// Iterates in stable path order.
161    pub fn iter(&self) -> impl Iterator<Item = (&str, &ObservationValue)> {
162        self.values
163            .iter()
164            .map(|(path, value)| (path.as_str(), value))
165    }
166
167    /// Number of observations.
168    pub fn len(&self) -> usize {
169        self.values.len()
170    }
171
172    /// Whether no observations are present.
173    pub fn is_empty(&self) -> bool {
174        self.values.is_empty()
175    }
176
177    /// Adds a prefix to every path, preserving deterministic order.
178    pub fn prefixed(self, prefix: &str) -> Result<Self, ObservationError> {
179        if prefix.is_empty() {
180            return Ok(self);
181        }
182        let mut output = Self::new();
183        for (path, value) in self.values {
184            output.insert(format!("{prefix}.{path}"), value)?;
185        }
186        Ok(output)
187    }
188
189    /// Extends this set, rejecting path collisions.
190    pub fn extend(&mut self, other: Self) -> Result<(), ObservationError> {
191        for (path, value) in other.values {
192            self.insert(path, value)?;
193        }
194        Ok(())
195    }
196}
197
198/// One activation-path selector.
199#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
200#[serde(tag = "match", content = "path", rename_all = "snake_case")]
201pub enum ObservationSelector {
202    /// Select exactly one stable path.
203    Exact(String),
204    /// Select a path and all descendants separated by `.`.
205    Prefix(String),
206}
207
208impl ObservationSelector {
209    /// Returns whether this selector accepts `path`.
210    pub fn matches(&self, path: &str) -> bool {
211        match self {
212            Self::Exact(expected) => path == expected,
213            Self::Prefix(prefix) => {
214                path == prefix
215                    || path
216                        .strip_prefix(prefix)
217                        .is_some_and(|suffix| suffix.starts_with('.'))
218            }
219        }
220    }
221}
222
223/// Explicit selection for an instrumented execution pass.
224#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
225pub struct ObservationRequest {
226    selectors: Vec<ObservationSelector>,
227}
228
229impl ObservationRequest {
230    /// Selects every observation point reached by the operation.
231    pub const fn all() -> Self {
232        Self {
233            selectors: Vec::new(),
234        }
235    }
236
237    /// Selects the supplied exact paths or prefixes.
238    pub fn selected(selectors: impl IntoIterator<Item = ObservationSelector>) -> Self {
239        Self {
240            selectors: selectors.into_iter().collect(),
241        }
242    }
243
244    /// Returns whether a named point is requested.
245    pub fn matches(&self, path: &str) -> bool {
246        self.selectors.is_empty() || self.selectors.iter().any(|selector| selector.matches(path))
247    }
248
249    /// Requested selectors; empty means all points.
250    pub fn selectors(&self) -> &[ObservationSelector] {
251        &self.selectors
252    }
253}
254
255/// A completed instrumented operation and its portable observations.
256#[derive(Debug)]
257pub struct InspectedOutput<O> {
258    /// Ordinary backend output from the operation.
259    pub output: O,
260    /// Requested host-materialized observations.
261    pub observations: ObservationSet,
262}
263
264/// Invalid portable observation data.
265#[derive(Debug, Clone, Eq, PartialEq, thiserror::Error)]
266pub enum ObservationError {
267    /// Tensor element-count multiplication overflowed.
268    #[error("tensor observation shape element count overflowed")]
269    ShapeOverflow,
270    /// Tensor shape and host values disagree.
271    #[error(
272        "tensor observation shape {shape:?} requires {expected} values, but received {actual}"
273    )]
274    ElementCount {
275        /// Logical shape.
276        shape: Vec<usize>,
277        /// Required element count.
278        expected: usize,
279        /// Supplied element count.
280        actual: usize,
281    },
282    /// Observation paths must be nonempty.
283    #[error("observation path must not be empty")]
284    EmptyPath,
285    /// Observation paths are unique within one operation.
286    #[error("duplicate observation path {0:?}")]
287    DuplicatePath(String),
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn tensor_shape_and_values_must_agree() {
296        let tensor = TensorObservation::new(
297            vec![2, 2],
298            TensorObservationData::F32(vec![1.0, 2.0, 3.0, 4.0]),
299        )
300        .unwrap();
301        assert_eq!(tensor.shape(), [2, 2]);
302        assert!(matches!(tensor.data(), TensorObservationData::F32(_)));
303        assert!(matches!(
304            TensorObservation::new(vec![2], TensorObservationData::I64(vec![1])),
305            Err(ObservationError::ElementCount { .. })
306        ));
307    }
308
309    #[test]
310    fn selectors_and_sets_are_stable_and_collision_safe() {
311        let request = ObservationRequest::selected([
312            ObservationSelector::Exact(MODEL_LOGITS_OBSERVATION_PATH.into()),
313            ObservationSelector::Prefix("model.layers.2".into()),
314        ]);
315        assert_eq!(MODEL_LOGITS_OBSERVATION_PATH, "model.logits");
316        assert!(request.matches(MODEL_LOGITS_OBSERVATION_PATH));
317        assert!(request.matches("model.layers.2.output"));
318        assert!(!request.matches("model.layers.20.output"));
319
320        let mut set = ObservationSet::new();
321        set.insert(MODEL_LOGITS_OBSERVATION_PATH, ObservationValue::Unsigned(3))
322            .unwrap();
323        assert_eq!(
324            set.insert(MODEL_LOGITS_OBSERVATION_PATH, ObservationValue::Unsigned(4)),
325            Err(ObservationError::DuplicatePath(
326                MODEL_LOGITS_OBSERVATION_PATH.into()
327            ))
328        );
329    }
330}