alkahest_cas/kernel/depth.rs
1//! The expression-depth ceiling that keeps deep trees from killing the process.
2//!
3//! # Why this exists
4//!
5//! Almost every operation on an expression is a structural recursion over the
6//! DAG: printing, simplification, differentiation, substitution, translation to
7//! Lean or SMT-LIB, evaluation. Each level of the expression costs one or more
8//! native stack frames, and a native stack overflow is **not** an exception —
9//! the kernel delivers `SIGSEGV` and the process dies with no traceback, no
10//! error code, and nothing for a caller's `except Exception` to catch. For an
11//! unattended run that is strictly worse than a wrong answer: a wrong answer
12//! can be logged.
13//!
14//! Measured by bisection on the shipped release build with the usual 8 MiB
15//! main-thread stack (`ulimit -s 8192`), on a chain of `sin` applications:
16//!
17//! | operation | deepest that returned | first that segfaulted |
18//! |---|---|---|
19//! | `symbolic_grad` (reverse-mode DFS) | 4 625 | 4 687 |
20//! | `simplify`, `to_lean` | 9 216 | 9 472 |
21//! | `latex` | 13 312 | 13 824 |
22//! | `unicode_str` | 15 360 | 15 872 |
23//! | `str` / `repr` | 23 552 | 24 576 |
24//!
25//! [`MAX_EXPR_DEPTH`] is set below the worst of those with room to spare, so
26//! that every consumer refuses before any of them overflows, and one number
27//! covers all of them instead of each walker carrying its own.
28//!
29//! The ceiling is calibrated for the **shipped release build on an 8 MiB
30//! stack**, which is what a Python caller gets on the main thread. A debug
31//! build has frames several times larger, and a `cargo test` worker or a Rayon
32//! worker has a 2 MiB stack, so those configurations can still overflow below
33//! this limit; a test that means to reach the cap should run on a thread it
34//! sized itself. (`simplify_par` already handles the Rayon case by hopping to
35//! a thread with a stack it sized itself — see `simplify::parallel`, which is
36//! only compiled with the `parallel` feature.)
37//!
38//! # How it is enforced
39//!
40//! [`ExprPool`] caches each node's depth at intern time, so
41//! [`check_expr_depth`] is a single array read and an integer compare. That
42//! matters: the guard sits on hot paths such as `__str__`, and anything that
43//! had to walk the tree to find its depth would cost more than it saves.
44//!
45//! # What a caller should do about a refusal
46//!
47//! [`DepthLimitError`] is a normal, catchable, coded error (`E-DEPTH-001`).
48//! Rebuild the expression with less nesting — a balanced `Add` of 100 000 terms
49//! has depth 2, while the same terms accumulated one at a time with `+` have
50//! depth 100 000 — or split the work into subexpressions.
51
52use crate::errors::AlkahestError;
53use crate::kernel::{ExprId, ExprPool};
54use std::fmt;
55
56/// Deepest expression any recursive consumer will accept.
57///
58/// See the module documentation for the measurements behind this number. The
59/// shallowest walker to fall over did so at depth 4 687 on an 8 MiB stack, so
60/// this leaves a factor of ~2.3 for stacks that already have frames on them,
61/// for debug builds (whose frames are several times larger than release ones),
62/// and for future walkers that use more stack per level than today's.
63///
64/// It is deliberately *one* number rather than a per-operation table: a caller
65/// that gets `str(expr)` to work should not then be surprised by a segfault
66/// from `symbolic_grad(expr)`, and a walker added later inherits the guard
67/// instead of having to remember to measure itself.
68pub const MAX_EXPR_DEPTH: u32 = 2048;
69
70/// An expression was too deeply nested to be processed by recursion.
71///
72/// Returned rather than risking a stack overflow; see the [module
73/// documentation](self).
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct DepthLimitError {
76 /// Depth of the offending expression, saturating at [`u32::MAX`].
77 pub depth: u32,
78 /// The ceiling that was exceeded — always [`MAX_EXPR_DEPTH`] today.
79 pub limit: u32,
80}
81
82impl fmt::Display for DepthLimitError {
83 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84 write!(
85 f,
86 "expression nesting depth {} exceeds the limit of {}; \
87 recursing over it would overflow the stack",
88 self.depth, self.limit
89 )
90 }
91}
92
93impl std::error::Error for DepthLimitError {}
94
95impl AlkahestError for DepthLimitError {
96 fn code(&self) -> &'static str {
97 "E-DEPTH-001"
98 }
99
100 fn remediation(&self) -> Option<&'static str> {
101 Some("rebuild the expression with less nesting (a balanced n-ary Add is shallow where a chain of binary ones is not), or process it in smaller pieces")
102 }
103}
104
105/// Refuse `id` if recursing over it would risk a stack overflow.
106///
107/// O(1) — the depth was cached when `id` was interned. Call this at the entry
108/// point of anything that walks an expression recursively; see the [module
109/// documentation](self) for why.
110///
111/// ```
112/// use alkahest_cas::kernel::depth::{check_expr_depth, MAX_EXPR_DEPTH};
113/// use alkahest_cas::kernel::{Domain, ExprPool};
114///
115/// let pool = ExprPool::new();
116/// let x = pool.symbol("x", Domain::Real);
117/// assert!(check_expr_depth(&pool, x).is_ok());
118///
119/// let mut deep = x;
120/// for _ in 0..MAX_EXPR_DEPTH {
121/// deep = pool.func("sin", vec![deep]);
122/// }
123/// let err = check_expr_depth(&pool, deep).unwrap_err();
124/// assert_eq!(err.limit, MAX_EXPR_DEPTH);
125/// ```
126pub fn check_expr_depth(pool: &ExprPool, id: ExprId) -> Result<(), DepthLimitError> {
127 let depth = pool.depth(id);
128 if depth > MAX_EXPR_DEPTH {
129 Err(DepthLimitError {
130 depth,
131 limit: MAX_EXPR_DEPTH,
132 })
133 } else {
134 Ok(())
135 }
136}
137
138/// Like [`check_expr_depth`] but for a batch of expressions.
139///
140/// Reports the first offender, so a caller handed a hundred expressions does
141/// not have to find the bad one itself.
142pub fn check_expr_depths(pool: &ExprPool, ids: &[ExprId]) -> Result<(), DepthLimitError> {
143 for &id in ids {
144 check_expr_depth(pool, id)?;
145 }
146 Ok(())
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use crate::kernel::Domain;
153
154 /// Depth is the *longest* root-to-leaf path, and hash-consing must not
155 /// confuse it: `sin(x) + x` is 3 (Add → Func → Symbol), not 2.
156 #[test]
157 fn depth_is_the_longest_path_not_the_shortest() {
158 let pool = ExprPool::new();
159 let x = pool.symbol("x", Domain::Real);
160 assert_eq!(pool.depth(x), 1);
161 let s = pool.func("sin", vec![x]);
162 assert_eq!(pool.depth(s), 2);
163 let sum = pool.add(vec![s, x]);
164 assert_eq!(pool.depth(sum), 3);
165 }
166
167 /// A wide expression is shallow; the guard must not confuse size with
168 /// depth, or `check_expr_depth` would reject perfectly printable inputs.
169 #[test]
170 fn width_does_not_count_towards_depth() {
171 let pool = ExprPool::new();
172 let terms: Vec<_> = (0..10_000).map(|i| pool.integer(i)).collect();
173 let wide = pool.add(terms);
174 assert_eq!(pool.depth(wide), 2);
175 assert!(check_expr_depth(&pool, wide).is_ok());
176 }
177
178 /// The same terms accumulated pairwise are deep, and that is exactly the
179 /// shape that used to segfault every printer.
180 #[test]
181 fn a_chain_of_binary_adds_is_refused_past_the_limit() {
182 let pool = ExprPool::new();
183 let x = pool.symbol("x", Domain::Real);
184 let mut acc = x;
185 for i in 0..MAX_EXPR_DEPTH {
186 let k = pool.integer(i);
187 acc = pool.add(vec![acc, k]);
188 }
189 assert_eq!(pool.depth(acc), MAX_EXPR_DEPTH + 1);
190 let err = check_expr_depth(&pool, acc).expect_err("one past the limit must be refused");
191 assert_eq!(err.depth, MAX_EXPR_DEPTH + 1);
192 assert_eq!(err.code(), "E-DEPTH-001");
193 }
194
195 /// Exactly at the limit is accepted — the boundary is inclusive, so the
196 /// documented number is the deepest expression that still works.
197 #[test]
198 fn the_limit_itself_is_accepted() {
199 let pool = ExprPool::new();
200 let x = pool.symbol("x", Domain::Real);
201 let mut acc = x;
202 for _ in 1..MAX_EXPR_DEPTH {
203 acc = pool.func("sin", vec![acc]);
204 }
205 assert_eq!(pool.depth(acc), MAX_EXPR_DEPTH);
206 assert!(check_expr_depth(&pool, acc).is_ok());
207 }
208
209 #[test]
210 fn batch_check_reports_the_first_offender() {
211 let pool = ExprPool::new();
212 let x = pool.symbol("x", Domain::Real);
213 let mut deep = x;
214 for _ in 0..=MAX_EXPR_DEPTH {
215 deep = pool.func("sin", vec![deep]);
216 }
217 assert!(check_expr_depths(&pool, &[x, x]).is_ok());
218 assert!(check_expr_depths(&pool, &[x, deep, x]).is_err());
219 }
220}