/home/noah/src/ruchy/src/runtime/deterministic.rs
Line | Count | Source |
1 | | //! Deterministic execution support for REPL replay testing |
2 | | //! |
3 | | //! Implements the `DeterministicRepl` trait for the Ruchy REPL to enable |
4 | | //! deterministic replay for testing and educational assessment. |
5 | | |
6 | | use anyhow::Result; |
7 | | use std::collections::HashMap; |
8 | | use sha2::{Sha256, Digest}; |
9 | | |
10 | | use crate::runtime::repl::{Repl, Value}; |
11 | | use crate::runtime::replay::{ |
12 | | DeterministicRepl, ReplayResult, StateCheckpoint, ResourceUsage, |
13 | | ValidationResult, Divergence |
14 | | }; |
15 | | |
16 | | /// Mock time source for deterministic time operations |
17 | | pub struct MockTime { |
18 | | current_ns: u64, |
19 | | } |
20 | | |
21 | | impl Default for MockTime { |
22 | 0 | fn default() -> Self { |
23 | 0 | Self::new() |
24 | 0 | } |
25 | | } |
26 | | |
27 | | impl MockTime { |
28 | 0 | pub fn new() -> Self { |
29 | 0 | Self { current_ns: 0 } |
30 | 0 | } |
31 | | |
32 | 0 | pub fn advance(&mut self, ns: u64) { |
33 | 0 | self.current_ns += ns; |
34 | 0 | } |
35 | | |
36 | 0 | pub fn now(&self) -> u64 { |
37 | 0 | self.current_ns |
38 | 0 | } |
39 | | } |
40 | | |
41 | | /// Deterministic random number generator |
42 | | pub struct DeterministicRng { |
43 | | seed: u64, |
44 | | state: u64, |
45 | | } |
46 | | |
47 | | impl DeterministicRng { |
48 | 0 | pub fn new(seed: u64) -> Self { |
49 | 0 | Self { |
50 | 0 | seed, |
51 | 0 | state: seed, |
52 | 0 | } |
53 | 0 | } |
54 | | |
55 | 0 | pub fn next(&mut self) -> u64 { |
56 | | // Simple LCG for deterministic pseudo-random numbers |
57 | 0 | self.state = self.state.wrapping_mul(6_364_136_223_846_793_005) |
58 | 0 | .wrapping_add(1_442_695_040_888_963_407); |
59 | 0 | self.state |
60 | 0 | } |
61 | | |
62 | 0 | pub fn reset(&mut self) { |
63 | 0 | self.state = self.seed; |
64 | 0 | } |
65 | | } |
66 | | |
67 | | /// Extension trait to make Repl deterministic |
68 | | impl DeterministicRepl for Repl { |
69 | 0 | fn execute_with_seed(&mut self, input: &str, _seed: u64) -> ReplayResult { |
70 | | // Store current resource usage start point |
71 | 0 | let start_heap = self.estimate_heap_usage(); |
72 | 0 | let start_stack = self.estimate_stack_depth(); |
73 | 0 | let start_time = std::time::Instant::now(); |
74 | | |
75 | | // Note: RNG seed will be set when random number support is added to REPL |
76 | | // Currently executes normally as REPL doesn't use RNG yet |
77 | | |
78 | | // Execute the input |
79 | 0 | let output = self.eval(input).map(|s| { |
80 | | // Convert string output back to Value |
81 | | // This is a simplified conversion - in production we'd preserve the actual Value |
82 | 0 | if s == "()" || s.is_empty() { |
83 | 0 | Value::Unit |
84 | 0 | } else if s == "true" { |
85 | 0 | Value::Bool(true) |
86 | 0 | } else if s == "false" { |
87 | 0 | Value::Bool(false) |
88 | 0 | } else if let Ok(n) = s.parse::<i64>() { |
89 | 0 | Value::Int(n) |
90 | 0 | } else if s.starts_with('"') && s.ends_with('"') { |
91 | 0 | Value::String(s[1..s.len()-1].to_string()) |
92 | | } else { |
93 | | // For complex types, we store as string representation |
94 | 0 | Value::String(s) |
95 | | } |
96 | 0 | }); |
97 | | |
98 | | // Calculate resource usage |
99 | 0 | let heap_bytes = self.estimate_heap_usage() - start_heap; |
100 | 0 | let stack_depth = self.estimate_stack_depth() - start_stack; |
101 | 0 | let cpu_ns = start_time.elapsed().as_nanos() as u64; |
102 | | |
103 | | // Compute state hash |
104 | 0 | let state_hash = self.compute_state_hash(); |
105 | | |
106 | 0 | ReplayResult { |
107 | 0 | output, |
108 | 0 | state_hash, |
109 | 0 | resource_usage: ResourceUsage { |
110 | 0 | heap_bytes, |
111 | 0 | stack_depth, |
112 | 0 | cpu_ns, |
113 | 0 | }, |
114 | 0 | } |
115 | 0 | } |
116 | | |
117 | 0 | fn checkpoint(&self) -> StateCheckpoint { |
118 | 0 | let mut bindings = HashMap::new(); |
119 | 0 | let type_environment = HashMap::new(); |
120 | | |
121 | | // Extract all variable bindings |
122 | 0 | for (name, value) in self.get_bindings() { |
123 | 0 | bindings.insert(name.clone(), format!("{value:?}")); |
124 | 0 | } |
125 | | |
126 | | // Extract type environment if available |
127 | | // Type tracking will be implemented when static analysis is added |
128 | | |
129 | 0 | StateCheckpoint { |
130 | 0 | bindings, |
131 | 0 | type_environment, |
132 | 0 | state_hash: self.compute_state_hash(), |
133 | 0 | resource_usage: ResourceUsage { |
134 | 0 | heap_bytes: self.estimate_heap_usage(), |
135 | 0 | stack_depth: self.estimate_stack_depth(), |
136 | 0 | cpu_ns: 0, // Not meaningful for a checkpoint |
137 | 0 | }, |
138 | 0 | } |
139 | 0 | } |
140 | | |
141 | 0 | fn restore(&mut self, checkpoint: &StateCheckpoint) -> Result<()> { |
142 | | // Clear current state |
143 | 0 | self.clear_bindings(); |
144 | | |
145 | | // Restore bindings |
146 | 0 | let bindings = self.get_bindings_mut(); |
147 | 0 | for (name, value_str) in &checkpoint.bindings { |
148 | | // This is simplified - in production we'd properly deserialize the values |
149 | 0 | let value = if value_str == "Unit" { |
150 | 0 | Value::Unit |
151 | 0 | } else if value_str == "true" { |
152 | 0 | Value::Bool(true) |
153 | 0 | } else if value_str == "false" { |
154 | 0 | Value::Bool(false) |
155 | 0 | } else if let Ok(n) = value_str.parse::<i64>() { |
156 | 0 | Value::Int(n) |
157 | | } else { |
158 | 0 | Value::String(value_str.clone()) |
159 | | }; |
160 | | |
161 | 0 | bindings.insert(name.clone(), value); |
162 | | } |
163 | | |
164 | 0 | Ok(()) |
165 | 0 | } |
166 | | |
167 | 0 | fn validate_determinism(&self, other: &Self) -> ValidationResult { |
168 | 0 | let mut divergences = vec![]; |
169 | | |
170 | | // Compare variable bindings |
171 | 0 | for (name, value) in self.get_bindings() { |
172 | 0 | match other.get_bindings().get(name) { |
173 | 0 | Some(other_value) if value == other_value => { |
174 | 0 | // Values match, good |
175 | 0 | } |
176 | 0 | Some(other_value) => { |
177 | 0 | divergences.push(Divergence::State { |
178 | 0 | expected_hash: format!("{value:?}"), |
179 | 0 | actual_hash: format!("{other_value:?}"), |
180 | 0 | }); |
181 | 0 | } |
182 | 0 | None => { |
183 | 0 | divergences.push(Divergence::State { |
184 | 0 | expected_hash: format!("{value:?}"), |
185 | 0 | actual_hash: "missing".to_string(), |
186 | 0 | }); |
187 | 0 | } |
188 | | } |
189 | | } |
190 | | |
191 | | // Check for variables in other but not in self |
192 | 0 | for name in other.get_bindings().keys() { |
193 | 0 | if !self.get_bindings().contains_key(name) { |
194 | 0 | divergences.push(Divergence::State { |
195 | 0 | expected_hash: "missing".to_string(), |
196 | 0 | actual_hash: format!("variable: {name}"), |
197 | 0 | }); |
198 | 0 | } |
199 | | } |
200 | | |
201 | 0 | ValidationResult { |
202 | 0 | is_deterministic: divergences.is_empty(), |
203 | 0 | divergences, |
204 | 0 | } |
205 | 0 | } |
206 | | } |
207 | | |
208 | | // Helper methods for Repl |
209 | | impl Repl { |
210 | | /// Estimate heap usage in bytes (simplified) |
211 | 0 | fn estimate_heap_usage(&self) -> usize { |
212 | | // Rough estimate based on number of variables and their sizes |
213 | 0 | let mut total = 0; |
214 | | |
215 | 0 | for value in self.get_bindings().values() { |
216 | 0 | total += std::mem::size_of_val(value); |
217 | 0 | total += match value { |
218 | 0 | Value::String(s) => s.capacity(), |
219 | 0 | Value::List(items) => items.len() * std::mem::size_of::<Value>(), |
220 | 0 | Value::Object(map) => map.len() * (32 + std::mem::size_of::<Value>()), |
221 | 0 | Value::HashMap(map) => map.len() * (std::mem::size_of::<Value>() * 2), |
222 | 0 | _ => 0, |
223 | | }; |
224 | | } |
225 | | |
226 | 0 | total |
227 | 0 | } |
228 | | |
229 | | /// Estimate current stack depth (simplified) |
230 | 0 | fn estimate_stack_depth(&self) -> usize { |
231 | | // This is a placeholder - real implementation would track actual call stack |
232 | | // For now, we return a fixed estimate based on the number of bindings |
233 | 0 | self.get_bindings().len() / 10 + 1 |
234 | 0 | } |
235 | | |
236 | | /// Compute a hash of the current state for comparison |
237 | 0 | fn compute_state_hash(&self) -> String { |
238 | 0 | let mut hasher = Sha256::new(); |
239 | | |
240 | | // Sort variables by name for deterministic hashing |
241 | 0 | let mut sorted_vars: Vec<_> = self.get_bindings().iter().collect(); |
242 | 0 | sorted_vars.sort_by_key(|(name, _)| name.as_str()); |
243 | | |
244 | 0 | for (name, value) in sorted_vars { |
245 | 0 | hasher.update(name.as_bytes()); |
246 | 0 | hasher.update(b":"); |
247 | 0 | hasher.update(format!("{value:?}").as_bytes()); |
248 | 0 | hasher.update(b";"); |
249 | 0 | } |
250 | | |
251 | 0 | format!("{:x}", hasher.finalize()) |
252 | 0 | } |
253 | | } |
254 | | |
255 | | #[cfg(test)] |
256 | | mod tests { |
257 | | use super::*; |
258 | | |
259 | | #[test] |
260 | | fn test_deterministic_execution() { |
261 | | let mut repl1 = Repl::new().unwrap(); |
262 | | let mut repl2 = Repl::new().unwrap(); |
263 | | |
264 | | // Execute same commands with same seed |
265 | | let result1 = repl1.execute_with_seed("let x = 42", 12345); |
266 | | let result2 = repl2.execute_with_seed("let x = 42", 12345); |
267 | | |
268 | | // Results should be identical |
269 | | assert!(result1.output.is_ok()); |
270 | | assert!(result2.output.is_ok()); |
271 | | assert_eq!(result1.state_hash, result2.state_hash); |
272 | | } |
273 | | |
274 | | #[test] |
275 | | fn test_checkpoint_restore() { |
276 | | let mut repl = Repl::new().unwrap(); |
277 | | |
278 | | // Create some state |
279 | | repl.eval("let x = 10").unwrap(); |
280 | | repl.eval("let y = 20").unwrap(); |
281 | | |
282 | | // Create checkpoint using DeterministicRepl trait |
283 | | let checkpoint = DeterministicRepl::checkpoint(&repl); |
284 | | |
285 | | // Modify state |
286 | | repl.eval("let x = 99").unwrap(); |
287 | | repl.eval("let z = 30").unwrap(); |
288 | | |
289 | | // Restore checkpoint |
290 | | DeterministicRepl::restore(&mut repl, &checkpoint).unwrap(); |
291 | | |
292 | | // Check that state was restored |
293 | | // Note: Values are restored from debug format, so they may have different representation |
294 | | assert!(repl.eval("x").is_ok()); |
295 | | assert!(repl.eval("y").is_ok()); |
296 | | // z should not exist after restore |
297 | | assert!(repl.eval("z").is_err()); |
298 | | } |
299 | | |
300 | | #[test] |
301 | | fn test_determinism_validation() { |
302 | | let mut repl1 = Repl::new().unwrap(); |
303 | | let mut repl2 = Repl::new().unwrap(); |
304 | | |
305 | | // Same operations |
306 | | repl1.eval("let x = 1").unwrap(); |
307 | | repl2.eval("let x = 1").unwrap(); |
308 | | |
309 | | let validation = repl1.validate_determinism(&repl2); |
310 | | assert!(validation.is_deterministic); |
311 | | assert!(validation.divergences.is_empty()); |
312 | | |
313 | | // Different operations |
314 | | repl1.eval("let y = 2").unwrap(); |
315 | | repl2.eval("let y = 3").unwrap(); |
316 | | |
317 | | let validation = repl1.validate_determinism(&repl2); |
318 | | assert!(!validation.is_deterministic); |
319 | | assert!(!validation.divergences.is_empty()); |
320 | | } |
321 | | } |