Skip to main content

proof_engine/math/
scripted.rs

1//! Dynamical systems written in a script, reloaded while the program runs.
2//!
3//! The built-in [`AttractorType`](super::AttractorType)s are compiled in.
4//! A [`ScriptedSystem`] is the same idea with the equations in a
5//! [Rhai](https://rhai.rs) script instead, so you can try a new system, or
6//! retune sigma and rho, without recompiling, and see it change as soon as
7//! the file is saved.
8//!
9//! A script sees the state as `x`, `y`, `z` and the time as `t`, all
10//! floats, and ends with the derivative as a three element array:
11//!
12//! ```text
13//! // Lorenz
14//! let sigma = 10.0;
15//! let rho = 28.0;
16//! let beta = 8.0 / 3.0;
17//! [sigma * (y - x), x * (rho - z) - y, x * y - beta * z]
18//! ```
19//!
20//! Rhai's standard maths is available (`sin`, `cos`, `exp`, `sqrt`, `abs`,
21//! `PI()` and so on). Integer division is integer division: write `8.0 / 3.0`,
22//! not `8 / 3`. Every evaluation is capped at a fixed number of operations,
23//! so a script with an endless loop returns an error instead of freezing
24//! the frame.
25//!
26//! ```rust
27//! use proof_engine::math::scripted::ScriptedSystem;
28//! use proof_engine::prelude::Vec3;
29//!
30//! let thomas = ScriptedSystem::compile("
31//!     let b = 0.208186;
32//!     [sin(y) - b * x, sin(z) - b * y, sin(x) - b * z]
33//! ").unwrap();
34//! let mut p = Vec3::new(0.1, 0.0, 0.0);
35//! for i in 0..1000 {
36//!     p = thomas.rk4_step(p, i as f32 * 0.05, 0.05).unwrap();
37//! }
38//! assert!(p.length() < 6.0, "Thomas' attractor is bounded");
39//! ```
40
41use std::fmt;
42use std::path::{Path, PathBuf};
43use std::time::SystemTime;
44
45use glam::Vec3;
46use rhai::{Dynamic, Engine, Scope, AST};
47
48/// Operations one evaluation may take before it is stopped.
49const MAX_OPERATIONS: u64 = 50_000;
50
51/// A script that failed to load, compile or run.
52#[derive(Debug, Clone, PartialEq)]
53pub struct ScriptedError {
54    /// What went wrong, with the line and column when Rhai knows them.
55    pub message: String,
56}
57
58impl fmt::Display for ScriptedError {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        f.write_str(&self.message)
61    }
62}
63
64impl std::error::Error for ScriptedError {}
65
66fn err(message: impl Into<String>) -> ScriptedError {
67    ScriptedError { message: message.into() }
68}
69
70/// A three-dimensional ODE `dp/dt = f(p, t)` whose `f` is a Rhai script.
71pub struct ScriptedSystem {
72    engine: Engine,
73    ast: AST,
74    source: String,
75    file: Option<(PathBuf, Option<SystemTime>)>,
76}
77
78impl fmt::Debug for ScriptedSystem {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        f.debug_struct("ScriptedSystem")
81            .field("file", &self.file.as_ref().map(|(p, _)| p))
82            .field("source", &self.source)
83            .finish()
84    }
85}
86
87fn make_engine() -> Engine {
88    let mut engine = Engine::new();
89    engine.set_max_operations(MAX_OPERATIONS);
90    engine.set_max_expr_depths(64, 32);
91    engine.set_max_string_size(4096);
92    engine.set_max_array_size(1024);
93    // print and debug go to the log instead of stdout.
94    engine.on_print(|s| log::info!("script: {s}"));
95    engine.on_debug(|s, _, pos| log::debug!("script {pos}: {s}"));
96    engine
97}
98
99fn mtime(path: &Path) -> Option<SystemTime> {
100    std::fs::metadata(path).and_then(|m| m.modified()).ok()
101}
102
103fn number(v: &Dynamic, i: usize) -> Result<f32, ScriptedError> {
104    if let Ok(f) = v.as_float() {
105        Ok(f as f32)
106    } else if let Ok(n) = v.as_int() {
107        Ok(n as f32)
108    } else {
109        Err(err(format!("element {i} of the result is a {}, not a number", v.type_name())))
110    }
111}
112
113impl ScriptedSystem {
114    /// Compile a script from source text.
115    pub fn compile(source: &str) -> Result<Self, ScriptedError> {
116        let engine = make_engine();
117        let ast = engine.compile(source).map_err(|e| err(format!("compile error: {e}")))?;
118        let sys = Self { engine, ast, source: source.to_string(), file: None };
119        // Run it once at the origin so a script that compiles but cannot
120        // produce a vector is caught here, not on the first frame.
121        sys.derivative(Vec3::ZERO, 0.0)?;
122        Ok(sys)
123    }
124
125    /// Load and compile a script file, remembering the path for
126    /// [`reload_if_changed`](Self::reload_if_changed).
127    pub fn load(path: impl AsRef<Path>) -> Result<Self, ScriptedError> {
128        let path = path.as_ref();
129        let source = std::fs::read_to_string(path)
130            .map_err(|e| err(format!("could not read {}: {e}", path.display())))?;
131        let mut sys = Self::compile(&source).map_err(|e| err(format!("{}: {e}", path.display())))?;
132        sys.file = Some((path.to_path_buf(), mtime(path)));
133        Ok(sys)
134    }
135
136    /// The source the system is running.
137    pub fn source(&self) -> &str {
138        &self.source
139    }
140
141    /// The file it was loaded from, if any.
142    pub fn path(&self) -> Option<&Path> {
143        self.file.as_ref().map(|(p, _)| p.as_path())
144    }
145
146    /// Recompile from the file if it changed on disk since the last load.
147    ///
148    /// Returns `Ok(true)` when the new script is now running and `Ok(false)`
149    /// when nothing changed. If the new version does not compile, or does
150    /// not return a vector, the error comes back and the previous script
151    /// keeps running, so a half-typed edit never stops the animation.
152    pub fn reload_if_changed(&mut self) -> Result<bool, ScriptedError> {
153        let Some((path, seen)) = self.file.clone() else { return Ok(false) };
154        let now = mtime(&path);
155        if now.is_none() || now == seen {
156            return Ok(false);
157        }
158        // Remember this version even if it fails, so a broken save is
159        // reported once rather than every frame.
160        if let Some((_, t)) = self.file.as_mut() {
161            *t = now;
162        }
163        let fresh = Self::load(&path)?;
164        self.ast = fresh.ast;
165        self.source = fresh.source;
166        Ok(true)
167    }
168
169    /// `dp/dt` at state `p` and time `t`.
170    pub fn derivative(&self, p: Vec3, t: f32) -> Result<Vec3, ScriptedError> {
171        let mut scope = Scope::new();
172        scope.push("x", p.x as f64);
173        scope.push("y", p.y as f64);
174        scope.push("z", p.z as f64);
175        scope.push("t", t as f64);
176        let out: Dynamic = self
177            .engine
178            .eval_ast_with_scope(&mut scope, &self.ast)
179            .map_err(|e| err(format!("runtime error: {e}")))?;
180        let arr = out
181            .into_typed_array::<Dynamic>()
182            .map_err(|ty| err(format!("the script must end with [dx, dy, dz], got a {ty}")))?;
183        if arr.len() != 3 {
184            return Err(err(format!("the script must end with [dx, dy, dz], got {} elements", arr.len())));
185        }
186        let v = Vec3::new(number(&arr[0], 0)?, number(&arr[1], 1)?, number(&arr[2], 2)?);
187        if !v.is_finite() {
188            return Err(err(format!("the derivative at {p} is not finite: {v}")));
189        }
190        Ok(v)
191    }
192
193    /// One classic fourth-order Runge-Kutta step of size `dt` from time `t`,
194    /// the same integrator as [`attractors::rk4_step`](super::attractors::rk4_step).
195    pub fn rk4_step(&self, p: Vec3, t: f32, dt: f32) -> Result<Vec3, ScriptedError> {
196        let h = dt * 0.5;
197        let k1 = self.derivative(p, t)?;
198        let k2 = self.derivative(p + k1 * h, t + h)?;
199        let k3 = self.derivative(p + k2 * h, t + h)?;
200        let k4 = self.derivative(p + k3 * dt, t + dt)?;
201        Ok(p + (k1 + k2 * 2.0 + k3 * 2.0 + k4) * (dt / 6.0))
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::math::attractors::{rk4_step, AttractorType};
209
210    const LORENZ: &str = "
211        let sigma = 10.0;
212        let rho = 28.0;
213        let beta = 8.0 / 3.0;
214        [sigma * (y - x), x * (rho - z) - y, x * y - beta * z]
215    ";
216
217    #[test]
218    fn a_scripted_lorenz_tracks_the_built_in_one() {
219        let sys = ScriptedSystem::compile(LORENZ).unwrap();
220        let (mut a, mut b) = (Vec3::new(1.0, 1.0, 1.0), Vec3::new(1.0, 1.0, 1.0));
221        for i in 0..200 {
222            a = sys.rk4_step(a, i as f32 * 0.005, 0.005).unwrap();
223            b = rk4_step(AttractorType::Lorenz, b, 0.005);
224        }
225        // Same equations, same integrator: the trajectories agree until
226        // f32 rounding (scripts compute in f64) is amplified by chaos.
227        assert!((a - b).length() < 1e-2, "{a} vs {b}");
228    }
229
230    #[test]
231    fn ints_floats_maths_and_time_all_work() {
232        let sys = ScriptedSystem::compile("[1, 2.5 * x, sin(t) + PI() * 0.0]").unwrap();
233        let d = sys.derivative(Vec3::new(2.0, 0.0, 0.0), std::f32::consts::FRAC_PI_2).unwrap();
234        assert_eq!(d, Vec3::new(1.0, 5.0, 1.0));
235    }
236
237    #[test]
238    fn bad_scripts_are_errors_not_panics() {
239        for (src, needle) in [
240            ("[x, y", "compile error"),
241            ("x + y", "must end with"),
242            ("[x, y]", "got 2 elements"),
243            ("[x, \"y\", z]", "not a number"),
244            ("[undefined_var, y, z]", "runtime error"),
245            ("loop { }", "runtime error"),
246            ("[1.0 / 0.0, 0.0, 0.0]", "not finite"),
247        ] {
248            let e = ScriptedSystem::compile(src).unwrap_err();
249            assert!(e.message.contains(needle), "{src:?}: {}", e.message);
250        }
251    }
252
253    #[test]
254    fn hot_reload_swaps_in_new_equations_and_survives_broken_edits() {
255        let dir = std::env::temp_dir().join("proof_engine_scripted_tests");
256        std::fs::create_dir_all(&dir).unwrap();
257        let path = dir.join("field.rhai");
258        std::fs::write(&path, "[1.0, 0.0, 0.0]").unwrap();
259        let mut sys = ScriptedSystem::load(&path).unwrap();
260        assert_eq!(sys.path(), Some(path.as_path()));
261        assert_eq!(sys.reload_if_changed(), Ok(false));
262
263        // File systems with coarse timestamps need the clock to move on.
264        let bump = |p: &Path| {
265            let f = std::fs::File::options().write(true).open(p).unwrap();
266            let later = mtime(p).unwrap() + std::time::Duration::from_secs(2);
267            f.set_modified(later).unwrap();
268        };
269
270        std::fs::write(&path, "[0.0, 2.0, 0.0]").unwrap();
271        bump(&path);
272        assert_eq!(sys.reload_if_changed(), Ok(true));
273        assert_eq!(sys.derivative(Vec3::ZERO, 0.0).unwrap(), Vec3::new(0.0, 2.0, 0.0));
274
275        // A half-typed edit is reported once and the old script keeps going.
276        std::fs::write(&path, "[0.0, 3.0,").unwrap();
277        bump(&path);
278        assert!(sys.reload_if_changed().is_err());
279        assert_eq!(sys.reload_if_changed(), Ok(false), "the broken version is not retried every frame");
280        assert_eq!(sys.derivative(Vec3::ZERO, 0.0).unwrap(), Vec3::new(0.0, 2.0, 0.0));
281    }
282}