Skip to content

Add linearization solver for [-1,1] coefficient templates#13

Open
moratorium08 wants to merge 8 commits intomainfrom
special-linearization
Open

Add linearization solver for [-1,1] coefficient templates#13
moratorium08 wants to merge 8 commits intomainfrom
special-linearization

Conversation

@moratorium08
Copy link
Collaborator

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

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>
Copilot AI review requested due to automatic review settings January 6, 2026 05:24
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-linearization CLI 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.

Comment on lines 1119 to 1125
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);
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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()));

Copilot uses AI. Check for mistakes.
let (sub_op, _) = arg.app_inspect().expect("expected an App");
assert_eq!(sub_op, term::Op::Or);
}
}
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 1118 to 1151
// 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()));
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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()));

Copilot uses AI. Check for mistakes.
Comment on lines 1194 to 1203
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
}
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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()

Copilot uses AI. Check for mistakes.
Comment on lines 852 to 865
// 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;
}
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
moratorium08 and others added 7 commits January 6, 2026 14:55
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant