Skip to main content

hekate_program/constraint/
mod.rs

1// SPDX-FileCopyrightText: 2026 Andrei Kochergin <andrei@oumuamua.dev>
2// SPDX-FileCopyrightText: 2026 Oumuamua Labs <info@oumuamua.dev>
3// SPDX-License-Identifier: AGPL-3.0-only
4
5//! SYMBOLIC CONSTRAINTS DEFINITION
6
7use crate::ProgramCell;
8use alloc::vec;
9use alloc::vec::Vec;
10use hashbrown::HashMap;
11use hekate_core::errors::Error;
12use hekate_math::{Flat, HardwareField, TowerField};
13
14pub mod builder;
15
16/// Represents a single term in a polynomial constraint.
17/// Form: `coeff * product(cells)`
18/// Example: `5 * x_curr * y_next`
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct ConstraintTerm<F> {
21    pub coeff: F,
22    pub poly_ind: Vec<ProgramCell>, // Multiplicands (variables)
23}
24
25impl<F: TowerField> ConstraintTerm<F> {
26    pub fn new(coeff: F, cells: Vec<ProgramCell>) -> Self {
27        Self {
28            coeff,
29            poly_ind: cells,
30        }
31    }
32}
33
34/// Represents a full constraint equation:
35/// sum(terms) == 0.
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct Constraint<F> {
38    pub terms: Vec<ConstraintTerm<F>>,
39}
40
41impl<F: TowerField> Constraint<F> {
42    pub fn new(terms: Vec<ConstraintTerm<F>>) -> Self {
43        Self { terms }
44    }
45}
46
47/// Source of the value a boundary constraint
48/// pins `Trace(row_idx, col_idx)` to.
49#[derive(Clone, Debug, PartialEq, Eq)]
50pub enum BoundaryTarget<F> {
51    /// `instance.public_inputs[idx]`. Main table only;
52    /// `ChipletDef::from_air` rejects this variant on chiplets.
53    PublicInput(usize),
54
55    /// Literal field value. Required for
56    /// chiplets, optional for main programs.
57    Constant(F),
58}
59
60/// Pins a single trace cell to a
61/// target value at a specific row.
62#[derive(Clone, Debug)]
63pub struct BoundaryConstraint<F> {
64    pub col_idx: usize,
65    pub row_idx: usize,
66    pub target: BoundaryTarget<F>,
67}
68
69impl<F> BoundaryConstraint<F> {
70    pub fn with_constant(col_idx: usize, row_idx: usize, val: F) -> Self {
71        Self {
72            col_idx,
73            row_idx,
74            target: BoundaryTarget::Constant(val),
75        }
76    }
77
78    pub fn with_public_input(col_idx: usize, row_idx: usize, input_idx: usize) -> Self {
79        Self {
80            col_idx,
81            row_idx,
82            target: BoundaryTarget::PublicInput(input_idx),
83        }
84    }
85}
86
87impl<F: TowerField> BoundaryConstraint<F> {
88    /// Read the pin value out of `target`.
89    /// `Constant` returns the literal;
90    /// `PublicInput(idx)` reads
91    /// `instance.public_inputs[idx]`.
92    pub fn resolve_target(
93        &self,
94        instance: &crate::ProgramInstance<F>,
95    ) -> hekate_core::errors::Result<F> {
96        match &self.target {
97            BoundaryTarget::Constant(v) => Ok(*v),
98            BoundaryTarget::PublicInput(idx) => {
99                instance.public_input(*idx).ok_or(Error::Protocol {
100                    protocol: "boundary",
101                    message: "public_input_idx out of bounds",
102                })
103            }
104        }
105    }
106
107    /// Constant values are load-bearing:
108    /// a malicious prover could swap them between
109    /// sessions while keeping the chiplet root valid
110    /// unless the transcript binds them.
111    pub fn absorb_into<H: hekate_crypto::Hasher>(
112        &self,
113        transcript: &mut hekate_crypto::transcript::Transcript<H>,
114    ) {
115        transcript.append_u64(b"chiplet_bnd_col", self.col_idx as u64);
116        transcript.append_u64(b"chiplet_bnd_row", self.row_idx as u64);
117
118        match &self.target {
119            BoundaryTarget::PublicInput(idx) => {
120                transcript.append_u64(b"chiplet_bnd_kind", 0);
121                transcript.append_u64(b"chiplet_bnd_pub", *idx as u64);
122            }
123            BoundaryTarget::Constant(v) => {
124                transcript.append_u64(b"chiplet_bnd_kind", 1);
125                transcript.append_field(b"chiplet_bnd_const", *v);
126            }
127        }
128    }
129}
130
131// =================================================================
132// CONSTRAINT IR
133// =================================================================
134
135/// Index into a `ConstraintArena`. Cheap to copy.
136#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
137pub struct ExprId(pub u32);
138
139/// A single node in the constraint AST-DAG.
140/// All variants are algebraic operations over GF(2^128).
141#[derive(Clone, Debug)]
142pub enum ConstraintExpr<F> {
143    /// A trace cell reference (column + current/next row).
144    Cell(ProgramCell),
145
146    /// A field constant.
147    Const(F),
148
149    /// Addition of two sub-expressions.
150    /// In GF(2^N) this is XOR.
151    Add(ExprId, ExprId),
152
153    /// Multiplication of two sub-expressions.
154    Mul(ExprId, ExprId),
155
156    /// Scalar multiplication:
157    /// coeff * expr.
158    ///
159    /// Avoids creating a Const node + Mul
160    /// pair for the common case.
161    Scale(F, ExprId),
162
163    /// Sum of N sub-expressions.
164    /// Exists specifically for Theta-style linear
165    /// combinations to avoid deep Add chains.
166    /// Evaluates to sum of children[i].
167    Sum(Vec<ExprId>),
168}
169
170/// Arena-allocated constraint DAG.
171/// Nodes reference each other by
172/// `ExprId` (index into `nodes`).
173///
174/// Cell nodes are automatically deduplicated:
175/// calling `cell()` twice with the same `ProgramCell`
176/// returns the same `ExprId`. This is mandatory, the
177/// downstream compiler maps ExprId to poly index,
178/// so duplicate cells would create duplicate polys
179/// in VirtualPoly and bloat Sumcheck evaluation.
180pub struct ConstraintArena<F> {
181    nodes: Vec<ConstraintExpr<F>>,
182
183    /// Dedup cache for Cell nodes.
184    /// Same ProgramCell -> same ExprId.
185    cell_cache: HashMap<ProgramCell, ExprId>,
186}
187
188impl<F: TowerField> Default for ConstraintArena<F> {
189    fn default() -> Self {
190        Self::new()
191    }
192}
193
194impl<F: TowerField> ConstraintArena<F> {
195    pub fn new() -> Self {
196        Self {
197            nodes: Vec::new(),
198            cell_cache: HashMap::new(),
199        }
200    }
201
202    /// Allocate a new expression node.
203    /// Returns its ID.
204    pub fn alloc(&mut self, expr: ConstraintExpr<F>) -> ExprId {
205        let id = ExprId(self.nodes.len() as u32);
206        self.nodes.push(expr);
207
208        id
209    }
210
211    /// Read a node by ID.
212    pub fn get(&self, id: ExprId) -> &ConstraintExpr<F> {
213        &self.nodes[id.0 as usize]
214    }
215
216    /// Total number of nodes.
217    pub fn len(&self) -> usize {
218        self.nodes.len()
219    }
220
221    pub fn is_empty(&self) -> bool {
222        self.nodes.is_empty()
223    }
224
225    /// Shift all Cell node col_idx by `offset`.
226    /// Used when embedding a chiplet's AST into
227    /// a combined program where column indices
228    /// are offset.
229    pub fn shift_cells(&mut self, offset: usize) {
230        for node in &mut self.nodes {
231            if let ConstraintExpr::Cell(cell) = node {
232                cell.col_idx += offset;
233            }
234        }
235
236        let old_cache = core::mem::take(&mut self.cell_cache);
237        for (mut cell, id) in old_cache {
238            cell.col_idx += offset;
239            self.cell_cache.insert(cell, id);
240        }
241    }
242
243    /// Create or reuse a cell reference.
244    /// Automatically deduplicates:
245    /// same ProgramCell -> same ExprId.
246    pub fn cell(&mut self, cell: ProgramCell) -> ExprId {
247        if let Some(&id) = self.cell_cache.get(&cell) {
248            return id;
249        }
250
251        let id = self.alloc(ConstraintExpr::Cell(cell));
252        self.cell_cache.insert(cell, id);
253
254        id
255    }
256
257    /// Create a constant.
258    pub fn constant(&mut self, val: F) -> ExprId {
259        self.alloc(ConstraintExpr::Const(val))
260    }
261
262    /// a + b
263    pub fn add(&mut self, a: ExprId, b: ExprId) -> ExprId {
264        self.alloc(ConstraintExpr::Add(a, b))
265    }
266
267    /// a * b
268    pub fn mul(&mut self, a: ExprId, b: ExprId) -> ExprId {
269        self.alloc(ConstraintExpr::Mul(a, b))
270    }
271
272    /// coeff * a
273    pub fn scale(&mut self, coeff: F, a: ExprId) -> ExprId {
274        self.alloc(ConstraintExpr::Scale(coeff, a))
275    }
276
277    /// Sum of multiple expressions.
278    pub fn sum(&mut self, children: Vec<ExprId>) -> ExprId {
279        self.alloc(ConstraintExpr::Sum(children))
280    }
281}
282
283/// A full constraint system in AST form.
284/// `roots` are the top-level constraint expressions.
285/// Each root must evaluate to 0 for a valid trace.
286pub struct ConstraintAst<F> {
287    pub arena: ConstraintArena<F>,
288    pub roots: Vec<ExprId>,
289    pub labels: Vec<Option<&'static str>>,
290}
291
292impl<F: TowerField> Clone for ConstraintArena<F> {
293    fn clone(&self) -> Self {
294        Self {
295            nodes: self.nodes.clone(),
296            cell_cache: self.cell_cache.clone(),
297        }
298    }
299}
300
301impl<F: TowerField> Clone for ConstraintAst<F> {
302    fn clone(&self) -> Self {
303        Self {
304            arena: self.arena.clone(),
305            roots: self.roots.clone(),
306            labels: self.labels.clone(),
307        }
308    }
309}
310
311impl<F: TowerField> ConstraintAst<F> {
312    /// Maximum polynomial degree
313    /// across all constraint roots.
314    pub fn max_degree(&self) -> usize {
315        if self.arena.is_empty() {
316            return 0;
317        }
318
319        let n = self.arena.len();
320        let mut deg: Vec<usize> = Vec::with_capacity(n);
321
322        for i in 0..n {
323            let d = match self.arena.get(ExprId(i as u32)) {
324                ConstraintExpr::Cell(_) => 1,
325                ConstraintExpr::Const(_) => 0,
326                ConstraintExpr::Add(a, b) => deg[a.0 as usize].max(deg[b.0 as usize]),
327                ConstraintExpr::Mul(a, b) => deg[a.0 as usize] + deg[b.0 as usize],
328                ConstraintExpr::Scale(_, a) => deg[a.0 as usize],
329                ConstraintExpr::Sum(children) => children
330                    .iter()
331                    .map(|c| deg[c.0 as usize])
332                    .max()
333                    .unwrap_or(0),
334            };
335
336            deg.push(d);
337        }
338
339        self.roots
340            .iter()
341            .map(|r| deg[r.0 as usize])
342            .max()
343            .unwrap_or(0)
344    }
345
346    /// Hardware-basis `Const`/`Scale` coefficients
347    /// indexed by `ExprId`; other slots hold zero.
348    pub fn precompute_hardware_consts(&self) -> Vec<Flat<F>>
349    where
350        F: HardwareField,
351    {
352        let n = self.arena.len();
353
354        let mut consts: Vec<Flat<F>> = Vec::with_capacity(n);
355        for i in 0..n {
356            let c = match self.arena.get(ExprId(i as u32)) {
357                ConstraintExpr::Const(k) => k.to_hardware(),
358                ConstraintExpr::Scale(k, _) => k.to_hardware(),
359                _ => Flat::from_raw(F::ZERO),
360            };
361
362            consts.push(c);
363        }
364
365        consts
366    }
367
368    /// Evaluate each constraint
369    /// root at a single point.
370    pub fn evaluate(&self, current_row: &[Flat<F>], next_row: &[Flat<F>]) -> Vec<Flat<F>>
371    where
372        F: HardwareField,
373    {
374        let consts = self.precompute_hardware_consts();
375        let mut buf: Vec<Flat<F>> = Vec::with_capacity(self.arena.len());
376
377        self.evaluate_into(&consts, current_row, next_row, &mut buf);
378
379        self.roots.iter().map(|r| buf[r.0 as usize]).collect()
380    }
381
382    /// `consts` must come from the same arena
383    /// `self.precompute_hardware_consts()`.
384    pub fn evaluate_into(
385        &self,
386        consts: &[Flat<F>],
387        current_row: &[Flat<F>],
388        next_row: &[Flat<F>],
389        buf: &mut Vec<Flat<F>>,
390    ) where
391        F: HardwareField,
392    {
393        buf.clear();
394
395        for (i, &coeff) in consts.iter().enumerate() {
396            let v = match self.arena.get(ExprId(i as u32)) {
397                ConstraintExpr::Cell(cell) => {
398                    if cell.next_row {
399                        next_row[cell.col_idx]
400                    } else {
401                        current_row[cell.col_idx]
402                    }
403                }
404                ConstraintExpr::Const(_) => coeff,
405                ConstraintExpr::Add(a, b) => buf[a.0 as usize] + buf[b.0 as usize],
406                ConstraintExpr::Mul(a, b) => buf[a.0 as usize] * buf[b.0 as usize],
407                ConstraintExpr::Scale(_, a) => coeff * buf[a.0 as usize],
408                ConstraintExpr::Sum(children) => {
409                    let mut s = Flat::from_raw(F::ZERO);
410                    for c in children {
411                        s += buf[c.0 as usize];
412                    }
413
414                    s
415                }
416            };
417
418            buf.push(v);
419        }
420    }
421
422    /// Merge another constraint AST into this one.
423    pub fn merge(&mut self, other: ConstraintAst<F>) {
424        let mut id_map: Vec<ExprId> = Vec::with_capacity(other.arena.len());
425        for node in other.arena.nodes {
426            let new_id = match node {
427                ConstraintExpr::Cell(cell) => self.arena.cell(cell),
428                ConstraintExpr::Const(val) => self.arena.constant(val),
429                ConstraintExpr::Add(a, b) => {
430                    self.arena.add(id_map[a.0 as usize], id_map[b.0 as usize])
431                }
432                ConstraintExpr::Mul(a, b) => {
433                    self.arena.mul(id_map[a.0 as usize], id_map[b.0 as usize])
434                }
435                ConstraintExpr::Scale(coeff, inner) => {
436                    self.arena.scale(coeff, id_map[inner.0 as usize])
437                }
438                ConstraintExpr::Sum(children) => {
439                    let remapped: Vec<ExprId> =
440                        children.into_iter().map(|c| id_map[c.0 as usize]).collect();
441                    self.arena.sum(remapped)
442                }
443            };
444
445            id_map.push(new_id);
446        }
447
448        for (root, label) in other.roots.into_iter().zip(other.labels) {
449            self.roots.push(id_map[root.0 as usize]);
450            self.labels.push(label);
451        }
452    }
453
454    /// Convert AST to flat `Vec<Constraint<F>>`.
455    /// Expands the DAG into sum-of-products form.
456    pub fn to_constraints(&self) -> Vec<Constraint<F>> {
457        /// A flat term:
458        /// coefficient × product of cells.
459        type FlatTerm<F> = (F, Vec<ProgramCell>);
460
461        fn expand<F: TowerField>(
462            arena: &ConstraintArena<F>,
463            id: ExprId,
464            cache: &mut Vec<Option<Vec<FlatTerm<F>>>>,
465        ) -> Vec<FlatTerm<F>> {
466            if let Some(ref cached) = cache[id.0 as usize] {
467                return cached.clone();
468            }
469
470            let result = match arena.get(id) {
471                ConstraintExpr::Cell(cell) => {
472                    vec![(F::ONE, vec![*cell])]
473                }
474                ConstraintExpr::Const(k) => {
475                    vec![(*k, vec![])]
476                }
477                ConstraintExpr::Add(a, b) => {
478                    let mut terms = expand(arena, *a, cache);
479                    terms.extend(expand(arena, *b, cache));
480
481                    terms
482                }
483                ConstraintExpr::Mul(a, b) => {
484                    let left = expand(arena, *a, cache);
485                    let right = expand(arena, *b, cache);
486
487                    let mut terms = Vec::with_capacity(left.len() * right.len());
488                    for (lc, lp) in &left {
489                        for (rc, rp) in &right {
490                            let coeff = *lc * *rc;
491                            let mut cells = lp.clone();
492
493                            cells.extend_from_slice(rp);
494                            terms.push((coeff, cells));
495                        }
496                    }
497
498                    terms
499                }
500                ConstraintExpr::Scale(k, a) => {
501                    let inner = expand(arena, *a, cache);
502                    inner
503                        .into_iter()
504                        .map(|(c, cells)| (*k * c, cells))
505                        .collect()
506                }
507                ConstraintExpr::Sum(children) => {
508                    let mut terms = Vec::new();
509                    for child in children {
510                        terms.extend(expand(arena, *child, cache));
511                    }
512
513                    terms
514                }
515            };
516
517            cache[id.0 as usize] = Some(result.clone());
518
519            result
520        }
521
522        let n = self.arena.len();
523        let mut cache: Vec<Option<Vec<FlatTerm<F>>>> = vec![None; n];
524
525        self.roots
526            .iter()
527            .map(|root| {
528                let flat_terms = expand(&self.arena, *root, &mut cache);
529                Constraint::new(
530                    flat_terms
531                        .into_iter()
532                        .map(|(coeff, cells)| ConstraintTerm::new(coeff, cells))
533                        .collect(),
534                )
535            })
536            .collect()
537    }
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543    use crate::constraint::ConstraintExpr;
544    use crate::constraint::builder::ConstraintSystem;
545    use crate::{Air, Program};
546    use hekate_core::trace::ColumnType;
547    use hekate_math::{Block128, Flat};
548
549    type F = Block128;
550
551    #[derive(Clone)]
552    struct TestFibProgram;
553
554    impl Air<F> for TestFibProgram {
555        fn num_columns(&self) -> usize {
556            3
557        }
558
559        fn column_layout(&self) -> &[ColumnType] {
560            &[ColumnType::B32, ColumnType::B32, ColumnType::Bit]
561        }
562
563        fn constraint_ast(&self) -> ConstraintAst<F> {
564            let cs = ConstraintSystem::<F>::new();
565
566            let [a, b, q] = [cs.col(0), cs.col(1), cs.col(2)];
567            let [na, nb] = [cs.next(0), cs.next(1)];
568
569            cs.constrain(q * (na + b));
570            cs.constrain(q * (nb + a + b));
571
572            cs.build()
573        }
574    }
575
576    impl Program<F> for TestFibProgram {}
577
578    #[test]
579    fn default_constraint_ast_produces_correct_roots() {
580        let program = TestFibProgram;
581        let ast = program.constraint_ast();
582
583        // 2 constraints -> 2 roots
584        assert_eq!(ast.roots.len(), 2);
585
586        // c1 has 2 terms, c2 has 3 terms.
587        // Each term: 1 Const + 2 Cell + 2 Mul = 5 nodes
588        // c1: 2 terms * 5 + 1 Sum = 11 nodes (but cells are deduped)
589        // Verify non-empty and structurally sound.
590        assert!(!ast.arena.is_empty());
591
592        // Both roots should be Sum or product nodes
593        for &root in &ast.roots {
594            let node = ast.arena.get(root);
595            match node {
596                ConstraintExpr::Sum(children) => {
597                    assert!(!children.is_empty());
598                }
599                ConstraintExpr::Mul(_, _) => {
600                    // single-term constraint would be a Mul
601                }
602                _ => panic!("Root should be Sum or Mul, got {:?}", node),
603            }
604        }
605    }
606
607    #[test]
608    fn cell_dedup_works() {
609        let mut arena = ConstraintArena::<F>::new();
610
611        let cell_a = ProgramCell::current(0);
612        let cell_b = ProgramCell::current(0);
613        let cell_c = ProgramCell::next(0);
614
615        let id_a = arena.cell(cell_a);
616        let id_b = arena.cell(cell_b);
617        let id_c = arena.cell(cell_c);
618
619        // Same cell -> same ExprId
620        assert_eq!(id_a, id_b);
621        // Different cell -> different ExprId
622        assert_ne!(id_a, id_c);
623        // Only 2 nodes allocated, not 3
624        assert_eq!(arena.len(), 2);
625    }
626
627    #[test]
628    fn dag_sharing_reduces_node_count() {
629        let mut arena = ConstraintArena::<F>::new();
630
631        // Build a shared sub-expression: theta = a + b + c
632        let a = arena.cell(ProgramCell::current(0));
633        let b = arena.cell(ProgramCell::current(1));
634        let c = arena.cell(ProgramCell::current(2));
635        let theta = arena.sum(vec![a, b, c]);
636
637        // Use theta in two different constraints (DAG sharing)
638        let d = arena.cell(ProgramCell::current(3));
639        let expr1 = arena.mul(theta, d);
640        let expr2 = arena.mul(theta, a); // reuses both theta and a
641
642        let dag_node_count = arena.len();
643        // 4 cells + 1 sum + 2 muls = 7 nodes
644        assert_eq!(dag_node_count, 7);
645
646        // Without sharing (tree), theta would be duplicated:
647        // 4 cells + 2 sums + 2 muls = 8 nodes minimum
648        // (and the cells inside the second theta would also duplicate)
649        // Actually: 3 cells * 2 + 1 extra cell + 2 sums + 2 muls = 10 nodes
650        // DAG: 7 < 10 tree nodes
651        assert!(dag_node_count < 10);
652
653        // Verify both expressions reference the same theta
654        match arena.get(expr1) {
655            ConstraintExpr::Mul(lhs, _) => assert_eq!(*lhs, theta),
656            _ => panic!("Expected Mul"),
657        }
658
659        match arena.get(expr2) {
660            ConstraintExpr::Mul(lhs, rhs) => {
661                assert_eq!(*lhs, theta);
662                assert_eq!(*rhs, a);
663            }
664            _ => panic!("Expected Mul"),
665        }
666    }
667
668    #[test]
669    fn default_constraint_ast_node_count_matches_flat() {
670        let program = TestFibProgram;
671        let flat = program.constraints();
672        let ast = program.constraint_ast();
673
674        // Count total cells across flat constraints
675        let mut flat_cell_count = 0;
676        for c in &flat {
677            for t in &c.terms {
678                flat_cell_count += t.poly_ind.len();
679            }
680        }
681
682        // With dedup, AST cell nodes <= flat cell references
683        let ast_cell_count = ast
684            .arena
685            .nodes
686            .iter()
687            .filter(|n| matches!(n, ConstraintExpr::Cell(_)))
688            .count();
689
690        assert!(ast_cell_count <= flat_cell_count);
691
692        // Fib shares ProgramCell::current(2) across all terms.
693        // 5 total term-cells reference current(2), but dedup
694        // means only 1 Cell node for it.
695        // Unique cells: current(0), current(1), current(2), next(0), next(1) = 5
696        assert_eq!(ast_cell_count, 5);
697    }
698
699    #[test]
700    fn empty_constraint_produces_empty_ast() {
701        #[derive(Clone)]
702        struct EmptyProgram;
703
704        impl Air<F> for EmptyProgram {
705            fn num_columns(&self) -> usize {
706                0
707            }
708
709            fn column_layout(&self) -> &[ColumnType] {
710                &[]
711            }
712
713            fn constraint_ast(&self) -> ConstraintAst<F> {
714                ConstraintSystem::<F>::new().build()
715            }
716        }
717
718        impl Program<F> for EmptyProgram {}
719
720        let ast = EmptyProgram.constraint_ast();
721        assert!(ast.roots.is_empty());
722        assert!(ast.arena.is_empty());
723    }
724
725    #[test]
726    fn single_term_constraint_no_sum_wrapper() {
727        #[derive(Clone)]
728        struct SingleTermProgram;
729
730        impl Air<F> for SingleTermProgram {
731            fn num_columns(&self) -> usize {
732                2
733            }
734
735            fn column_layout(&self) -> &[ColumnType] {
736                &[ColumnType::B128, ColumnType::B128]
737            }
738
739            fn constraint_ast(&self) -> ConstraintAst<F> {
740                let cs = ConstraintSystem::<F>::new();
741                cs.constrain(cs.col(0) * cs.col(1));
742
743                cs.build()
744            }
745        }
746
747        impl Program<F> for SingleTermProgram {}
748
749        let ast = SingleTermProgram.constraint_ast();
750        assert_eq!(ast.roots.len(), 1);
751
752        // Single term: Const * cell0 * cell1 -> chain of Mul, no Sum
753        match ast.arena.get(ast.roots[0]) {
754            ConstraintExpr::Mul(_, _) => {} // correct
755            other => panic!("Expected Mul for single-term, got {:?}", other),
756        }
757    }
758
759    // =========================================================
760    // ConstraintAst method tests
761    // =========================================================
762
763    #[test]
764    fn max_degree_fibonacci() {
765        let program = TestFibProgram;
766        let ast = program.constraint_ast();
767
768        // Fib constraints: q * next_a (degree 2), q * curr_b (degree 2)
769        // Default AST adds Const(ONE) * cell * cell per term → degree 2 + Const
770        // Const has degree 0, so each term is Mul chain: 0 + 1 + 1 = 2
771        // The AST max degree should match the flat form.
772        let flat = program.constraints();
773        let flat_max = flat
774            .iter()
775            .flat_map(|c| c.terms.iter())
776            .map(|t| t.poly_ind.len())
777            .max()
778            .unwrap_or(0);
779
780        assert_eq!(ast.max_degree(), flat_max);
781    }
782
783    #[test]
784    fn max_degree_empty() {
785        let ast = ConstraintAst::<F> {
786            arena: ConstraintArena::new(),
787            roots: Vec::new(),
788            labels: Vec::new(),
789        };
790        assert_eq!(ast.max_degree(), 0);
791    }
792
793    #[test]
794    fn max_degree_builder_mul_chain() {
795        use crate::constraint::builder::ConstraintSystem;
796
797        let cs = ConstraintSystem::<F>::new();
798        let a = cs.col(0);
799        let b = cs.col(1);
800        let c = cs.col(2);
801
802        // a * b * c = degree 3
803        cs.constrain(a * b * c);
804        // a + b = degree 1
805        cs.constrain(a + b);
806
807        let ast = cs.build();
808        assert_eq!(ast.max_degree(), 3);
809    }
810
811    #[test]
812    fn to_constraints_roundtrip_fibonacci() {
813        let program = TestFibProgram;
814        let ast = program.constraint_ast();
815        let flat_from_ast = ast.to_constraints();
816        let flat_direct = program.constraints();
817
818        // Same number of constraints
819        assert_eq!(flat_from_ast.len(), flat_direct.len());
820
821        // Same number of terms per constraint
822        for (a, d) in flat_from_ast.iter().zip(flat_direct.iter()) {
823            assert_eq!(a.terms.len(), d.terms.len());
824        }
825    }
826
827    #[test]
828    fn to_constraints_from_builder() {
829        use crate::constraint::builder::ConstraintSystem;
830
831        let cs = ConstraintSystem::<F>::new();
832        let a = cs.col(0);
833        let b = cs.col(1);
834
835        // a + b = 0  →  should produce 2 flat terms: (ONE, [a]) + (ONE, [b])
836        cs.constrain(a + b);
837
838        let ast = cs.build();
839        let flat = ast.to_constraints();
840
841        assert_eq!(flat.len(), 1);
842        assert_eq!(flat[0].terms.len(), 2);
843
844        // Both terms should have exactly 1 cell each
845        for term in &flat[0].terms {
846            assert_eq!(term.coeff, F::ONE);
847            assert_eq!(term.poly_ind.len(), 1);
848        }
849    }
850
851    #[test]
852    fn evaluate_simple_constraint() {
853        use crate::constraint::builder::ConstraintSystem;
854        use hekate_math::Flat;
855
856        let cs = ConstraintSystem::<F>::new();
857        let a = cs.col(0);
858        let b = cs.col(1);
859
860        // a + b = 0
861        cs.constrain(a + b);
862
863        let ast = cs.build();
864
865        // Evaluate at a=3, b=3 -> 3 + 3 = 0 in GF(2^k) (XOR)
866        let current = vec![
867            Flat::from_raw(F::from(3u128)),
868            Flat::from_raw(F::from(3u128)),
869        ];
870        let next = vec![Flat::from_raw(F::ZERO); 2];
871
872        let evals = ast.evaluate(&current, &next);
873        assert_eq!(evals.len(), 1);
874        assert_eq!(evals[0], Flat::from_raw(F::ZERO)); // 3 XOR 3 = 0
875
876        // Evaluate at a=3, b=5 -> 3 XOR 5 = 6 ≠ 0
877        let current2 = vec![
878            Flat::from_raw(F::from(3u128)),
879            Flat::from_raw(F::from(5u128)),
880        ];
881        let evals2 = ast.evaluate(&current2, &next);
882        assert_ne!(evals2[0], Flat::from_raw(F::ZERO));
883    }
884
885    #[test]
886    fn evaluate_into_matches_evaluate() {
887        let cs = ConstraintSystem::<F>::new();
888
889        let a = cs.col(0);
890        let b = cs.col(1);
891        let na = cs.next(0);
892
893        cs.constrain(a + b);
894        cs.constrain(a * b);
895        cs.constrain(na + a);
896
897        let ast = cs.build();
898        let zero = Flat::from_raw(F::ZERO);
899
900        let current = vec![
901            Flat::from_raw(F::from(3u128)),
902            Flat::from_raw(F::from(5u128)),
903        ];
904        let next = vec![Flat::from_raw(F::from(7u128)), zero];
905
906        let expected = ast.evaluate(&current, &next);
907
908        let consts = ast.precompute_hardware_consts();
909
910        let mut buf = Vec::new();
911        ast.evaluate_into(&consts, &current, &next, &mut buf);
912
913        for (i, root) in ast.roots.iter().enumerate() {
914            assert_eq!(buf[root.0 as usize], expected[i]);
915        }
916    }
917}