Skip to main content

entrenar/monitor/inference/
trace.rs

1//! Decision Trace (ENT-104)
2//!
3//! Universal decision trace structure for all APR models.
4
5use super::path::DecisionPath;
6use serde::{Deserialize, Serialize};
7
8/// Universal decision trace structure
9///
10/// Records everything needed to understand and reproduce a prediction.
11#[derive(Clone, Debug, Serialize, Deserialize)]
12pub struct DecisionTrace<P: DecisionPath> {
13    /// Monotonic nanosecond timestamp
14    pub timestamp_ns: u64,
15    /// Sequence number within session
16    pub sequence: u64,
17    /// FNV-1a hash of input features
18    pub input_hash: u64,
19    /// Model-specific decision path
20    pub path: P,
21    /// Final output value
22    pub output: f32,
23    /// Inference latency in nanoseconds
24    pub latency_ns: u64,
25}
26
27impl<P: DecisionPath> DecisionTrace<P> {
28    /// Create a new decision trace
29    pub fn new(
30        timestamp_ns: u64,
31        sequence: u64,
32        input_hash: u64,
33        path: P,
34        output: f32,
35        latency_ns: u64,
36    ) -> Self {
37        Self { timestamp_ns, sequence, input_hash, path, output, latency_ns }
38    }
39
40    /// Get confidence from the underlying path
41    pub fn confidence(&self) -> f32 {
42        self.path.confidence()
43    }
44
45    /// Get human-readable explanation
46    pub fn explain(&self) -> String {
47        let mut explanation = format!(
48            "Trace #{} @ {}ns (latency: {}ns)\n",
49            self.sequence, self.timestamp_ns, self.latency_ns
50        );
51        explanation.push_str(&format!("Input hash: 0x{:016x}\n", self.input_hash));
52        explanation.push_str(&format!("Output: {:.4}\n", self.output));
53        explanation.push_str("---\n");
54        explanation.push_str(&self.path.explain());
55        explanation
56    }
57
58    /// Get feature contributions from the underlying path
59    pub fn feature_contributions(&self) -> &[f32] {
60        self.path.feature_contributions()
61    }
62
63    /// Convert to binary format
64    ///
65    /// Format:
66    /// - `[0..8]`: timestamp_ns (u64 LE)
67    /// - `[8..16]`: sequence (u64 LE)
68    /// - `[16..24]`: input_hash (u64 LE)
69    /// - `[24..28]`: output (f32 LE)
70    /// - `[28..32]`: latency_ns (u32 LE, microsecond precision)
71    /// - `[32..36]`: path_length (u32 LE)
72    /// - `[36..]`: path_data
73    pub fn to_bytes(&self) -> Vec<u8> {
74        let path_bytes = self.path.to_bytes();
75
76        let mut bytes = Vec::with_capacity(36 + path_bytes.len());
77
78        bytes.extend_from_slice(&self.timestamp_ns.to_le_bytes());
79        bytes.extend_from_slice(&self.sequence.to_le_bytes());
80        bytes.extend_from_slice(&self.input_hash.to_le_bytes());
81        bytes.extend_from_slice(&self.output.to_le_bytes());
82
83        // Store latency as microseconds in u32 for compactness
84        let latency_us = (self.latency_ns / 1000) as u32;
85        bytes.extend_from_slice(&latency_us.to_le_bytes());
86
87        bytes.extend_from_slice(&(path_bytes.len() as u32).to_le_bytes());
88        bytes.extend_from_slice(&path_bytes);
89
90        bytes
91    }
92
93    /// Reconstruct from binary format
94    pub fn from_bytes(bytes: &[u8]) -> Result<Self, super::path::PathError>
95    where
96        P: DecisionPath,
97    {
98        if bytes.len() < 36 {
99            return Err(super::path::PathError::InsufficientData {
100                expected: 36,
101                actual: bytes.len(),
102            });
103        }
104
105        let timestamp_ns = u64::from_le_bytes([
106            bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
107        ]);
108
109        let sequence = u64::from_le_bytes([
110            bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
111        ]);
112
113        let input_hash = u64::from_le_bytes([
114            bytes[16], bytes[17], bytes[18], bytes[19], bytes[20], bytes[21], bytes[22], bytes[23],
115        ]);
116
117        let output = f32::from_le_bytes([bytes[24], bytes[25], bytes[26], bytes[27]]);
118
119        let latency_us = u32::from_le_bytes([bytes[28], bytes[29], bytes[30], bytes[31]]);
120        let latency_ns = u64::from(latency_us) * 1000;
121
122        let path_length = u32::from_le_bytes([bytes[32], bytes[33], bytes[34], bytes[35]]) as usize;
123
124        if bytes.len() < 36 + path_length {
125            return Err(super::path::PathError::InsufficientData {
126                expected: 36 + path_length,
127                actual: bytes.len(),
128            });
129        }
130
131        let path = P::from_bytes(&bytes[36..36 + path_length])?;
132
133        Ok(Self { timestamp_ns, sequence, input_hash, path, output, latency_ns })
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use crate::monitor::inference::path::LinearPath;
141
142    #[test]
143    fn test_decision_trace_new() {
144        let path = LinearPath::new(vec![0.5, -0.3], 0.1, 0.5, 0.87);
145        let trace = DecisionTrace::new(1000, 42, 0xdeadbeef, path, 0.87, 500);
146
147        assert_eq!(trace.timestamp_ns, 1000);
148        assert_eq!(trace.sequence, 42);
149        assert_eq!(trace.input_hash, 0xdeadbeef);
150        assert_eq!(trace.output, 0.87);
151        assert_eq!(trace.latency_ns, 500);
152    }
153
154    #[test]
155    fn test_decision_trace_confidence() {
156        let path = LinearPath::new(vec![0.5], 0.0, 0.0, 0.0).with_probability(0.9);
157        let trace = DecisionTrace::new(0, 0, 0, path, 0.0, 0);
158        assert!((trace.confidence() - 0.9).abs() < 1e-6);
159    }
160
161    #[test]
162    fn test_decision_trace_explain() {
163        let path = LinearPath::new(vec![0.5, -0.3], 0.1, 0.5, 0.87);
164        let trace = DecisionTrace::new(1000, 42, 0xdeadbeef, path, 0.87, 500);
165
166        let explanation = trace.explain();
167        assert!(explanation.contains("Trace #42"));
168        assert!(explanation.contains("0x00000000deadbeef"));
169        assert!(explanation.contains("Output: 0.87"));
170    }
171
172    #[test]
173    fn test_decision_trace_serialization_roundtrip() {
174        let path = LinearPath::new(vec![0.5, -0.3, 0.2], 0.1, 0.5, 0.87).with_probability(0.87);
175        let trace = DecisionTrace::new(1_000_000_000, 42, 0xdeadbeef12345678, path, 0.87, 500_000);
176
177        let bytes = trace.to_bytes();
178        let restored: DecisionTrace<LinearPath> =
179            DecisionTrace::from_bytes(&bytes).expect("Failed to deserialize");
180
181        assert_eq!(trace.timestamp_ns, restored.timestamp_ns);
182        assert_eq!(trace.sequence, restored.sequence);
183        assert_eq!(trace.input_hash, restored.input_hash);
184        assert!((trace.output - restored.output).abs() < 1e-6);
185        // Latency has microsecond precision, so check within 1000ns
186        assert!((trace.latency_ns as i64 - restored.latency_ns as i64).abs() < 1000);
187    }
188
189    #[test]
190    fn test_decision_trace_feature_contributions() {
191        let path = LinearPath::new(vec![0.5, -0.3, 0.2], 0.0, 0.0, 0.0);
192        let trace = DecisionTrace::new(0, 0, 0, path, 0.0, 0);
193
194        let contributions = trace.feature_contributions();
195        assert_eq!(contributions.len(), 3);
196        assert!((contributions[0] - 0.5).abs() < 1e-6);
197    }
198
199    #[test]
200    fn test_decision_trace_insufficient_data() {
201        let result: Result<DecisionTrace<LinearPath>, _> = DecisionTrace::from_bytes(&[0; 10]);
202        assert!(result.is_err());
203    }
204}