Skip to main content

clifford_codegen/symbolic/
constraint_derive.rs

1//! Constraint derivation from algebra structure.
2//!
3//! This module derives constraint expressions by computing `u * involution(u)`
4//! symbolically for a type and extracting the non-scalar terms. The geometric
5//! constraint states that `u * involution(u)` must produce only a scalar for
6//! valid geometric entities.
7//!
8//! The involution used depends on the algebra's configuration:
9//! - `Reverse`: Most algebras (Euclidean, PGA) - versors satisfy `v * reverse(v) = scalar`
10//! - `GradeInvolution`: Hyperbolic numbers - `z * involute(z) = a² - b²`
11//! - `CliffordConjugate`: Complex numbers - `z * conjugate(z) = a² + b²`
12//!
13//! For example, for a rotor R = s + B (scalar + bivector), computing
14//! `R * reverse(R)` produces scalar terms and potentially non-scalar terms.
15//! The non-scalar terms must equal zero, giving us the constraint expression.
16
17use std::collections::HashMap;
18
19use symbolica::atom::Atom;
20
21use crate::algebra::{
22    Algebra, Blade, ProductTable, antireverse_sign, clifford_conjugate_sign, grade,
23    grade_involution_sign, reverse_sign,
24};
25use crate::spec::{InvolutionKind, TypeSpec};
26
27/// Result of deriving constraints for a type.
28#[derive(Debug, Clone)]
29pub struct DerivedConstraint {
30    /// Name describing the constraint (e.g., "geometric", "study").
31    pub name: String,
32
33    /// Symbolic expressions that must equal zero.
34    ///
35    /// Each expression corresponds to a non-scalar grade in `u * reverse(u)`.
36    /// All must be zero for the type to satisfy the geometric constraint.
37    pub zero_expressions: Vec<Atom>,
38
39    /// Grades of the non-scalar terms (for documentation).
40    pub constraint_grades: Vec<usize>,
41}
42
43/// Derives constraint expressions from algebra structure.
44pub struct ConstraintDeriver<'a> {
45    /// The algebra for computations.
46    algebra: &'a Algebra,
47    /// Product table for efficient computation.
48    table: ProductTable,
49    /// Which involution to use for constraint derivation.
50    involution: InvolutionKind,
51}
52
53impl<'a> ConstraintDeriver<'a> {
54    /// Creates a new constraint deriver with the specified involution.
55    pub fn new(algebra: &'a Algebra, involution: InvolutionKind) -> Self {
56        let table = ProductTable::new(algebra);
57        Self {
58            algebra,
59            table,
60            involution,
61        }
62    }
63
64    /// Derives the geometric constraint for a type.
65    ///
66    /// Computes `u * involution(u)` symbolically and returns the non-scalar
67    /// terms that must equal zero. The involution used is determined by
68    /// the algebra's configuration (reverse, grade involution, or Clifford conjugate).
69    ///
70    /// # Returns
71    ///
72    /// - `Some(constraint)` if the type has non-trivial constraints
73    /// - `None` if `u * reverse(u)` is purely scalar (no constraints needed)
74    pub fn derive_geometric_constraint(
75        &self,
76        ty: &TypeSpec,
77        field_prefix: &str,
78    ) -> Option<DerivedConstraint> {
79        let dim = self.algebra.dim();
80
81        // Create symbolic variables for fields
82        let symbols = self.create_symbols(ty, field_prefix);
83
84        // Compute u * involution(u) for each output grade
85        let mut zero_expressions = Vec::new();
86        let mut constraint_grades = Vec::new();
87
88        // Check all grades from 1 to dim (skip scalar grade 0)
89        for output_grade in 1..=dim {
90            let expr = self.compute_product_involution_at_grade(ty, output_grade, &symbols);
91
92            // Check if the expression is non-trivial (not just zero)
93            if !self.is_zero(&expr) {
94                zero_expressions.push(expr);
95                constraint_grades.push(output_grade);
96            }
97        }
98
99        if zero_expressions.is_empty() {
100            None
101        } else {
102            Some(DerivedConstraint {
103                name: "geometric".to_string(),
104                zero_expressions,
105                constraint_grades,
106            })
107        }
108    }
109
110    /// Creates symbolic variables for a type's fields.
111    fn create_symbols(&self, ty: &TypeSpec, prefix: &str) -> HashMap<usize, Atom> {
112        use std::borrow::Cow;
113        use symbolica::atom::DefaultNamespace;
114        use symbolica::parser::ParseSettings;
115
116        ty.fields
117            .iter()
118            .map(|f| {
119                let symbol_name = format!("{}_{}", prefix, f.name);
120                let input = DefaultNamespace {
121                    namespace: Cow::Borrowed(env!("CARGO_CRATE_NAME")),
122                    data: symbol_name.as_str(),
123                    file: Cow::Borrowed(file!()),
124                    line: line!() as usize,
125                };
126                let atom = Atom::parse(input, ParseSettings::symbolica()).unwrap();
127                (f.blade_index, atom)
128            })
129            .collect()
130    }
131
132    /// Computes the expression for `u * involution(u)` at a specific output grade.
133    fn compute_product_involution_at_grade(
134        &self,
135        ty: &TypeSpec,
136        output_grade: usize,
137        symbols: &HashMap<usize, Atom>,
138    ) -> Atom {
139        let mut terms: Vec<Atom> = Vec::new();
140
141        // For each pair of fields (a, b), compute a * involution(b)
142        // The involution of a blade B depends on the configured involution kind
143        for field_a in &ty.fields {
144            for field_b in &ty.fields {
145                let blade_a = field_a.blade_index;
146                let blade_b = field_b.blade_index;
147                let grade_b = Blade::from_index(blade_b).grade();
148
149                // Get the product a * b
150                let (product_sign, result_blade) = self.table.geometric(blade_a, blade_b);
151
152                // Check if result is at the target grade
153                let result_grade = grade(result_blade);
154                if result_grade != output_grade || product_sign == 0 {
155                    continue;
156                }
157
158                // Apply involution sign to b based on configured involution kind
159                let inv_sign = match self.involution {
160                    InvolutionKind::Reverse => reverse_sign(grade_b),
161                    InvolutionKind::GradeInvolution => grade_involution_sign(grade_b),
162                    InvolutionKind::CliffordConjugate => clifford_conjugate_sign(grade_b),
163                };
164                let total_sign = product_sign * inv_sign;
165
166                // Get symbolic values
167                let sym_a = symbols.get(&blade_a).unwrap();
168                let sym_b = symbols.get(&blade_b).unwrap();
169
170                // Create term: total_sign * a * b
171                let product = sym_a * sym_b;
172                let term = if total_sign > 0 { product } else { -product };
173                terms.push(term);
174            }
175        }
176
177        if terms.is_empty() {
178            Atom::num(0)
179        } else {
180            // Sort terms by string representation for deterministic output
181            terms.sort_by_cached_key(|t| t.to_string());
182            terms.into_iter().reduce(|acc, t| acc + t).unwrap()
183        }
184    }
185
186    /// Checks if an expression is zero.
187    fn is_zero(&self, expr: &Atom) -> bool {
188        // Check if the expression is the number 0
189        *expr == Atom::num(0)
190    }
191
192    /// Derives the antiproduct constraint for a type.
193    ///
194    /// Computes `u ⊟ antireverse(u)` symbolically and returns the non-pseudoscalar
195    /// terms that must equal zero.
196    ///
197    /// The antiproduct is defined as: `a ⊟ b = dual(dual(a) * dual(b))`
198    /// where dual(x) = x * I⁻¹ (right complement with pseudoscalar).
199    ///
200    /// # Returns
201    ///
202    /// - `Some(constraint)` if the type has non-trivial antiproduct constraints
203    /// - `None` if `u ⊟ antireverse(u)` is purely pseudoscalar (no constraints needed)
204    pub fn derive_antiproduct_constraint(
205        &self,
206        ty: &TypeSpec,
207        field_prefix: &str,
208    ) -> Option<DerivedConstraint> {
209        let dim = self.algebra.dim();
210
211        // Create symbolic variables for fields
212        let symbols = self.create_symbols(ty, field_prefix);
213
214        // Compute u ⊟ antireverse(u) for each output grade
215        let mut zero_expressions = Vec::new();
216        let mut constraint_grades = Vec::new();
217
218        // Check all grades from 0 to dim-1 (skip pseudoscalar grade dim)
219        for output_grade in 0..dim {
220            let expr = self.compute_antiproduct_antireverse_at_grade(ty, output_grade, &symbols);
221
222            // Check if the expression is non-trivial (not just zero)
223            if !self.is_zero(&expr) {
224                zero_expressions.push(expr);
225                constraint_grades.push(output_grade);
226            }
227        }
228
229        if zero_expressions.is_empty() {
230            None
231        } else {
232            Some(DerivedConstraint {
233                name: "antiproduct".to_string(),
234                zero_expressions,
235                constraint_grades,
236            })
237        }
238    }
239
240    /// Derives all constraints for a type (both geometric and antiproduct).
241    ///
242    /// Returns the combined set of constraint expressions that must equal zero.
243    /// Symbolically equivalent expressions are deduplicated using Symbolica.
244    pub fn derive_all_constraints(
245        &self,
246        ty: &TypeSpec,
247        field_prefix: &str,
248    ) -> Option<DerivedConstraint> {
249        let geometric = self.derive_geometric_constraint(ty, field_prefix);
250        let antiproduct = self.derive_antiproduct_constraint(ty, field_prefix);
251
252        match (geometric, antiproduct) {
253            (None, None) => None,
254            (Some(c), None) | (None, Some(c)) => Some(c),
255            (Some(geo), Some(anti)) => {
256                // Combine both constraints, deduplicating equivalent expressions
257                let mut all_exprs = geo.zero_expressions;
258                let mut all_grades = geo.constraint_grades;
259
260                // Add antiproduct expressions if not symbolically equivalent to existing ones
261                for (expr, grade) in anti
262                    .zero_expressions
263                    .into_iter()
264                    .zip(anti.constraint_grades)
265                {
266                    if !self.is_equivalent_to_any(&expr, &all_exprs) {
267                        all_exprs.push(expr);
268                        all_grades.push(grade);
269                    }
270                }
271
272                if all_exprs.is_empty() {
273                    None
274                } else {
275                    Some(DerivedConstraint {
276                        name: "combined".to_string(),
277                        zero_expressions: all_exprs,
278                        constraint_grades: all_grades,
279                    })
280                }
281            }
282        }
283    }
284
285    /// Checks if an expression is symbolically equivalent to any in a list.
286    ///
287    /// Two expressions are equivalent if their difference simplifies to zero.
288    fn is_equivalent_to_any(&self, expr: &Atom, existing: &[Atom]) -> bool {
289        use crate::symbolic::ExpressionSimplifier;
290        let simplifier = ExpressionSimplifier::new();
291
292        for other in existing {
293            let diff = expr - other;
294            let simplified = simplifier.simplify(&diff);
295            if self.is_zero(&simplified) {
296                return true;
297            }
298            // Also check if they're negatives of each other (same constraint)
299            let neg_diff = expr + other;
300            let neg_simplified = simplifier.simplify(&neg_diff);
301            if self.is_zero(&neg_simplified) {
302                return true;
303            }
304        }
305        false
306    }
307
308    /// Computes the expression for `u ⊟ antireverse(u)` at a specific output grade.
309    ///
310    /// The antiproduct is: `a ⊟ b = dual(dual(a) * dual(b))`
311    fn compute_antiproduct_antireverse_at_grade(
312        &self,
313        ty: &TypeSpec,
314        output_grade: usize,
315        symbols: &HashMap<usize, Atom>,
316    ) -> Atom {
317        let dim = self.algebra.dim();
318        let pseudoscalar = (1 << dim) - 1; // All bits set
319        let mut terms: Vec<Atom> = Vec::new();
320
321        for field_a in &ty.fields {
322            for field_b in &ty.fields {
323                let blade_a = field_a.blade_index;
324                let blade_b = field_b.blade_index;
325                let grade_b = Blade::from_index(blade_b).grade();
326
327                // Compute duals by XOR with pseudoscalar
328                let dual_a = pseudoscalar ^ blade_a;
329                let dual_b = pseudoscalar ^ blade_b;
330
331                // Get the product of duals
332                let (product_sign, dual_result) = self.table.geometric(dual_a, dual_b);
333
334                // Convert back from dual (the antiproduct result)
335                let result_blade = pseudoscalar ^ dual_result;
336
337                // Check if result is at the target grade
338                let result_grade = grade(result_blade);
339                if result_grade != output_grade || product_sign == 0 {
340                    continue;
341                }
342
343                // Apply antireverse sign to b (reverse sign of dual grade)
344                let antirev_sign = antireverse_sign(grade_b, dim);
345                let total_sign = product_sign * antirev_sign;
346
347                // Get symbolic values
348                let sym_a = symbols.get(&blade_a).unwrap();
349                let sym_b = symbols.get(&blade_b).unwrap();
350
351                // Create term: total_sign * a * b
352                let product = sym_a * sym_b;
353                let term = if total_sign > 0 { product } else { -product };
354                terms.push(term);
355            }
356        }
357
358        if terms.is_empty() {
359            Atom::num(0)
360        } else {
361            // Sort terms by string representation for deterministic output
362            terms.sort_by_cached_key(|t| t.to_string());
363            terms.into_iter().reduce(|acc, t| acc + t).unwrap()
364        }
365    }
366
367    /// Derives the scalar norm expression (scalar part of `u * reverse(u)`).
368    ///
369    /// This is useful for generating `norm_squared()` implementations.
370    pub fn derive_norm_squared(&self, ty: &TypeSpec, field_prefix: &str) -> Atom {
371        let symbols = self.create_symbols(ty, field_prefix);
372        self.compute_product_involution_at_grade(ty, 0, &symbols)
373    }
374
375    /// Derives the antiscalar norm expression (pseudoscalar part of `u ⊟ antireverse(u)`).
376    ///
377    /// This is useful for generating weight norm implementations in PGA.
378    pub fn derive_antinorm_squared(&self, ty: &TypeSpec, field_prefix: &str) -> Atom {
379        let dim = self.algebra.dim();
380        let symbols = self.create_symbols(ty, field_prefix);
381        self.compute_antiproduct_antireverse_at_grade(ty, dim, &symbols)
382    }
383
384    /// Derives the weight norm squared expression for PGA types.
385    ///
386    /// In PGA, the weight norm is the degenerate part of the element.
387    /// For points, this is the w coordinate. For planes, it's the d coefficient.
388    /// The weight norm squared is the sum of squares of all weight (degenerate) fields.
389    ///
390    /// Weight fields are those whose blade contains the degenerate basis vector (e0).
391    pub fn derive_weight_norm_squared(&self, ty: &TypeSpec, field_prefix: &str) -> Atom {
392        let symbols = self.create_symbols(ty, field_prefix);
393        // Get degenerate indices directly from algebra (supports arbitrary metric ordering)
394        let degenerate_indices: Vec<_> = self.algebra.degenerate_indices().collect();
395
396        let mut terms: Vec<Atom> = Vec::new();
397
398        for field in &ty.fields {
399            // Check if this blade contains a degenerate basis vector
400            let blade = field.blade_index;
401            let is_weight = degenerate_indices
402                .iter()
403                .any(|&idx| (blade & (1 << idx)) != 0);
404
405            if is_weight {
406                if let Some(sym) = symbols.get(&blade) {
407                    // Add sym²
408                    terms.push(sym * sym);
409                }
410            }
411        }
412
413        if terms.is_empty() {
414            Atom::num(0)
415        } else {
416            // Sort terms by string representation for deterministic output
417            terms.sort_by_cached_key(|t| t.to_string());
418            terms.into_iter().reduce(|acc, t| acc + t).unwrap()
419        }
420    }
421
422    /// Derives the bulk norm squared expression for PGA types.
423    ///
424    /// In PGA, the bulk norm is the non-degenerate part of the element.
425    /// The bulk norm squared is the sum of squares of all bulk (non-degenerate) fields.
426    ///
427    /// Bulk fields are those whose blade does NOT contain the degenerate basis vector (e0).
428    pub fn derive_bulk_norm_squared(&self, ty: &TypeSpec, field_prefix: &str) -> Atom {
429        let symbols = self.create_symbols(ty, field_prefix);
430        // Get degenerate indices directly from algebra (supports arbitrary metric ordering)
431        let degenerate_indices: Vec<_> = self.algebra.degenerate_indices().collect();
432
433        let mut terms: Vec<Atom> = Vec::new();
434
435        for field in &ty.fields {
436            // Check if this blade does NOT contain any degenerate basis vectors
437            let blade = field.blade_index;
438            let is_bulk = degenerate_indices
439                .iter()
440                .all(|&idx| (blade & (1 << idx)) == 0);
441
442            if is_bulk {
443                if let Some(sym) = symbols.get(&blade) {
444                    // Add sym²
445                    terms.push(sym * sym);
446                }
447            }
448        }
449
450        if terms.is_empty() {
451            Atom::num(0)
452        } else {
453            // Sort terms by string representation for deterministic output
454            terms.sort_by_cached_key(|t| t.to_string());
455            terms.into_iter().reduce(|acc, t| acc + t).unwrap()
456        }
457    }
458
459    /// Derives individual weight component expressions for the Ideal wrapper.
460    ///
461    /// For Ideal elements (elements at infinity in PGA), all weight components
462    /// must be zero. This returns a list of the symbolic expressions for each
463    /// weight field, which can be used as constraints in Groebner simplification.
464    pub fn derive_weight_components(&self, ty: &TypeSpec, field_prefix: &str) -> Vec<Atom> {
465        let symbols = self.create_symbols(ty, field_prefix);
466        // Get degenerate indices directly from algebra (supports arbitrary metric ordering)
467        let degenerate_indices: Vec<_> = self.algebra.degenerate_indices().collect();
468
469        let mut components = Vec::new();
470
471        for field in &ty.fields {
472            // Check if this blade contains a degenerate basis vector
473            let blade = field.blade_index;
474            let is_weight = degenerate_indices
475                .iter()
476                .any(|&idx| (blade & (1 << idx)) != 0);
477
478            if is_weight {
479                if let Some(sym) = symbols.get(&blade) {
480                    components.push(sym.clone());
481                }
482            }
483        }
484
485        components
486    }
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492    use crate::spec::parse_spec;
493
494    #[test]
495    fn symbolica_rotor_has_no_derived_constraints() {
496        // Euclidean rotor: u * reverse(u) should be purely scalar
497        // (no non-scalar constraints needed because cross-terms cancel)
498        let spec = parse_spec(
499            r#"
500            [algebra]
501            name = "test"
502            complete = false
503
504            [signature]
505            positive = ["e1", "e2", "e3"]
506
507            [types.Rotor]
508            grades = [0, 2]
509            field_map = [
510                { name = "s", blade = "s" },
511                { name = "xy", blade = "e12" },
512                { name = "xz", blade = "e13" },
513                { name = "yz", blade = "e23" }
514            ]
515            "#,
516        )
517        .unwrap();
518
519        let algebra = Algebra::euclidean(3);
520        let deriver = ConstraintDeriver::new(&algebra, InvolutionKind::Reverse);
521        let rotor = spec.types.iter().find(|t| t.name == "Rotor").unwrap();
522
523        let constraint = deriver.derive_geometric_constraint(rotor, "u");
524
525        // For Euclidean rotors, cross-terms cancel due to anticommutativity
526        // so there should be no derived constraint
527        assert!(
528            constraint.is_none(),
529            "Euclidean rotor should have no derived geometric constraint"
530        );
531    }
532
533    #[test]
534    fn symbolica_vector_norm_squared() {
535        let spec = parse_spec(
536            r#"
537            [algebra]
538            name = "test"
539            complete = false
540
541            [signature]
542            positive = ["e1", "e2", "e3"]
543
544            [types.Vector]
545            grades = [1]
546            field_map = [
547                { name = "x", blade = "e1" },
548                { name = "y", blade = "e2" },
549                { name = "z", blade = "e3" }
550            ]
551            "#,
552        )
553        .unwrap();
554
555        let algebra = Algebra::euclidean(3);
556        let deriver = ConstraintDeriver::new(&algebra, InvolutionKind::Reverse);
557        let vector = spec.types.iter().find(|t| t.name == "Vector").unwrap();
558
559        let norm_sq = deriver.derive_norm_squared(vector, "v");
560
561        // Should be x² + y² + z²
562        let expr_str = format!("{}", norm_sq);
563        assert!(expr_str.contains("v_x"), "norm² should contain v_x");
564        assert!(expr_str.contains("v_y"), "norm² should contain v_y");
565        assert!(expr_str.contains("v_z"), "norm² should contain v_z");
566    }
567
568    #[test]
569    fn symbolica_pga_motor_has_study_condition() {
570        // PGA motor: grades [0, 2, 4] - should have Study condition constraint
571        // The Study condition arises from the pseudoscalar term in motor * reverse(motor)
572        let spec = parse_spec(
573            r#"
574            [algebra]
575            name = "pga3"
576            complete = false
577
578            [signature]
579            positive = ["e1", "e2", "e3"]
580            zero = ["e0"]
581
582            [types.Motor]
583            grades = [0, 2, 4]
584            field_map = [
585                { name = "s", blade = "s" },
586                { name = "e01", blade = "e14" },
587                { name = "e02", blade = "e24" },
588                { name = "e03", blade = "e34" },
589                { name = "e12", blade = "e12" },
590                { name = "e31", blade = "e13" },
591                { name = "e23", blade = "e23" },
592                { name = "e0123", blade = "e1234" }
593            ]
594            "#,
595        )
596        .unwrap();
597
598        let algebra = Algebra::pga(3);
599        let deriver = ConstraintDeriver::new(&algebra, InvolutionKind::Reverse);
600        let motor = spec.types.iter().find(|t| t.name == "Motor").unwrap();
601
602        let constraint = deriver.derive_geometric_constraint(motor, "m");
603
604        // PGA motors should have a derived constraint (the Study condition)
605        assert!(
606            constraint.is_some(),
607            "PGA motor should have derived geometric constraint (Study condition)"
608        );
609
610        let constraint = constraint.unwrap();
611        // The Study condition is on the pseudoscalar (grade 4) term
612        assert!(
613            constraint.constraint_grades.contains(&4),
614            "Study condition should involve grade 4 (pseudoscalar)"
615        );
616    }
617
618    #[test]
619    fn symbolica_pga_bivector_has_plucker_condition() {
620        // PGA line (bivector): grade [2] - should have Plücker condition
621        // In 4D PGA, bivectors don't automatically satisfy geometric constraint
622        let spec = parse_spec(
623            r#"
624            [algebra]
625            name = "pga3"
626            complete = false
627
628            [signature]
629            positive = ["e1", "e2", "e3"]
630            zero = ["e0"]
631
632            [types.Line]
633            grades = [2]
634            field_map = [
635                { name = "e01", blade = "e14" },
636                { name = "e02", blade = "e24" },
637                { name = "e03", blade = "e34" },
638                { name = "e12", blade = "e12" },
639                { name = "e31", blade = "e13" },
640                { name = "e23", blade = "e23" }
641            ]
642            "#,
643        )
644        .unwrap();
645
646        let algebra = Algebra::pga(3);
647        let deriver = ConstraintDeriver::new(&algebra, InvolutionKind::Reverse);
648        let line = spec.types.iter().find(|t| t.name == "Line").unwrap();
649
650        let constraint = deriver.derive_geometric_constraint(line, "l");
651
652        // PGA bivectors should have a derived constraint (Plücker condition)
653        // because disjoint bivectors commute in 4D
654        assert!(
655            constraint.is_some(),
656            "PGA line should have derived geometric constraint (Plücker condition)"
657        );
658    }
659
660    #[test]
661    fn symbolica_euclidean_rotor_all_constraints_deduplicated() {
662        // For Euclidean rotors, both geometric and antiproduct constraints
663        // should yield no constraints (cross-terms cancel in both cases)
664        let spec = parse_spec(
665            r#"
666            [algebra]
667            name = "test"
668            complete = false
669
670            [signature]
671            positive = ["e1", "e2", "e3"]
672
673            [types.Rotor]
674            grades = [0, 2]
675            field_map = [
676                { name = "s", blade = "s" },
677                { name = "xy", blade = "e12" },
678                { name = "xz", blade = "e13" },
679                { name = "yz", blade = "e23" }
680            ]
681            "#,
682        )
683        .unwrap();
684
685        let algebra = Algebra::euclidean(3);
686        let deriver = ConstraintDeriver::new(&algebra, InvolutionKind::Reverse);
687        let rotor = spec.types.iter().find(|t| t.name == "Rotor").unwrap();
688
689        let all_constraints = deriver.derive_all_constraints(rotor, "u");
690
691        // Euclidean rotors satisfy both constraints trivially
692        assert!(
693            all_constraints.is_none(),
694            "Euclidean rotor should have no combined constraints"
695        );
696    }
697
698    #[test]
699    fn symbolica_pga_motor_all_constraints() {
700        // For PGA motors, derive_all_constraints should combine geometric
701        // and antiproduct constraints with deduplication
702        let spec = parse_spec(
703            r#"
704            [algebra]
705            name = "pga3"
706            complete = false
707
708            [signature]
709            positive = ["e1", "e2", "e3"]
710            zero = ["e0"]
711
712            [types.Motor]
713            grades = [0, 2, 4]
714            field_map = [
715                { name = "s", blade = "s" },
716                { name = "e01", blade = "e14" },
717                { name = "e02", blade = "e24" },
718                { name = "e03", blade = "e34" },
719                { name = "e12", blade = "e12" },
720                { name = "e31", blade = "e13" },
721                { name = "e23", blade = "e23" },
722                { name = "e0123", blade = "e1234" }
723            ]
724            "#,
725        )
726        .unwrap();
727
728        let algebra = Algebra::pga(3);
729        let deriver = ConstraintDeriver::new(&algebra, InvolutionKind::Reverse);
730        let motor = spec.types.iter().find(|t| t.name == "Motor").unwrap();
731
732        let all_constraints = deriver.derive_all_constraints(motor, "m");
733
734        // PGA motors should have combined constraints
735        assert!(
736            all_constraints.is_some(),
737            "PGA motor should have combined constraints"
738        );
739
740        let constraint = all_constraints.unwrap();
741        // Print constraint info for debugging
742        eprintln!(
743            "PGA Motor combined constraint grades: {:?}",
744            constraint.constraint_grades
745        );
746        eprintln!(
747            "Number of constraint expressions: {}",
748            constraint.zero_expressions.len()
749        );
750    }
751}