Skip to main content

clifford_codegen/symbolic/
groebner.rs

1//! Groebner basis simplification for product expressions.
2//!
3//! This module provides constraint-based simplification using Groebner bases.
4//! When computing products of constrained types (like PGA lines with the Plücker
5//! condition), the Groebner basis allows us to eliminate redundant terms.
6//!
7//! # Overview
8//!
9//! 1. **Collect constraints** from input types as polynomial equations
10//! 2. **Compute Groebner basis** of the constraint ideal
11//! 3. **Reduce product expressions** modulo the Groebner basis
12//! 4. **Validate coefficients** to ensure numerical safety
13//!
14//! # Example
15//!
16//! For PGA lines with the Plücker constraint `e01*e23 + e02*e31 + e03*e12 = 0`:
17//!
18//! ```ignore
19//! let constraints = collector.collect_constraints(&line_spec, "l");
20//! let simplifier = GroebnerSimplifier::new(constraints, true);
21//! let reduced = simplifier.reduce_atom(&expr);
22//! ```
23
24use std::collections::HashMap;
25use std::sync::Arc;
26
27use symbolica::atom::{Atom, AtomCore};
28use symbolica::domains::rational::Q;
29use symbolica::poly::GrevLexOrder;
30use symbolica::poly::groebner::GroebnerBasis;
31use symbolica::poly::polynomial::MultivariatePolynomial;
32
33use crate::algebra::Algebra;
34use crate::spec::{InvolutionKind, TypeSpec, WrapperKind};
35
36use super::ConstraintDeriver;
37
38/// Maximum number of constraint polynomials before we skip Groebner computation.
39///
40/// Large constraint systems can cause exponential blowup in Groebner basis computation.
41const MAX_CONSTRAINT_POLYNOMIALS: usize = 20;
42
43/// Maximum numerator/denominator magnitude for safe float conversion.
44///
45/// Coefficients larger than this may lose precision when converted to f64.
46const MAX_COEFFICIENT_MAGNITUDE: i64 = 1_000_000;
47
48/// Collects constraint polynomials for input types in a product.
49///
50/// This struct gathers the constraint expressions that must equal zero for
51/// valid instances of each input type, then converts them to polynomials
52/// suitable for Groebner basis computation.
53pub struct ProductConstraintCollector<'a> {
54    /// The constraint deriver for extracting constraints.
55    deriver: ConstraintDeriver<'a>,
56}
57
58impl<'a> ProductConstraintCollector<'a> {
59    /// Creates a new constraint collector.
60    ///
61    /// # Arguments
62    ///
63    /// * `algebra` - The algebra for computations
64    /// * `involution` - Which involution to use (usually Reverse)
65    pub fn new(algebra: &'a Algebra, involution: InvolutionKind) -> Self {
66        Self {
67            deriver: ConstraintDeriver::new(algebra, involution),
68        }
69    }
70
71    /// Collects all constraint polynomials for a type.
72    ///
73    /// Returns polynomials that must equal zero for valid instances.
74    /// The polynomials use field names prefixed with the given prefix
75    /// (e.g., "self_e01" for the e01 field of self).
76    ///
77    /// # Arguments
78    ///
79    /// * `ty` - The type specification
80    /// * `prefix` - Variable prefix (e.g., "self", "rhs")
81    pub fn collect_constraints(&self, ty: &TypeSpec, prefix: &str) -> Vec<Atom> {
82        let mut constraints = Vec::new();
83
84        // 1. Geometric constraint: u * ũ = scalar (non-scalar terms = 0)
85        if let Some(geo) = self.deriver.derive_geometric_constraint(ty, prefix) {
86            constraints.extend(geo.zero_expressions);
87        }
88
89        // 2. Antiproduct constraint: u ⊟ ũ̃ = antiscalar
90        if let Some(anti) = self.deriver.derive_antiproduct_constraint(ty, prefix) {
91            constraints.extend(anti.zero_expressions);
92        }
93
94        constraints
95    }
96
97    /// Collects constraints for a type with a wrapper constraint.
98    ///
99    /// This combines the algebraic constraints (geometric, antiproduct) with
100    /// wrapper-specific constraints based on the `WrapperKind`.
101    ///
102    /// # Arguments
103    ///
104    /// * `ty` - The type specification
105    /// * `wrapper` - The kind of wrapper constraint to apply
106    /// * `prefix` - Variable prefix (e.g., "self", "rhs")
107    ///
108    /// # Wrapper Constraint Mapping
109    ///
110    /// | Wrapper | Constraint |
111    /// |---------|------------|
112    /// | `Unit` | `norm_squared - 1 = 0` |
113    /// | `Bulk` | `bulk_norm_squared - 1 = 0` |
114    /// | `Unitized` | `weight_norm_squared - 1 = 0` |
115    /// | `Ideal` | each weight component = 0 |
116    /// | `Proper` | `norm_squared - 1 = 0` |
117    /// | `Spacelike` | `norm_squared + 1 = 0` |
118    /// | `Null` | `norm_squared = 0` |
119    pub fn collect_wrapper_constraints(
120        &self,
121        ty: &TypeSpec,
122        wrapper: WrapperKind,
123        prefix: &str,
124    ) -> Vec<Atom> {
125        // Start with the algebraic constraints
126        let mut constraints = self.collect_constraints(ty, prefix);
127
128        // Add wrapper-specific constraints
129        match wrapper {
130            WrapperKind::Unit | WrapperKind::Proper => {
131                // norm_squared - 1 = 0
132                let norm_sq = self.deriver.derive_norm_squared(ty, prefix);
133                constraints.push(norm_sq - Atom::num(1));
134            }
135            WrapperKind::Bulk => {
136                // bulk_norm_squared - 1 = 0
137                let bulk_sq = self.deriver.derive_bulk_norm_squared(ty, prefix);
138                constraints.push(bulk_sq - Atom::num(1));
139            }
140            WrapperKind::Unitized => {
141                // weight_norm_squared - 1 = 0
142                let weight_sq = self.deriver.derive_weight_norm_squared(ty, prefix);
143                constraints.push(weight_sq - Atom::num(1));
144            }
145            WrapperKind::Ideal => {
146                // Each weight component = 0
147                let weight_components = self.deriver.derive_weight_components(ty, prefix);
148                constraints.extend(weight_components);
149            }
150            WrapperKind::Spacelike => {
151                // norm_squared + 1 = 0 (spacelike has norm² = -1 in signature (1,-1,-1,-1))
152                let norm_sq = self.deriver.derive_norm_squared(ty, prefix);
153                constraints.push(norm_sq + Atom::num(1));
154            }
155            WrapperKind::Null => {
156                // norm_squared = 0
157                let norm_sq = self.deriver.derive_norm_squared(ty, prefix);
158                constraints.push(norm_sq);
159            }
160        }
161
162        constraints
163    }
164}
165
166/// Result of attempting a safe Groebner reduction.
167#[derive(Debug, Clone)]
168pub enum ReductionResult {
169    /// Successfully reduced with safe coefficients.
170    Reduced {
171        /// The reduced expression.
172        reduced: Atom,
173        /// Number of terms reduced (positive = fewer terms).
174        term_reduction: i32,
175    },
176    /// Fell back to original due to validation failure.
177    Fallback {
178        /// The original expression (unchanged).
179        original: Atom,
180        /// Reason for fallback.
181        reason: String,
182    },
183}
184
185impl ReductionResult {
186    /// Returns the resulting expression (reduced or original).
187    pub fn expression(&self) -> &Atom {
188        match self {
189            ReductionResult::Reduced { reduced, .. } => reduced,
190            ReductionResult::Fallback { original, .. } => original,
191        }
192    }
193
194    /// Returns true if reduction was successful.
195    pub fn is_reduced(&self) -> bool {
196        matches!(self, ReductionResult::Reduced { .. })
197    }
198}
199
200/// Simplifies expressions using Groebner basis reduction.
201///
202/// This struct computes a Groebner basis from constraint polynomials and
203/// uses it to reduce (simplify) product expressions.
204///
205/// # Safety
206///
207/// The simplifier validates reduced expressions to ensure:
208/// - Coefficients don't explode (within `MAX_COEFFICIENT_MAGNITUDE`)
209/// - The reduced form is actually simpler (fewer terms)
210///
211/// If validation fails, it falls back to the original expression.
212pub struct GroebnerSimplifier {
213    /// The Groebner basis polynomials (already computed).
214    basis_polys: Vec<MultivariatePolynomial<Q, u16, GrevLexOrder>>,
215    /// Whether reduction is enabled (may be disabled for performance).
216    enabled: bool,
217}
218
219impl GroebnerSimplifier {
220    /// Creates a simplifier from constraint atoms.
221    ///
222    /// # Arguments
223    ///
224    /// * `constraints` - Atom expressions that must equal zero
225    /// * `use_grevlex` - Use grevlex ordering (faster) vs lex (better elimination)
226    ///
227    /// # Returns
228    ///
229    /// A simplifier ready to reduce expressions. If constraints are empty or
230    /// too numerous, returns a disabled simplifier that passes through unchanged.
231    pub fn new(constraints: Vec<Atom>, _use_grevlex: bool) -> Self {
232        if constraints.is_empty() {
233            return Self::disabled();
234        }
235
236        if constraints.len() > MAX_CONSTRAINT_POLYNOMIALS {
237            return Self::disabled();
238        }
239
240        // Convert constraints to polynomials
241        let mut polys = Vec::with_capacity(constraints.len());
242
243        for constraint in &constraints {
244            // Expand the constraint first
245            let expanded = constraint.expand();
246            // Convert to polynomial over rationals (lex order by default)
247            let poly: MultivariatePolynomial<Q, u16> = expanded.to_polynomial(&Q, None);
248            // Reorder to grevlex for faster Groebner computation
249            polys.push(poly.reorder::<GrevLexOrder>());
250        }
251
252        if polys.is_empty() {
253            return Self::disabled();
254        }
255
256        // Compute Groebner basis (false = don't print stats)
257        let basis = GroebnerBasis::new(&polys, false);
258
259        Self {
260            basis_polys: basis.system,
261            enabled: true,
262        }
263    }
264
265    /// Creates a disabled simplifier that passes expressions through unchanged.
266    fn disabled() -> Self {
267        Self {
268            basis_polys: Vec::new(),
269            enabled: false,
270        }
271    }
272
273    /// Returns true if this simplifier is enabled.
274    pub fn is_enabled(&self) -> bool {
275        self.enabled
276    }
277
278    /// Reduces an expression modulo the Groebner basis.
279    ///
280    /// This is the main simplification method. It converts the expression to
281    /// a polynomial, reduces it using the Groebner basis, then converts back.
282    ///
283    /// # Arguments
284    ///
285    /// * `expr` - The expression to reduce
286    ///
287    /// # Returns
288    ///
289    /// The reduced expression, or the original if reduction fails or is disabled.
290    pub fn reduce_atom(&self, expr: &Atom) -> Atom {
291        self.reduce_safe(expr).expression().clone()
292    }
293
294    /// Reduces an expression with validation and fallback.
295    ///
296    /// This method performs reduction and validates the result. If validation
297    /// fails (e.g., coefficient explosion), it falls back to the original.
298    pub fn reduce_safe(&self, expr: &Atom) -> ReductionResult {
299        if !self.enabled || self.basis_polys.is_empty() {
300            return ReductionResult::Fallback {
301                original: expr.clone(),
302                reason: "Groebner simplification disabled".to_string(),
303            };
304        }
305
306        // Expand and convert to polynomial
307        let expanded = expr.expand();
308        let poly: MultivariatePolynomial<Q, u16> = expanded.to_polynomial(&Q, None);
309        let poly = poly.reorder::<GrevLexOrder>();
310
311        // Count original terms
312        let original_terms = poly.nterms();
313
314        // Reduce modulo Groebner basis
315        let reduced_poly = poly.reduce(&self.basis_polys);
316
317        // Validate coefficients
318        if let Err(e) = Self::validate_coefficients(&reduced_poly) {
319            return ReductionResult::Fallback {
320                original: expr.clone(),
321                reason: e,
322            };
323        }
324
325        // Convert back to atom
326        let reduced = reduced_poly.to_expression();
327
328        // Count reduced terms
329        let reduced_terms = reduced_poly.nterms();
330        let term_reduction = original_terms as i32 - reduced_terms as i32;
331
332        // Only accept if we actually reduced terms or stayed the same
333        if term_reduction < 0 {
334            return ReductionResult::Fallback {
335                original: expr.clone(),
336                reason: format!(
337                    "Reduction increased terms: {} -> {}",
338                    original_terms, reduced_terms
339                ),
340            };
341        }
342
343        ReductionResult::Reduced {
344            reduced,
345            term_reduction,
346        }
347    }
348
349    /// Validates that polynomial coefficients are safe for float conversion.
350    fn validate_coefficients(
351        poly: &MultivariatePolynomial<Q, u16, GrevLexOrder>,
352    ) -> Result<(), String> {
353        for monomial in poly.into_iter() {
354            let coef = monomial.coefficient;
355            // Get numerator and denominator as Integers
356            let num = coef.numerator_ref();
357            let den = coef.denominator_ref();
358
359            // Try to convert to i64 for bounds checking
360            if let (Some(n), Some(d)) = (num.to_i64(), den.to_i64()) {
361                if n.abs() > MAX_COEFFICIENT_MAGNITUDE {
362                    return Err(format!(
363                        "Numerator too large: {} > {}",
364                        n.abs(),
365                        MAX_COEFFICIENT_MAGNITUDE
366                    ));
367                }
368
369                if d.abs() > MAX_COEFFICIENT_MAGNITUDE {
370                    return Err(format!(
371                        "Denominator too large: {} > {}",
372                        d.abs(),
373                        MAX_COEFFICIENT_MAGNITUDE
374                    ));
375                }
376            }
377            // If conversion to i64 fails, the number is definitely too large
378            // but we'll allow it for now (might be simplified later)
379        }
380        Ok(())
381    }
382}
383
384/// Cache for Groebner bases computed for type pairs.
385///
386/// Computing Groebner bases can be expensive, so we cache them for reuse
387/// when the same type pair is encountered multiple times.
388pub struct GroebnerCache {
389    /// Cache key: (type_a_name, type_b_name) -> simplifier
390    cache: HashMap<(String, String), Arc<GroebnerSimplifier>>,
391}
392
393impl GroebnerCache {
394    /// Creates a new empty cache.
395    pub fn new() -> Self {
396        Self {
397            cache: HashMap::new(),
398        }
399    }
400
401    /// Gets or computes a Groebner simplifier for a type pair.
402    ///
403    /// If the simplifier for this pair is already cached, returns it.
404    /// Otherwise, computes the Groebner basis and caches it.
405    pub fn get_or_compute(
406        &mut self,
407        type_a: &TypeSpec,
408        type_b: &TypeSpec,
409        collector: &ProductConstraintCollector,
410    ) -> Arc<GroebnerSimplifier> {
411        let key = (type_a.name.clone(), type_b.name.clone());
412
413        if let Some(cached) = self.cache.get(&key) {
414            return Arc::clone(cached);
415        }
416
417        // Collect constraints from both input types
418        let mut constraints = Vec::new();
419        constraints.extend(collector.collect_constraints(type_a, "self"));
420        constraints.extend(collector.collect_constraints(type_b, "rhs"));
421
422        // Compute Groebner basis
423        let simplifier = Arc::new(GroebnerSimplifier::new(constraints, true));
424        self.cache.insert(key, Arc::clone(&simplifier));
425        simplifier
426    }
427
428    /// Clears the cache.
429    pub fn clear(&mut self) {
430        self.cache.clear();
431    }
432}
433
434impl Default for GroebnerCache {
435    fn default() -> Self {
436        Self::new()
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443    use std::borrow::Cow;
444    use std::sync::Mutex;
445    use symbolica::atom::DefaultNamespace;
446    use symbolica::parser::ParseSettings;
447
448    // Symbolica uses global state that conflicts when tests run in parallel.
449    static SYMBOLICA_LOCK: Mutex<()> = Mutex::new(());
450
451    fn parse_atom(s: &str) -> Atom {
452        let input = DefaultNamespace {
453            namespace: Cow::Borrowed(env!("CARGO_CRATE_NAME")),
454            data: s,
455            file: Cow::Borrowed(file!()),
456            line: line!() as usize,
457        };
458        Atom::parse(input, ParseSettings::symbolica()).unwrap()
459    }
460
461    #[test]
462    fn symbolica_empty_constraints_returns_disabled() {
463        let _guard = SYMBOLICA_LOCK.lock().unwrap();
464        let simplifier = GroebnerSimplifier::new(vec![], true);
465        assert!(!simplifier.is_enabled());
466    }
467
468    #[test]
469    fn symbolica_disabled_simplifier_returns_original() {
470        let _guard = SYMBOLICA_LOCK.lock().unwrap();
471        let simplifier = GroebnerSimplifier::disabled();
472        let expr = parse_atom("a + b");
473        let result = simplifier.reduce_safe(&expr);
474
475        assert!(!result.is_reduced());
476        assert_eq!(result.expression().expand(), expr.expand());
477    }
478
479    #[test]
480    fn symbolica_simple_constraint_reduces() {
481        let _guard = SYMBOLICA_LOCK.lock().unwrap();
482
483        // Constraint: a + b = 0  =>  a = -b
484        let constraint = parse_atom("a + b");
485        let simplifier = GroebnerSimplifier::new(vec![constraint], true);
486
487        // Expression: 2*a + 2*b should reduce to 0
488        let expr = parse_atom("2*a + 2*b");
489        let result = simplifier.reduce_safe(&expr);
490
491        // The expression should reduce to 0 since a + b = 0
492        if result.is_reduced() {
493            let reduced = result.expression();
494            assert_eq!(reduced.expand(), Atom::num(0));
495        }
496    }
497
498    #[test]
499    fn symbolica_plucker_constraint() {
500        let _guard = SYMBOLICA_LOCK.lock().unwrap();
501
502        // Plücker constraint: e01*e23 + e02*e31 + e03*e12 = 0
503        let constraint = parse_atom("e01*e23 + e02*e31 + e03*e12");
504        let simplifier = GroebnerSimplifier::new(vec![constraint], true);
505
506        assert!(simplifier.is_enabled());
507
508        // The constraint itself should reduce to 0
509        let expr = parse_atom("e01*e23 + e02*e31 + e03*e12");
510        let result = simplifier.reduce_safe(&expr);
511
512        if result.is_reduced() {
513            let reduced = result.expression();
514            assert_eq!(reduced.expand(), Atom::num(0));
515        }
516    }
517
518    /// Creates a minimal Line type for testing.
519    fn create_line_type() -> TypeSpec {
520        use crate::spec::FieldSpec;
521
522        TypeSpec {
523            name: "Line".to_string(),
524            description: None,
525            grades: vec![2],
526            fields: vec![
527                FieldSpec {
528                    name: "e01".to_string(),
529                    blade_index: 0b0011,
530                    grade: 2,
531                    sign: 1,
532                },
533                FieldSpec {
534                    name: "e02".to_string(),
535                    blade_index: 0b0101,
536                    grade: 2,
537                    sign: 1,
538                },
539                FieldSpec {
540                    name: "e03".to_string(),
541                    blade_index: 0b1001,
542                    grade: 2,
543                    sign: 1,
544                },
545                FieldSpec {
546                    name: "e12".to_string(),
547                    blade_index: 0b0110,
548                    grade: 2,
549                    sign: 1,
550                },
551                FieldSpec {
552                    name: "e31".to_string(),
553                    blade_index: 0b1010,
554                    grade: 2,
555                    sign: 1,
556                },
557                FieldSpec {
558                    name: "e23".to_string(),
559                    blade_index: 0b1100,
560                    grade: 2,
561                    sign: 1,
562                },
563            ],
564            versor: None,
565            alias_of: None,
566            is_sparse: false,
567            inverse_sandwich_targets: vec![],
568        }
569    }
570
571    #[test]
572    fn symbolica_constraint_collector_pga_line() {
573        let _guard = SYMBOLICA_LOCK.lock().unwrap();
574
575        let line = create_line_type();
576        let algebra = Algebra::pga(3);
577        let collector = ProductConstraintCollector::new(&algebra, InvolutionKind::Reverse);
578
579        let constraints = collector.collect_constraints(&line, "l");
580
581        // PGA lines have the Plücker constraint
582        assert!(
583            !constraints.is_empty(),
584            "PGA lines should have constraints (Plücker condition)"
585        );
586    }
587
588    #[test]
589    fn symbolica_cache_reuses_simplifier() {
590        let _guard = SYMBOLICA_LOCK.lock().unwrap();
591
592        let line = create_line_type();
593        let algebra = Algebra::pga(3);
594        let collector = ProductConstraintCollector::new(&algebra, InvolutionKind::Reverse);
595
596        let mut cache = GroebnerCache::new();
597
598        // First call should compute
599        let s1 = cache.get_or_compute(&line, &line, &collector);
600        // Second call should reuse
601        let s2 = cache.get_or_compute(&line, &line, &collector);
602
603        // Should be the same Arc
604        assert!(Arc::ptr_eq(&s1, &s2));
605    }
606
607    #[test]
608    fn symbolica_term_count_reduction_with_constraint() {
609        let _guard = SYMBOLICA_LOCK.lock().unwrap();
610
611        // Constraint: a*d + b*e + c*f = 0 (like Plücker)
612        let constraint = parse_atom("a*d + b*e + c*f");
613        let simplifier = GroebnerSimplifier::new(vec![constraint], true);
614
615        // Expression that can be simplified using the constraint
616        // 2*a*d + 2*b*e + 2*c*f should reduce to 0
617        let expr = parse_atom("2*a*d + 2*b*e + 2*c*f");
618
619        let result = simplifier.reduce_safe(&expr);
620
621        assert!(
622            result.is_reduced(),
623            "Expression should reduce using the constraint"
624        );
625
626        if let ReductionResult::Reduced { term_reduction, .. } = result {
627            assert!(term_reduction >= 0, "Term reduction should be non-negative");
628        }
629    }
630
631    #[test]
632    fn symbolica_non_constrained_expression_unchanged() {
633        let _guard = SYMBOLICA_LOCK.lock().unwrap();
634
635        // Constraint that doesn't apply to our expression
636        let constraint = parse_atom("x*y + z");
637        let simplifier = GroebnerSimplifier::new(vec![constraint], true);
638
639        // Expression using different variables - can't be reduced
640        let expr = parse_atom("a + b + c");
641        let result = simplifier.reduce_safe(&expr);
642
643        let original_expanded = expr.expand();
644        let result_expanded = result.expression().expand();
645
646        // Unrelated expression should be unchanged
647        assert_eq!(
648            original_expanded, result_expanded,
649            "Unrelated expression should be unchanged"
650        );
651    }
652
653    #[test]
654    fn symbolica_partial_reduction() {
655        let _guard = SYMBOLICA_LOCK.lock().unwrap();
656
657        // Constraint: a + b = 0 => a = -b
658        let constraint = parse_atom("a + b");
659        let simplifier = GroebnerSimplifier::new(vec![constraint], true);
660
661        // Expression: a + b + c should reduce to c
662        let expr = parse_atom("a + b + c");
663        let result = simplifier.reduce_safe(&expr);
664
665        if result.is_reduced() {
666            let reduced = result.expression();
667            let expected = parse_atom("c");
668            assert_eq!(
669                reduced.expand(),
670                expected.expand(),
671                "a + b should cancel leaving only c"
672            );
673        }
674    }
675}
676
677/// Property-based tests for Groebner reduction correctness.
678#[cfg(test)]
679mod proptest_tests {
680    use super::*;
681    use proptest::prelude::*;
682    use std::borrow::Cow;
683    use std::sync::Mutex;
684    use symbolica::atom::DefaultNamespace;
685    use symbolica::parser::ParseSettings;
686
687    // Symbolica uses global state that conflicts when tests run in parallel.
688    static SYMBOLICA_LOCK: Mutex<()> = Mutex::new(());
689
690    fn parse_atom(s: &str) -> Atom {
691        let input = DefaultNamespace {
692            namespace: Cow::Borrowed(env!("CARGO_CRATE_NAME")),
693            data: s,
694            file: Cow::Borrowed(file!()),
695            line: line!() as usize,
696        };
697        Atom::parse(input, ParseSettings::symbolica()).unwrap()
698    }
699
700    /// Strategy for generating simple polynomial terms like "3*a*b".
701    fn term_strategy() -> impl Strategy<Value = String> {
702        // Generate coefficient (-10 to 10, non-zero)
703        let coef = prop::sample::select(vec![-5, -3, -2, -1, 1, 2, 3, 5]);
704
705        // Generate variable names
706        let var = prop::sample::select(vec!["a", "b", "c", "d", "e", "f"]);
707
708        // Combine into a term with 1-3 variables
709        (coef, prop::collection::vec(var, 1..=3)).prop_map(|(c, vars)| {
710            let var_product = vars.join("*");
711            if c == 1 {
712                var_product
713            } else if c == -1 {
714                format!("-{}", var_product)
715            } else {
716                format!("{}*{}", c, var_product)
717            }
718        })
719    }
720
721    /// Strategy for generating simple linear expressions (sums of terms).
722    fn linear_expr_strategy() -> impl Strategy<Value = String> {
723        prop::collection::vec(term_strategy(), 1..=4).prop_map(|terms| terms.join(" + "))
724    }
725
726    proptest! {
727        /// Reduction is idempotent: reducing twice gives the same result.
728        #[test]
729        fn symbolica_reduction_is_idempotent(expr_str in linear_expr_strategy()) {
730            let _guard = SYMBOLICA_LOCK.lock().unwrap();
731
732            // Simple constraint: a + b = 0
733            let constraint = parse_atom("a + b");
734            let simplifier = GroebnerSimplifier::new(vec![constraint], true);
735
736            let expr = parse_atom(&expr_str);
737
738            // First reduction
739            let first = simplifier.reduce_atom(&expr);
740            // Second reduction
741            let second = simplifier.reduce_atom(&first);
742
743            // Should be identical after expansion
744            prop_assert_eq!(
745                first.expand(),
746                second.expand(),
747                "Reduction should be idempotent"
748            );
749        }
750
751        /// Disabled simplifier is identity.
752        #[test]
753        fn symbolica_disabled_is_identity(expr_str in linear_expr_strategy()) {
754            let _guard = SYMBOLICA_LOCK.lock().unwrap();
755
756            let simplifier = GroebnerSimplifier::disabled();
757            let expr = parse_atom(&expr_str);
758
759            let result = simplifier.reduce_atom(&expr);
760
761            prop_assert_eq!(
762                expr.expand(),
763                result.expand(),
764                "Disabled simplifier should return original"
765            );
766        }
767
768        /// Term count never increases during reduction.
769        #[test]
770        fn symbolica_term_count_non_increasing(expr_str in linear_expr_strategy()) {
771            let _guard = SYMBOLICA_LOCK.lock().unwrap();
772
773            // Constraint: a*d + b*e = 0 (Plücker-like)
774            let constraint = parse_atom("a*d + b*e");
775            let simplifier = GroebnerSimplifier::new(vec![constraint], true);
776
777            let expr = parse_atom(&expr_str);
778            let result = simplifier.reduce_safe(&expr);
779
780            match result {
781                ReductionResult::Reduced { term_reduction, .. } => {
782                    prop_assert!(
783                        term_reduction >= 0,
784                        "Term count should not increase, got reduction: {}",
785                        term_reduction
786                    );
787                }
788                ReductionResult::Fallback { .. } => {
789                    // Fallback is acceptable - it means we detected a problem
790                }
791            }
792        }
793
794        /// Constraints reduce to zero.
795        #[test]
796        fn symbolica_constraint_reduces_to_zero(scale in -5i32..=5i32) {
797            prop_assume!(scale != 0);
798            let _guard = SYMBOLICA_LOCK.lock().unwrap();
799
800            // Constraint: a + b = 0
801            let constraint = parse_atom("a + b");
802            let simplifier = GroebnerSimplifier::new(vec![constraint.clone()], true);
803
804            // Scale * constraint should reduce to 0
805            let scaled = if scale == 1 {
806                "a + b".to_string()
807            } else if scale == -1 {
808                "-a - b".to_string()
809            } else {
810                format!("{}*a + {}*b", scale, scale)
811            };
812
813            let expr = parse_atom(&scaled);
814            let result = simplifier.reduce_atom(&expr);
815
816            prop_assert_eq!(
817                result.expand(),
818                Atom::num(0),
819                "Scaled constraint should reduce to 0"
820            );
821        }
822
823        /// Linear combinations of constraints reduce appropriately.
824        #[test]
825        fn symbolica_linear_combo_of_constraints(c1 in -3i32..=3i32, c2 in -3i32..=3i32) {
826            prop_assume!(c1 != 0 || c2 != 0);
827            let _guard = SYMBOLICA_LOCK.lock().unwrap();
828
829            // Constraints: a + b = 0, c + d = 0
830            let constraint1 = parse_atom("a + b");
831            let constraint2 = parse_atom("c + d");
832            let simplifier =
833                GroebnerSimplifier::new(vec![constraint1.clone(), constraint2.clone()], true);
834
835            // c1*(a + b) + c2*(c + d) should reduce to 0
836            let expr_str = format!("{}*a + {}*b + {}*c + {}*d", c1, c1, c2, c2);
837            let expr = parse_atom(&expr_str);
838            let result = simplifier.reduce_atom(&expr);
839
840            prop_assert_eq!(
841                result.expand(),
842                Atom::num(0),
843                "Linear combination of constraints should reduce to 0"
844            );
845        }
846    }
847
848    proptest! {
849        #![proptest_config(ProptestConfig::with_cases(20))]
850
851        /// Reduction preserves semantic equivalence under constraint substitution.
852        ///
853        /// If a + b = 0 (so a = -b), then evaluating the original and reduced
854        /// expressions with a = -b should give the same result.
855        #[test]
856        fn symbolica_reduction_preserves_semantics(
857            b_val in -10i64..=10i64,
858            c_val in -10i64..=10i64,
859        ) {
860            let _guard = SYMBOLICA_LOCK.lock().unwrap();
861
862            // Constraint: a + b = 0 => a = -b
863            let constraint = parse_atom("a + b");
864            let simplifier = GroebnerSimplifier::new(vec![constraint], true);
865
866            // Test expression: a*c + b*c = c*(a + b) = 0 when a = -b
867            let expr = parse_atom("a*c + b*c");
868            let reduced = simplifier.reduce_atom(&expr);
869
870            // The reduced form should be 0 since a*c + b*c = c*(a+b) = 0
871            // Or it should evaluate to 0 when substituting a = -b
872            let a_val = -b_val;
873
874            // Evaluate original: a*c + b*c with a=-b, b=b, c=c
875            let original_eval = a_val * c_val + b_val * c_val;
876            // This should always be 0
877
878            prop_assert_eq!(
879                original_eval, 0,
880                "Original expression should evaluate to 0 when a = -b"
881            );
882
883            // The reduced form should also be 0 or evaluate to 0
884            if reduced.expand() == Atom::num(0) {
885                // Perfect - reduced to constant 0
886            } else {
887                // The reduction might leave variables - check it evaluates to 0
888                // For a*c + b*c, the reduction should give 0
889                prop_assert_eq!(
890                    reduced.expand(),
891                    Atom::num(0),
892                    "Expression a*c + b*c should reduce to 0 with constraint a + b = 0"
893                );
894            }
895        }
896    }
897
898    /// Edge case tests for Groebner reduction.
899    mod edge_cases {
900        use super::{Atom, GroebnerSimplifier, SYMBOLICA_LOCK, parse_atom};
901        use symbolica::atom::AtomCore;
902
903        #[test]
904        fn symbolica_zero_expression() {
905            let _guard = SYMBOLICA_LOCK.lock().unwrap();
906
907            let constraint = parse_atom("a + b");
908            let simplifier = GroebnerSimplifier::new(vec![constraint], true);
909
910            let zero = Atom::num(0);
911            let result = simplifier.reduce_atom(&zero);
912
913            assert_eq!(result.expand(), Atom::num(0), "Zero should stay zero");
914        }
915
916        #[test]
917        fn symbolica_constant_expression() {
918            let _guard = SYMBOLICA_LOCK.lock().unwrap();
919
920            let constraint = parse_atom("a + b");
921            let simplifier = GroebnerSimplifier::new(vec![constraint], true);
922
923            let constant = Atom::num(42);
924            let result = simplifier.reduce_atom(&constant);
925
926            assert_eq!(
927                result.expand(),
928                Atom::num(42),
929                "Constants should be unchanged"
930            );
931        }
932
933        #[test]
934        fn symbolica_single_variable() {
935            let _guard = SYMBOLICA_LOCK.lock().unwrap();
936
937            let constraint = parse_atom("x + y");
938            let simplifier = GroebnerSimplifier::new(vec![constraint], true);
939
940            // Variable not in constraint
941            let var = parse_atom("z");
942            let result = simplifier.reduce_atom(&var);
943
944            assert_eq!(
945                result.expand(),
946                var.expand(),
947                "Unrelated variable should be unchanged"
948            );
949        }
950
951        #[test]
952        fn symbolica_many_constraints_disabled() {
953            let _guard = SYMBOLICA_LOCK.lock().unwrap();
954
955            // Create more than MAX_CONSTRAINT_POLYNOMIALS constraints
956            let constraints: Vec<_> = (0..25)
957                .map(|i| parse_atom(&format!("x{} + y{}", i, i)))
958                .collect();
959
960            let simplifier = GroebnerSimplifier::new(constraints, true);
961
962            assert!(
963                !simplifier.is_enabled(),
964                "Too many constraints should disable simplifier"
965            );
966        }
967
968        #[test]
969        fn symbolica_redundant_constraints() {
970            let _guard = SYMBOLICA_LOCK.lock().unwrap();
971
972            // Two equivalent constraints: a + b = 0 and 2a + 2b = 0
973            let c1 = parse_atom("a + b");
974            let c2 = parse_atom("2*a + 2*b");
975            let simplifier = GroebnerSimplifier::new(vec![c1, c2], true);
976
977            // Should still work correctly
978            let expr = parse_atom("a + b");
979            let result = simplifier.reduce_atom(&expr);
980
981            assert_eq!(
982                result.expand(),
983                Atom::num(0),
984                "Redundant constraints should still reduce correctly"
985            );
986        }
987
988        #[test]
989        fn symbolica_product_constraint() {
990            let _guard = SYMBOLICA_LOCK.lock().unwrap();
991
992            // Plücker-like: p*s + q*t + r*u = 0
993            let constraint = parse_atom("p*s + q*t + r*u");
994            let simplifier = GroebnerSimplifier::new(vec![constraint], true);
995
996            // Expression containing the constraint pattern
997            let expr = parse_atom("2*p*s + 2*q*t + 2*r*u");
998            let result = simplifier.reduce_atom(&expr);
999
1000            assert_eq!(
1001                result.expand(),
1002                Atom::num(0),
1003                "Product constraint should reduce multiples to 0"
1004            );
1005        }
1006
1007        #[test]
1008        fn symbolica_nested_product() {
1009            let _guard = SYMBOLICA_LOCK.lock().unwrap();
1010
1011            // Constraint: a + b = 0
1012            let constraint = parse_atom("a + b");
1013            let simplifier = GroebnerSimplifier::new(vec![constraint], true);
1014
1015            // (a + b) * c = a*c + b*c should reduce to 0
1016            let expr = parse_atom("(a + b)*c");
1017            let expanded = expr.expand();
1018            let result = simplifier.reduce_atom(&expanded);
1019
1020            assert_eq!(
1021                result.expand(),
1022                Atom::num(0),
1023                "Product with constraint should reduce to 0"
1024            );
1025        }
1026    }
1027}