1use 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
38const MAX_CONSTRAINT_POLYNOMIALS: usize = 20;
42
43const MAX_COEFFICIENT_MAGNITUDE: i64 = 1_000_000;
47
48pub struct ProductConstraintCollector<'a> {
54 deriver: ConstraintDeriver<'a>,
56}
57
58impl<'a> ProductConstraintCollector<'a> {
59 pub fn new(algebra: &'a Algebra, involution: InvolutionKind) -> Self {
66 Self {
67 deriver: ConstraintDeriver::new(algebra, involution),
68 }
69 }
70
71 pub fn collect_constraints(&self, ty: &TypeSpec, prefix: &str) -> Vec<Atom> {
82 let mut constraints = Vec::new();
83
84 if let Some(geo) = self.deriver.derive_geometric_constraint(ty, prefix) {
86 constraints.extend(geo.zero_expressions);
87 }
88
89 if let Some(anti) = self.deriver.derive_antiproduct_constraint(ty, prefix) {
91 constraints.extend(anti.zero_expressions);
92 }
93
94 constraints
95 }
96
97 pub fn collect_wrapper_constraints(
120 &self,
121 ty: &TypeSpec,
122 wrapper: WrapperKind,
123 prefix: &str,
124 ) -> Vec<Atom> {
125 let mut constraints = self.collect_constraints(ty, prefix);
127
128 match wrapper {
130 WrapperKind::Unit | WrapperKind::Proper => {
131 let norm_sq = self.deriver.derive_norm_squared(ty, prefix);
133 constraints.push(norm_sq - Atom::num(1));
134 }
135 WrapperKind::Bulk => {
136 let bulk_sq = self.deriver.derive_bulk_norm_squared(ty, prefix);
138 constraints.push(bulk_sq - Atom::num(1));
139 }
140 WrapperKind::Unitized => {
141 let weight_sq = self.deriver.derive_weight_norm_squared(ty, prefix);
143 constraints.push(weight_sq - Atom::num(1));
144 }
145 WrapperKind::Ideal => {
146 let weight_components = self.deriver.derive_weight_components(ty, prefix);
148 constraints.extend(weight_components);
149 }
150 WrapperKind::Spacelike => {
151 let norm_sq = self.deriver.derive_norm_squared(ty, prefix);
153 constraints.push(norm_sq + Atom::num(1));
154 }
155 WrapperKind::Null => {
156 let norm_sq = self.deriver.derive_norm_squared(ty, prefix);
158 constraints.push(norm_sq);
159 }
160 }
161
162 constraints
163 }
164}
165
166#[derive(Debug, Clone)]
168pub enum ReductionResult {
169 Reduced {
171 reduced: Atom,
173 term_reduction: i32,
175 },
176 Fallback {
178 original: Atom,
180 reason: String,
182 },
183}
184
185impl ReductionResult {
186 pub fn expression(&self) -> &Atom {
188 match self {
189 ReductionResult::Reduced { reduced, .. } => reduced,
190 ReductionResult::Fallback { original, .. } => original,
191 }
192 }
193
194 pub fn is_reduced(&self) -> bool {
196 matches!(self, ReductionResult::Reduced { .. })
197 }
198}
199
200pub struct GroebnerSimplifier {
213 basis_polys: Vec<MultivariatePolynomial<Q, u16, GrevLexOrder>>,
215 enabled: bool,
217}
218
219impl GroebnerSimplifier {
220 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 let mut polys = Vec::with_capacity(constraints.len());
242
243 for constraint in &constraints {
244 let expanded = constraint.expand();
246 let poly: MultivariatePolynomial<Q, u16> = expanded.to_polynomial(&Q, None);
248 polys.push(poly.reorder::<GrevLexOrder>());
250 }
251
252 if polys.is_empty() {
253 return Self::disabled();
254 }
255
256 let basis = GroebnerBasis::new(&polys, false);
258
259 Self {
260 basis_polys: basis.system,
261 enabled: true,
262 }
263 }
264
265 fn disabled() -> Self {
267 Self {
268 basis_polys: Vec::new(),
269 enabled: false,
270 }
271 }
272
273 pub fn is_enabled(&self) -> bool {
275 self.enabled
276 }
277
278 pub fn reduce_atom(&self, expr: &Atom) -> Atom {
291 self.reduce_safe(expr).expression().clone()
292 }
293
294 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 let expanded = expr.expand();
308 let poly: MultivariatePolynomial<Q, u16> = expanded.to_polynomial(&Q, None);
309 let poly = poly.reorder::<GrevLexOrder>();
310
311 let original_terms = poly.nterms();
313
314 let reduced_poly = poly.reduce(&self.basis_polys);
316
317 if let Err(e) = Self::validate_coefficients(&reduced_poly) {
319 return ReductionResult::Fallback {
320 original: expr.clone(),
321 reason: e,
322 };
323 }
324
325 let reduced = reduced_poly.to_expression();
327
328 let reduced_terms = reduced_poly.nterms();
330 let term_reduction = original_terms as i32 - reduced_terms as i32;
331
332 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 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 let num = coef.numerator_ref();
357 let den = coef.denominator_ref();
358
359 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 }
380 Ok(())
381 }
382}
383
384pub struct GroebnerCache {
389 cache: HashMap<(String, String), Arc<GroebnerSimplifier>>,
391}
392
393impl GroebnerCache {
394 pub fn new() -> Self {
396 Self {
397 cache: HashMap::new(),
398 }
399 }
400
401 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 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 let simplifier = Arc::new(GroebnerSimplifier::new(constraints, true));
424 self.cache.insert(key, Arc::clone(&simplifier));
425 simplifier
426 }
427
428 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 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 let constraint = parse_atom("a + b");
485 let simplifier = GroebnerSimplifier::new(vec![constraint], true);
486
487 let expr = parse_atom("2*a + 2*b");
489 let result = simplifier.reduce_safe(&expr);
490
491 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 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 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 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 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 let s1 = cache.get_or_compute(&line, &line, &collector);
600 let s2 = cache.get_or_compute(&line, &line, &collector);
602
603 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 let constraint = parse_atom("a*d + b*e + c*f");
613 let simplifier = GroebnerSimplifier::new(vec![constraint], true);
614
615 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 let constraint = parse_atom("x*y + z");
637 let simplifier = GroebnerSimplifier::new(vec![constraint], true);
638
639 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 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 let constraint = parse_atom("a + b");
659 let simplifier = GroebnerSimplifier::new(vec![constraint], true);
660
661 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#[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 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 fn term_strategy() -> impl Strategy<Value = String> {
702 let coef = prop::sample::select(vec![-5, -3, -2, -1, 1, 2, 3, 5]);
704
705 let var = prop::sample::select(vec!["a", "b", "c", "d", "e", "f"]);
707
708 (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 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 #[test]
729 fn symbolica_reduction_is_idempotent(expr_str in linear_expr_strategy()) {
730 let _guard = SYMBOLICA_LOCK.lock().unwrap();
731
732 let constraint = parse_atom("a + b");
734 let simplifier = GroebnerSimplifier::new(vec![constraint], true);
735
736 let expr = parse_atom(&expr_str);
737
738 let first = simplifier.reduce_atom(&expr);
740 let second = simplifier.reduce_atom(&first);
742
743 prop_assert_eq!(
745 first.expand(),
746 second.expand(),
747 "Reduction should be idempotent"
748 );
749 }
750
751 #[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 #[test]
770 fn symbolica_term_count_non_increasing(expr_str in linear_expr_strategy()) {
771 let _guard = SYMBOLICA_LOCK.lock().unwrap();
772
773 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 }
791 }
792 }
793
794 #[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 let constraint = parse_atom("a + b");
802 let simplifier = GroebnerSimplifier::new(vec![constraint.clone()], true);
803
804 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 #[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 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 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 #[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 let constraint = parse_atom("a + b");
864 let simplifier = GroebnerSimplifier::new(vec![constraint], true);
865
866 let expr = parse_atom("a*c + b*c");
868 let reduced = simplifier.reduce_atom(&expr);
869
870 let a_val = -b_val;
873
874 let original_eval = a_val * c_val + b_val * c_val;
876 prop_assert_eq!(
879 original_eval, 0,
880 "Original expression should evaluate to 0 when a = -b"
881 );
882
883 if reduced.expand() == Atom::num(0) {
885 } else {
887 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 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 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 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 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 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 let constraint = parse_atom("p*s + q*t + r*u");
994 let simplifier = GroebnerSimplifier::new(vec![constraint], true);
995
996 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 let constraint = parse_atom("a + b");
1013 let simplifier = GroebnerSimplifier::new(vec![constraint], true);
1014
1015 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}