Skip to content

Commit 13c0d20

Browse files
isPANNclaude
andcommitted
Cap canonical-form expansion to fix #1069 OOM/hang in big_o
Composed-path overheads that traverse quadratic-overhead reductions (e.g. QuadraticAssignment) form nested (sum)^2*(sum)^2 expressions. canonical_form() expanded these into sum-of-monomials, which is exponential in nesting depth, blowing up to multi-GB and OOM/hanging `pred path --all`. On CI this SIGTERM-killed the Test job (exit 143) via the integration test test_path_all_max_paths_truncates. Add a hard cap (MAX_CANONICAL_TERMS) on the intermediate term count, checked before the Cartesian product is materialized. On overflow, expansion is abandoned with CanonicalizationError::Unsupported; the existing big_o_of fallback then prints the compact, un-expanded expression instead of hanging. Paths are still enumerated and shown — only the Big-O rendering of pathological paths degrades to un-simplified form. Stopgap until the symbolic system is reworked to derive Big-O structurally without full expansion. Regression tests: - canonical: nested-power blowup returns Unsupported; moderate power still expands normally (cap does not perturb legitimate exprs) - big_o: pathological nesting errors instead of hanging Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent babfd49 commit 13c0d20

4 files changed

Lines changed: 69 additions & 4 deletions

File tree

src/canonical.rs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ use std::collections::BTreeMap;
77

88
use crate::expr::{CanonicalizationError, Expr};
99

10+
/// Hard cap on the number of additive terms produced while expanding an
11+
/// expression into canonical sum-of-monomials form.
12+
///
13+
/// Expanding a nested `(sum)^2 * (sum)^2` structure is exponential in nesting
14+
/// depth: composed-path overheads that traverse quadratic-overhead reductions
15+
/// (e.g. `QuadraticAssignment`) blow up to multi-GB of monomials and OOM/hang.
16+
/// When the intermediate term count would exceed this cap we abandon expansion
17+
/// and report the expression as `Unsupported`; callers (e.g. `big_o_of`) fall
18+
/// back to printing the compact, un-expanded expression. See issue #1069.
19+
///
20+
/// Legitimate overhead expressions stay far below this bound (the worst
21+
/// non-pathological case is a few hundred terms), so this never affects normal
22+
/// output — it only stops pathological blowups. This is a stopgap guard; the
23+
/// symbolic system is slated for a larger rework.
24+
const MAX_CANONICAL_TERMS: usize = 50_000;
25+
1026
/// An opaque non-polynomial factor (exp, log, fractional-power base).
1127
///
1228
/// Stored by its canonical string representation for deterministic ordering.
@@ -184,6 +200,19 @@ impl CanonicalSum {
184200
CanonicalSum { terms }
185201
}
186202

