Add linearization solver for [-1,1] coefficient templates#13
Add linearization solver for [-1,1] coefficient templates#13moratorium08 wants to merge 8 commits intomainfrom
Conversation
Implement a linearization-based solver that converts polynomial
constraints into pure LIA constraints for efficient SMT solving
when the number of coefficient variables exceeds the blasting
threshold (>10 variables).
Key changes:
- Add polynomial expansion (distribute multiplication over addition)
- Add cubic simplification (c³ = c for c ∈ {-1, 0, 1})
- Add LinearizationContext for managing auxiliary variables
- Add linearize_term to replace products with aux vars
- Add solve_by_linearization as alternative to solve_by_blasting
- Add --force-linearization CLI flag and HOICE_FORCE_LINEARIZATION env var
- Add 29 unit tests covering expansion, linearization, and edge cases
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR implements a linearization-based solver for polynomial constraints involving coefficient template variables in the range [-1, 1]. The solver is designed as an alternative to the existing bit-blasting approach when the number of coefficient variables exceeds 10, converting polynomial constraints with products into Linear Integer Arithmetic (LIA) constraints by introducing auxiliary variables.
Key changes:
- Adds polynomial expansion with cubic simplification (c³ = c for c ∈ {-1, 0, 1})
- Introduces auxiliary variables for products (c² → c2, c*d → c_d) with appropriate constraints
- Provides
--force-linearizationCLI flag for debugging/testing
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| src/common/config.rs | Adds force_linearization configuration field and CLI argument with environment variable support (HOICE_FORCE_LINEARIZATION) |
| src/absadt/learn.rs | Implements polynomial expansion, linearization logic with auxiliary variable management, solver integration, and 29 unit tests for expansion and linearization functionality |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/absadt/learn.rs
Outdated
| let mut processed: HashSet<VarIdx> = HashSet::new(); | ||
| for (&idx, &count) in &counts { | ||
| if count >= 2 { | ||
| // c² → c2 (squared variable) | ||
| let c2 = ctx.get_or_create_squared(idx); | ||
| result_coef_terms.push(term::var(c2, typ::int())); | ||
| processed.insert(idx); |
There was a problem hiding this comment.
The variable processed is created and populated but never read. It appears to be intended to track which coefficient variables have been handled as squared terms, but this information isn't used. Consider removing this unused variable to improve code clarity.
| let mut processed: HashSet<VarIdx> = HashSet::new(); | |
| for (&idx, &count) in &counts { | |
| if count >= 2 { | |
| // c² → c2 (squared variable) | |
| let c2 = ctx.get_or_create_squared(idx); | |
| result_coef_terms.push(term::var(c2, typ::int())); | |
| processed.insert(idx); | |
| for (&idx, &count) in &counts { | |
| if count >= 2 { | |
| // c² → c2 (squared variable) | |
| let c2 = ctx.get_or_create_squared(idx); | |
| result_coef_terms.push(term::var(c2, typ::int())); |
src/absadt/learn.rs
Outdated
| let (sub_op, _) = arg.app_inspect().expect("expected an App"); | ||
| assert_eq!(sub_op, term::Op::Or); | ||
| } | ||
| } |
There was a problem hiding this comment.
Missing test coverage for linearization of products with three or more distinct coefficient variables (e.g., cde). The existing tests cover ccd (squared plus single) but not the case where all variables are distinct. This would help validate whether the implementation correctly handles N-ary products.
src/absadt/learn.rs
Outdated
| // Handle squared terms (c² → c2) | ||
| let mut processed: HashSet<VarIdx> = HashSet::new(); | ||
| for (&idx, &count) in &counts { | ||
| if count >= 2 { | ||
| // c² → c2 (squared variable) | ||
| let c2 = ctx.get_or_create_squared(idx); | ||
| result_coef_terms.push(term::var(c2, typ::int())); | ||
| processed.insert(idx); | ||
| } | ||
| } | ||
|
|
||
| // For single occurrences, we need to handle products | ||
| let single_vars: Vec<VarIdx> = counts | ||
| .iter() | ||
| .filter(|(_, &c)| c == 1) | ||
| .map(|(&idx, _)| idx) | ||
| .collect(); | ||
|
|
||
| if single_vars.len() >= 2 { | ||
| // Create product variables for pairs | ||
| let mut i = 0; | ||
| while i + 1 < single_vars.len() { | ||
| let c = single_vars[i]; | ||
| let d = single_vars[i + 1]; | ||
| let c_d = ctx.get_or_create_product(c, d); | ||
| result_coef_terms.push(term::var(c_d, typ::int())); | ||
| i += 2; | ||
| } | ||
| // If odd number, keep the last one | ||
| if i < single_vars.len() { | ||
| result_coef_terms.push(term::var(single_vars[i], typ::int())); | ||
| } | ||
| } else if single_vars.len() == 1 { | ||
| result_coef_terms.push(term::var(single_vars[0], typ::int())); |
There was a problem hiding this comment.
The pairwise linearization approach for products of more than two coefficient variables may produce non-linear results. For example, with three coefficient variables c, d, e appearing as cde, the code creates c_d for cd and keeps e separate, resulting in c_de. However, c_d is itself a coefficient-like variable (constrained to {-1,0,1}), so c_d*e remains a non-linear product of two coefficient variables. Consider either: (1) recursively applying linearization until all products involve at most one coefficient variable, or (2) handling N-ary products by creating auxiliary variables for all combinations, not just pairs.
| // Handle squared terms (c² → c2) | |
| let mut processed: HashSet<VarIdx> = HashSet::new(); | |
| for (&idx, &count) in &counts { | |
| if count >= 2 { | |
| // c² → c2 (squared variable) | |
| let c2 = ctx.get_or_create_squared(idx); | |
| result_coef_terms.push(term::var(c2, typ::int())); | |
| processed.insert(idx); | |
| } | |
| } | |
| // For single occurrences, we need to handle products | |
| let single_vars: Vec<VarIdx> = counts | |
| .iter() | |
| .filter(|(_, &c)| c == 1) | |
| .map(|(&idx, _)| idx) | |
| .collect(); | |
| if single_vars.len() >= 2 { | |
| // Create product variables for pairs | |
| let mut i = 0; | |
| while i + 1 < single_vars.len() { | |
| let c = single_vars[i]; | |
| let d = single_vars[i + 1]; | |
| let c_d = ctx.get_or_create_product(c, d); | |
| result_coef_terms.push(term::var(c_d, typ::int())); | |
| i += 2; | |
| } | |
| // If odd number, keep the last one | |
| if i < single_vars.len() { | |
| result_coef_terms.push(term::var(single_vars[i], typ::int())); | |
| } | |
| } else if single_vars.len() == 1 { | |
| result_coef_terms.push(term::var(single_vars[0], typ::int())); | |
| // Collect all coefficient-like variables arising in this product: | |
| // - squared terms (c² → c2), and | |
| // - singly-occurring coefficient variables. | |
| let mut coef_like_vars: Vec<VarIdx> = Vec::new(); | |
| // Handle squared terms (c² → c2) | |
| for (&idx, &count) in &counts { | |
| if count >= 2 { | |
| // c² → c2 (squared variable) | |
| let c2 = ctx.get_or_create_squared(idx); | |
| coef_like_vars.push(c2); | |
| } | |
| } | |
| // For single occurrences, aggregate them into a single product variable | |
| let single_vars: Vec<VarIdx> = counts | |
| .iter() | |
| .filter(|(_, &c)| c == 1) | |
| .map(|(&idx, _)| idx) | |
| .collect(); | |
| if !single_vars.is_empty() { | |
| // Chain all single-occurrence coefficients into one auxiliary product | |
| let mut acc = single_vars[0]; | |
| for &v in single_vars.iter().skip(1) { | |
| acc = ctx.get_or_create_product(acc, v); | |
| } | |
| coef_like_vars.push(acc); | |
| } | |
| // Finally, combine all coefficient-like variables into at most one term | |
| if !coef_like_vars.is_empty() { | |
| let mut acc = coef_like_vars[0]; | |
| for &v in coef_like_vars.iter().skip(1) { | |
| acc = ctx.get_or_create_product(acc, v); | |
| } | |
| result_coef_terms.push(term::var(acc, typ::int())); |
src/absadt/learn.rs
Outdated
| term::RTerm::CArray { term: inner, .. } => { | ||
| // CArray is rare in this context, just recurse into the inner term | ||
| // and reconstruct if needed. For now, return original if structure matches. | ||
| let new_inner = linearize_term(inner, ctx, coef_vars); | ||
| if new_inner == *inner { | ||
| term.clone() | ||
| } else { | ||
| // Create a new CArray - this is an edge case not expected in coefficient templates | ||
| term.clone() // Simplified: just return the original | ||
| } |
There was a problem hiding this comment.
The CArray case returns the original term even after recursively processing the inner term (when new_inner != inner). This means transformations applied to the inner term are discarded. Either properly reconstruct the CArray with the new inner term, or if CArray reconstruction is not feasible, add a comment explaining why this case is expected to be unreachable in coefficient templates.
| term::RTerm::CArray { term: inner, .. } => { | |
| // CArray is rare in this context, just recurse into the inner term | |
| // and reconstruct if needed. For now, return original if structure matches. | |
| let new_inner = linearize_term(inner, ctx, coef_vars); | |
| if new_inner == *inner { | |
| term.clone() | |
| } else { | |
| // Create a new CArray - this is an edge case not expected in coefficient templates | |
| term.clone() // Simplified: just return the original | |
| } | |
| term::RTerm::CArray { .. } => { | |
| // Coefficient templates are not expected to contain CArray terms. | |
| // If this arm is reached, we conservatively leave the array term unchanged, | |
| // as the linearization procedure only reasons about scalar and datatype terms. | |
| term.clone() |
src/absadt/learn.rs
Outdated
| // Distribute over additions using Cartesian product | ||
| let mut products = vec![other_terms]; | ||
|
|
||
| for add_args in additions { | ||
| let mut new_products = Vec::new(); | ||
| for product in products { | ||
| for add_term in add_args { | ||
| let mut new_product = product.clone(); | ||
| new_product.push(add_term.clone()); | ||
| new_products.push(new_product); | ||
| } | ||
| } | ||
| products = new_products; | ||
| } |
There was a problem hiding this comment.
The Cartesian product expansion at lines 852-865 can produce exponentially many terms. For example, (a+b)(c+d)(e+f)*(g+h) would produce 2^4=16 terms. With deeply nested sums, this could lead to exponential blowup in both time and memory. Consider adding a check to limit the expansion size or document this limitation in the function's docstring.
The previous implementation would produce non-linear results for products
of 3+ coefficient variables. For example, c*d*e would become c_d * e,
but c_d is constrained to {-1,0,1}, so c_d * e is still a product of
two coefficient-like variables.
Fix by recursively pairing coefficient-like terms until at most one
remains. For c*d*e: first create c_d, then create (c_d)_e.
Also removes the unused `processed` variable.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Previously, when the inner term of a CArray was transformed, the transformation was discarded and the original term was returned. Now properly reconstruct the CArray with the transformed inner term. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add docstring explaining that Cartesian product expansion can produce exponentially many terms for deeply nested sums. Note that this is acceptable for coefficient templates which are typically simple linear combinations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Update test_linearize_term_mixed to verify c*c*d becomes a single aux variable (not c2*d) with the new recursive linearization - Add test_linearize_three_distinct_vars for c*d*e - Add test_linearize_four_distinct_vars for c*d*e*f - Add note about integration tests requiring SMT solver 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Remove redundant wrapper function by inlining expand_term_recursive - Add debug_assert for CMul having exactly 2 arguments (per op.rs docs) - Replace catch-all with explicit handling for all RTerm variants - Add comprehensive documentation for expand_multiplication categories - Note that no equivalent normalization function exists in simplify.rs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
term::add and term::mul automatically handle single-element vectors by returning the element directly (see src/term/simplify.rs:1798-1801 and 2079-2082), so manual len() == 1 checks are unnecessary. Removed 4 redundant checks in: - expand_multiplication (3 places) - linearize_term (1 place) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Replace fragile term-rewriting approach with normalized representation: - Define ConstOrVar enum for sum-of-products representation - Implement arithmetic_to_normal_form: parse arithmetic to normalized form - Implement linearize_products: replace coefficient products with aux vars - Implement from_sum_of_products: convert back to term::Term - Rewrite linearize_term with boundary-based approach (transform at >=, ==) Benefits over old approach: - No dependency on term constructor simplification behavior - Clearer separation of concerns (parse → transform → reconstruct) - More maintainable (~100 lines vs ~200 lines for core logic) - Same functionality with cleaner architecture Removed old expansion functions and tests, added minimal new tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Implement a linearization-based solver that converts polynomial constraints into pure LIA constraints for efficient SMT solving when the number of coefficient variables exceeds the blasting threshold (>10 variables).
Key changes:
🤖 Generated with Claude Code