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