203+
/// Multiply with a guard against pathological expansion (see
204+
/// [`MAX_CANONICAL_TERMS`]). The Cartesian product size is checked *before*
205+
/// it is materialized, so this never allocates the blown-up vector.
206+
fn try_mul(&self, other: &CanonicalSum) -> Result<CanonicalSum, CanonicalizationError> {
207+
let product = self.terms.len().saturating_mul(other.terms.len());
208+
if product > MAX_CANONICAL_TERMS {
209+
return Err(CanonicalizationError::Unsupported(format!(
210+
"expression too large to canonicalize ({product} terms exceeds cap of {MAX_CANONICAL_TERMS})"
211+
)));
212+
}
213+
Ok(self.mul(other))
214+
}
215+
187216
/// Merge terms with the same signature and drop zero-coefficient terms.
188217
/// Sort the result deterministically.
189218
fn simplify(self) -> Self {
@@ -238,7 +267,7 @@ fn expr_to_canonical(expr: &Expr) -> Result<CanonicalSum, CanonicalizationError>
238267
Expr::Mul(a, b) => {
239268
let ca = expr_to_canonical(a)?;
240269
let cb = expr_to_canonical(b)?;
241-
Ok(ca.mul(&cb))
270+
ca.try_mul(&cb)
242271
}
243272
Expr::Pow(base, exp) => canonicalize_pow(base, exp),
244273
Expr::Exp(arg) => {
@@ -300,7 +329,7 @@ fn canonicalize_pow(base: &Expr, exp: &Expr) -> Result<CanonicalSum, Canonicaliz
300329
}
301330
let mut result = base_sum.clone();
302331
for _ in 1..n {
303-
result = result.mul(&base_sum);
332+
result = result.try_mul(&base_sum)?;
304333
}
305334
Ok(result)
306335
} else {

src/models/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ pub use algebraic::{
1515
ConsecutiveOnesMatrixAugmentation, ConsecutiveOnesSubmatrix, EquilibriumPoint,
1616
FeasibleBasisExtension, MinimumMatrixCover, MinimumMatrixDomination, MinimumWeightDecoding,
1717
MinimumWeightSolutionToLinearEquations, QuadraticAssignment, QuadraticCongruences,
18-
QuadraticDiophantineEquations, SimultaneousIncongruences,
19-
SparseMatrixCompression, BMF, ILP, QUBO,
18+
QuadraticDiophantineEquations, SimultaneousIncongruences, SparseMatrixCompression, BMF, ILP,
19+
QUBO,
2020
};
2121
pub use decision::Decision;
2222
pub use formula::{

src/unit_tests/big_o.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,3 +217,13 @@ fn test_big_o_multivar_exp_dominates_poly() {
217217
"expected n*m to survive (different var set), got: {s}"
218218
);
219219
}
220+
221+
#[test]
222+
fn test_big_o_pathological_nesting_errors_instead_of_hanging() {
223+
// Regression for issue #1069: a deeply-nested power that expands
224+
// exponentially must return an error promptly (so callers like `big_o_of`
225+
// fall back to the un-expanded expression) rather than OOM/hang.
226+
let sum = Expr::Var("a") + Expr::Var("b") + Expr::Var("c") + Expr::Var("d");
227+
let e = Expr::pow(Expr::pow(sum, Expr::Const(4.0)), Expr::Const(4.0));
228+
assert!(big_o_normal_form(&e).is_err());
229+
}

src/unit_tests/canonical.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,29 @@ fn test_canonical_sqrt_as_power() {
137137
let b = canonical_form(&Expr::pow(Expr::Var("n"), Expr::Const(0.5))).unwrap();
138138
assert_eq!(a.to_string(), b.to_string());
139139
}
140+
141+
#[test]
142+
fn test_canonical_nested_power_blowup_is_capped() {
143+
// Regression for issue #1069: a "square of a square of a sum" structure —
144+
// the shape composed-path overheads take when they traverse
145+
// quadratic-overhead reductions — expands exponentially. Before the cap
146+
// this OOM'd / hung indefinitely; now it must fail fast with Unsupported
147+
// rather than try to materialize the blown-up monomial expansion.
148+
let sum = Expr::Var("a") + Expr::Var("b") + Expr::Var("c") + Expr::Var("d");
149+
// ((a+b+c+d)^4)^4 expands to >50_000 intermediate terms.
150+
let e = Expr::pow(Expr::pow(sum, Expr::Const(4.0)), Expr::Const(4.0));
151+
let err = canonical_form(&e).unwrap_err();
152+
assert!(matches!(err, CanonicalizationError::Unsupported(_)));
153+
}
154+
155+
#[test]
156+
fn test_canonical_moderate_power_still_expands() {
157+
// The cap must not perturb legitimate, modestly-sized expressions:
158+
// (a+b)^3 stays well under the cap and expands normally.
159+
let e = Expr::pow(Expr::Var("a") + Expr::Var("b"), Expr::Const(3.0));
160+
let c = canonical_form(&e).unwrap();
161+
// a^3 + 3 a^2 b + 3 a b^2 + b^3 — compare against the same expansion
162+
// written out flat (both go through canonical_form for identical ordering).
163+
let expected = canonical_form(&Expr::parse("a^3 + 3*a^2*b + 3*a*b^2 + b^3")).unwrap();
164+
assert_eq!(c.to_string(), expected.to_string());
165+
}

0 commit comments

Comments
 (0)