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
176/// `ProgramCell` returns the same `ExprId`.
177pub struct ConstraintArena<F> {
178    nodes: Vec<ConstraintExpr<F>>,
179
180    /// Dedup cache for Cell nodes.
181    /// Same ProgramCell -> same ExprId.
182    cell_cache: HashMap<ProgramCell, ExprId>,
183}
184
185impl<F: TowerField> Default for ConstraintArena<F> {
186    fn default() -> Self {
187        Self::new()
188    }
189}
190
191impl<F: TowerField> ConstraintArena<F> {
192    pub fn new() -> Self {
193        Self {
194            nodes: Vec::new(),
195            cell_cache: HashMap::new(),
196        }
197    }
198
199    /// Allocate a new expression node.
200    /// Returns its ID.
201    pub fn alloc(&mut self, expr: ConstraintExpr<F>) -> ExprId {
202        let id = ExprId(self.nodes.len() as u32);
203        self.nodes.push(expr);
204
205        id
206    }
207
208    /// Read a node by ID.
209    pub fn get(&self, id: ExprId) -> &ConstraintExpr<F> {
210        &self.nodes[id.0 as usize]
211    }
212
213    /// Total number of nodes.
214    pub fn len(&self) -> usize {
215        self.nodes.len()
216    }
217
218    pub fn is_empty(&self) -> bool {
219        self.nodes.is_empty()
220    }
221
222    /// Shift all Cell node col_idx by `offset`.
223    /// Used when embedding a chiplet's AST into
224    /// a combined program where column indices
225    /// are offset.
226    pub fn shift_cells(&mut self, offset: usize) {
227        for node in &mut self.nodes {
228            if let ConstraintExpr::Cell(cell) = node {
229                cell.col_idx += offset;
230            }
231        }
232
233        let old_cache = core::mem::take(&mut self.cell_cache);
234        for (mut cell, id) in old_cache {
235            cell.col_idx += offset;
236            self.cell_cache.insert(cell, id);
237        }
238    }
239
240    /// Create or reuse a cell reference.
241    /// Automatically deduplicates:
242    /// same ProgramCell -> same ExprId.
243    pub fn cell(&mut self, cell: ProgramCell) -> ExprId {
244        if let Some(&id) = self.cell_cache.get(&cell) {
245            return id;
246        }
247
248        let id = self.alloc(ConstraintExpr::Cell(cell));
249        self.cell_cache.insert(cell, id);
250
251        id
252    }
253
254    /// Create a constant.
255    pub fn constant(&mut self, val: F) -> ExprId {
256        self.alloc(ConstraintExpr::Const(val))
257    }
258
259    /// a + b
260    pub fn add(&mut self, a: ExprId, b: ExprId) -> ExprId {
261        self.alloc(ConstraintExpr::Add(a, b))
262    }
263
264    /// a * b
265    pub fn mul(&mut self, a: ExprId, b: ExprId) -> ExprId {
266        self.alloc(ConstraintExpr::Mul(a, b))
267    }
268
269    /// coeff * a
270    pub fn scale(&mut self, coeff: F, a: ExprId) -> ExprId {
271        self.alloc(ConstraintExpr::Scale(coeff, a))
272    }
273
274    /// Sum of multiple expressions.
275    pub fn sum(&mut self, children: Vec<ExprId>) -> ExprId {
276        self.alloc(ConstraintExpr::Sum(children))
277    }
278}
279
280/// A full constraint system in AST form.
281/// `roots` are the top-level constraint expressions.
282/// Each root must evaluate to 0 for a valid trace.
283pub struct ConstraintAst<F> {
284    pub arena: ConstraintArena<F>,
285    pub roots: Vec<ExprId>,
286    pub labels: Vec<Option<&'static str>>,
287}
288
289impl<F: TowerField> Clone for ConstraintArena<F> {
290    fn clone(&self) -> Self {
291        Self {
292            nodes: self.nodes.clone(),
293            cell_cache: self.cell_cache.clone(),
294        }
295    }
296}
297
298impl<F: TowerField> Clone for ConstraintAst<F> {
299    fn clone(&self) -> Self {
300        Self {
301            arena: self.arena.clone(),
302            roots: self.roots.clone(),
303            labels: self.labels.clone(),
304        }
305    }
306}
307
308impl<F: TowerField> ConstraintAst<F> {
309    /// Errs unless every child id is below its node's
310    /// id and every root id lies inside the arena.
311    pub fn validate_order(&self) -> hekate_core::errors::Result<()> {
312        let n = self.arena.len();
313        for i in 0..n {
314            let precedes = |id: &ExprId| (id.0 as usize) < i;
315
316            let ordered = match self.arena.get(ExprId(i as u32)) {
317                ConstraintExpr::Cell(_) | ConstraintExpr::Const(_) => true,
318                ConstraintExpr::Add(a, b) | ConstraintExpr::Mul(a, b) => precedes(a) && precedes(b),
319                ConstraintExpr::Scale(_, a) => precedes(a),
320                ConstraintExpr::Sum(children) => children.iter().all(precedes),
321            };
322
323            if !ordered {
324                return Err(Error::Protocol {
325                    protocol: "constraint_ast",
326                    message: "child does not precede its node",
327                });
328            }
329        }
330
331        if self.roots.iter().any(|root| root.0 as usize >= n) {
332            return Err(Error::Protocol {
333                protocol: "constraint_ast",
334                message: "root outside the arena",
335            });
336        }
337
338        Ok(())
339    }
340
341    /// Maximum polynomial degree
342    /// across all constraint roots.
343    pub fn max_degree(&self) -> usize {
344        if self.arena.is_empty() {
345            return 0;
346        }
347
348        let n = self.arena.len();
349        let mut deg: Vec<usize> = Vec::with_capacity(n);
350
351        for i in 0..n {
352            let d = match self.arena.get(ExprId(i as u32)) {
353                ConstraintExpr::Cell(_) => 1,
354                ConstraintExpr::Const(_) => 0,
355                ConstraintExpr::Add(a, b) => deg[a.0 as usize].max(deg[b.0 as usize]),
356                ConstraintExpr::Mul(a, b) => deg[a.0 as usize] + deg[b.0 as usize],
357                ConstraintExpr::Scale(_, a) => deg[a.0 as usize],
358                ConstraintExpr::Sum(children) => children
359                    .iter()
360                    .map(|c| deg[c.0 as usize])
361                    .max()
362                    .unwrap_or(0),
363            };
364
365            deg.push(d);
366        }
367
368        self.roots
369            .iter()
370            .map(|r| deg[r.0 as usize])
371            .max()
372            .unwrap_or(0)
373    }
374
375    /// Hardware-basis `Const`/`Scale` coefficients
376    /// indexed by `ExprId`; other slots hold zero.
377    pub fn precompute_hardware_consts(&self) -> Vec<Flat<F>>
378    where
379        F: HardwareField,
380    {
381        let n = self.arena.len();
382
383        let mut consts: Vec<Flat<F>> = Vec::with_capacity(n);
384        for i in 0..n {
385            let c = match self.arena.get(ExprId(i as u32)) {
386                ConstraintExpr::Const(k) => k.to_hardware(),
387                ConstraintExpr::Scale(k, _) => k.to_hardware(),
388                _ => Flat::from_raw(F::ZERO),
389            };
390
391            consts.push(c);
392        }
393
394        consts
395    }
396
397    /// Evaluate each constraint
398    /// root at a single point.
399    pub fn evaluate(&self, current_row: &[Flat<F>], next_row: &[Flat<F>]) -> Vec<Flat<F>>
400    where
401        F: HardwareField,
402    {
403        let consts = self.precompute_hardware_consts();
404        let mut buf: Vec<Flat<F>> = Vec::with_capacity(self.arena.len());
405
406        self.evaluate_into(&consts, current_row, next_row, &mut buf);
407
408        self.roots.iter().map(|r| buf[r.0 as usize]).collect()
409    }
410
411    /// `consts` must come from the same arena
412    /// `self.precompute_hardware_consts()`.
413    pub fn evaluate_into(
414        &self,
415        consts: &[Flat<F>],
416        current_row: &[Flat<F>],
417        next_row: &[Flat<F>],
418        buf: &mut Vec<Flat<F>>,
419    ) where
420        F: HardwareField,
421    {
422        buf.clear();
423
424        for (i, &coeff) in consts.iter().enumerate() {
425            let v = match self.arena.get(ExprId(i as u32)) {
426                ConstraintExpr::Cell(cell) => {
427                    if cell.next_row {
428                        next_row[cell.col_idx]
429                    } else {
430                        current_row[cell.col_idx]
431                    }
432                }
433                ConstraintExpr::Const(_) => coeff,
434                ConstraintExpr::Add(a, b) => buf[a.0 as usize] + buf[b.0 as usize],
435                ConstraintExpr::Mul(a, b) => buf[a.0 as usize] * buf[b.0 as usize],
436                ConstraintExpr::Scale(_, a) => coeff * buf[a.0 as usize],
437                ConstraintExpr::Sum(children) => {
438                    let mut s = Flat::from_raw(F::ZERO);
439                    for c in children {
440                        s += buf[c.0 as usize];
441                    }
442
443                    s
444                }
445            };
446
447            buf.push(v);
448        }
449    }
450
451    /// Merge another constraint AST into this one.
452    pub fn merge(&mut self, other: ConstraintAst<F>) {
453        let mut id_map: Vec<ExprId> = Vec::with_capacity(other.arena.len());
454        for node in other.arena.nodes {
455            let new_id = match node {
456                ConstraintExpr::Cell(cell) => self.arena.cell(cell),
457                ConstraintExpr::Const(val) => self.arena.constant(val),
458                ConstraintExpr::Add(a, b) => {
459                    self.arena.add(id_map[a.0 as usize], id_map[b.0 as usize])
460                }
461                ConstraintExpr::Mul(a, b) => {
462                    self.arena.mul(id_map[a.0 as usize], id_map[b.0 as usize])
463                }
464                ConstraintExpr::Scale(coeff, inner) => {
465                    self.arena.scale(coeff, id_map[inner.0 as usize])
466                }
467                ConstraintExpr::Sum(children) => {
468                    let remapped: Vec<ExprId> =
469                        children.into_iter().map(|c| id_map[c.0 as usize]).collect();
470                    self.arena.sum(remapped)
471                }
472            };
473
474            id_map.push(new_id);
475        }
476
477        for (root, label) in other.roots.into_iter().zip(other.labels) {
478            self.roots.push(id_map[root.0 as usize]);
479            self.labels.push(label);
480        }
481    }
482
483    /// Convert AST to flat `Vec<Constraint<F>>`.
484    /// Expands the DAG into sum-of-products form.
485    pub fn to_constraints(&self) -> Vec<Constraint<F>> {
486        /// A flat term:
487        /// coefficient × product of cells.
488        type FlatTerm<F> = (F, Vec<ProgramCell>);
489
490        fn expand<F: TowerField>(
491            arena: &ConstraintArena<F>,
492            id: ExprId,
493            cache: &mut Vec<Option<Vec<FlatTerm<F>>>>,
494        ) -> Vec<FlatTerm<F>> {
495            if let Some(ref cached) = cache[id.0 as usize] {
496                return cached.clone();
497            }
498
499            let result = match arena.get(id) {
500                ConstraintExpr::Cell(cell) => {
501                    vec![(F::ONE, vec![*cell])]
502                }
503                ConstraintExpr::Const(k) => {
504                    vec![(*k, vec![])]
505                }
506                ConstraintExpr::Add(a, b) => {
507                    let mut terms = expand(arena, *a, cache);
508                    terms.extend(expand(arena, *b, cache));
509
510                    terms
511                }
512                ConstraintExpr::Mul(a, b) => {
513                    let left = expand(arena, *a, cache);
514                    let right = expand(arena, *b, cache);
515
516                    let mut terms = Vec::with_capacity(left.len() * right.len());
517                    for (lc, lp) in &left {
518                        for (rc, rp) in &right {
519                            let coeff = *lc * *rc;
520                            let mut cells = lp.clone();
521
522                            cells.extend_from_slice(rp);
523                            terms.push((coeff, cells));
524                        }
525                    }
526
527                    terms
528                }
529                ConstraintExpr::Scale(k, a) => {
530                    let inner = expand(arena, *a, cache);
531                    inner
532                        .into_iter()
533                        .map(|(c, cells)| (*k * c, cells))
534                        .collect()
535                }
536                ConstraintExpr::Sum(children) => {
537                    let mut terms = Vec::new();
538                    for child in children {
539                        terms.extend(expand(arena, *child, cache));
540                    }
541
542                    terms
543                }
544            };
545
546            cache[id.0 as usize] = Some(result.clone());
547
548            result
549        }
550
551        let n = self.arena.len();
552        let mut cache: Vec<Option<Vec<FlatTerm<F>>>> = vec![None; n];
553
554        self.roots
555            .iter()
556            .map(|root| {
557                let flat_terms = expand(&self.arena, *root, &mut cache);
558                Constraint::new(
559                    flat_terms
560                        .into_iter()
561                        .map(|(coeff, cells)| ConstraintTerm::new(coeff, cells))
562                        .collect(),
563                )
564            })
565            .collect()
566    }
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572    use crate::constraint::ConstraintExpr;
573    use crate::constraint::builder::ConstraintSystem;
574    use crate::outer::{TableShape, TableStatics};
575    use crate::{Air, Program};
576    use hekate_core::trace::ColumnType;
577    use hekate_math::{Block128, Flat};
578
579    type F = Block128;
580
581    #[derive(Clone)]
582    struct TestFibProgram;
583
584    impl Air<F> for TestFibProgram {
585        fn num_columns(&self) -> usize {
586            3
587        }
588
589        fn column_layout(&self) -> &[ColumnType] {
590            &[ColumnType::B32, ColumnType::B32, ColumnType::Bit]
591        }
592
593        fn constraint_ast(&self) -> ConstraintAst<F> {
594            let cs = ConstraintSystem::<F>::new();
595
596            let [a, b, q] = [cs.col(0), cs.col(1), cs.col(2)];
597            let [na, nb] = [cs.next(0), cs.next(1)];
598
599            cs.constrain(q * (na + b));
600            cs.constrain(q * (nb + a + b));
601
602            cs.build()
603        }
604    }
605
606    impl Program<F> for TestFibProgram {}
607
608    fn misordered_asts() -> [(&'static str, &'static str, ConstraintAst<F>); 3] {
609        let cell = |col| ConstraintExpr::Cell(ProgramCell::current(col));
610
611        let ast = |nodes: [ConstraintExpr<F>; 3], root| {
612            let mut arena = ConstraintArena::new();
613            for node in nodes {
614                arena.alloc(node);
615            }
616
617            ConstraintAst {
618                arena,
619                roots: vec![ExprId(root)],
620                labels: vec![None],
621            }
622        };
623
624        [
625            (
626                "forward child",
627                "child does not precede its node",
628                ast(
629                    [
630                        cell(0),
631                        ConstraintExpr::Sum(vec![ExprId(0), ExprId(2)]),
632                        cell(1),
633                    ],
634                    1,
635                ),
636            ),
637            (
638                "self reference",
639                "child does not precede its node",
640                ast(
641                    [cell(0), ConstraintExpr::Mul(ExprId(0), ExprId(1)), cell(1)],
642                    1,
643                ),
644            ),
645            (
646                "root past the arena",
647                "root outside the arena",
648                ast(
649                    [cell(0), cell(1), ConstraintExpr::Add(ExprId(0), ExprId(1))],
650                    3,
651                ),
652            ),
653        ]
654    }
655
656    #[test]
657    fn default_constraint_ast_produces_correct_roots() {
658        let program = TestFibProgram;
659        let ast = program.constraint_ast();
660
661        // 2 constraints -> 2 roots
662        assert_eq!(ast.roots.len(), 2);
663
664        // c1 has 2 terms, c2 has 3 terms.
665        // Verify non-empty and structurally sound.
666        assert!(!ast.arena.is_empty());
667
668        // Both roots should be Sum or product nodes
669        for &root in &ast.roots {
670            let node = ast.arena.get(root);
671            match node {
672                ConstraintExpr::Sum(children) => {
673                    assert!(!children.is_empty());
674                }
675                ConstraintExpr::Mul(_, _) => {
676                    // single-term constraint would be a Mul
677                }
678                _ => panic!("Root should be Sum or Mul, got {:?}", node),
679            }
680        }
681    }
682
683    #[test]
684    fn cell_dedup_works() {
685        let mut arena = ConstraintArena::<F>::new();
686
687        let cell_a = ProgramCell::current(0);
688        let cell_b = ProgramCell::current(0);
689        let cell_c = ProgramCell::next(0);
690
691        let id_a = arena.cell(cell_a);
692        let id_b = arena.cell(cell_b);
693        let id_c = arena.cell(cell_c);
694
695        // Same cell -> same ExprId
696        assert_eq!(id_a, id_b);
697        // Different cell -> different ExprId
698        assert_ne!(id_a, id_c);
699        // Only 2 nodes allocated, not 3
700        assert_eq!(arena.len(), 2);
701    }
702
703    #[test]
704    fn dag_sharing_reduces_node_count() {
705        let mut arena = ConstraintArena::<F>::new();
706
707        // Build a shared sub-expression: theta = a + b + c
708        let a = arena.cell(ProgramCell::current(0));
709        let b = arena.cell(ProgramCell::current(1));
710        let c = arena.cell(ProgramCell::current(2));
711        let theta = arena.sum(vec![a, b, c]);
712
713        // Use theta in two different constraints (DAG sharing)
714        let d = arena.cell(ProgramCell::current(3));
715        let expr1 = arena.mul(theta, d);
716        let expr2 = arena.mul(theta, a); // reuses both theta and a
717
718        let dag_node_count = arena.len();
719        // 4 cells + 1 sum + 2 muls = 7 nodes
720        assert_eq!(dag_node_count, 7);
721
722        // Without sharing (tree), theta would be duplicated:
723        // 4 cells + 2 sums + 2 muls = 8 nodes minimum
724        // (and the cells inside the second theta would also duplicate)
725        // Actually: 3 cells * 2 + 1 extra cell + 2 sums + 2 muls = 10 nodes
726        // DAG: 7 < 10 tree nodes
727        assert!(dag_node_count < 10);
728
729        // Verify both expressions reference the same theta
730        match arena.get(expr1) {
731            ConstraintExpr::Mul(lhs, _) => assert_eq!(*lhs, theta),
732            _ => panic!("Expected Mul"),
733        }
734
735        match arena.get(expr2) {
736            ConstraintExpr::Mul(lhs, rhs) => {
737                assert_eq!(*lhs, theta);
738                assert_eq!(*rhs, a);
739            }
740            _ => panic!("Expected Mul"),
741        }
742    }
743
744    #[test]
745    fn default_constraint_ast_node_count_matches_flat() {
746        let program = TestFibProgram;
747        let flat = program.constraints();
748        let ast = program.constraint_ast();
749
750        // Count total cells across flat constraints
751        let mut flat_cell_count = 0;
752        for c in &flat {
753            for t in &c.terms {
754                flat_cell_count += t.poly_ind.len();
755            }
756        }
757
758        // With dedup, AST cell nodes <= flat cell references
759        let ast_cell_count = ast
760            .arena
761            .nodes
762            .iter()
763            .filter(|n| matches!(n, ConstraintExpr::Cell(_)))
764            .count();
765
766        assert!(ast_cell_count <= flat_cell_count);
767
768        // Fib shares ProgramCell::current(2) across all terms.
769        // 5 total term-cells reference current(2), but dedup
770        // means only 1 Cell node for it.
771        // Unique cells: current(0), current(1), current(2), next(0), next(1) = 5
772        assert_eq!(ast_cell_count, 5);
773    }
774
775    #[test]
776    fn empty_constraint_produces_empty_ast() {
777        #[derive(Clone)]
778        struct EmptyProgram;
779
780        impl Air<F> for EmptyProgram {
781            fn num_columns(&self) -> usize {
782                0
783            }
784
785            fn column_layout(&self) -> &[ColumnType] {
786                &[]
787            }
788
789            fn constraint_ast(&self) -> ConstraintAst<F> {
790                ConstraintSystem::<F>::new().build()
791            }
792        }
793
794        impl Program<F> for EmptyProgram {}
795
796        let ast = EmptyProgram.constraint_ast();
797        assert!(ast.roots.is_empty());
798        assert!(ast.arena.is_empty());
799    }
800
801    #[test]
802    fn single_term_constraint_no_sum_wrapper() {
803        #[derive(Clone)]
804        struct SingleTermProgram;
805
806        impl Air<F> for SingleTermProgram {
807            fn num_columns(&self) -> usize {
808                2
809            }
810
811            fn column_layout(&self) -> &[ColumnType] {
812                &[ColumnType::B128, ColumnType::B128]
813            }
814
815            fn constraint_ast(&self) -> ConstraintAst<F> {
816                let cs = ConstraintSystem::<F>::new();
817                cs.constrain(cs.col(0) * cs.col(1));
818
819                cs.build()
820            }
821        }
822
823        impl Program<F> for SingleTermProgram {}
824
825        let ast = SingleTermProgram.constraint_ast();
826        assert_eq!(ast.roots.len(), 1);
827
828        // Single term: cell0 * cell1 -> Mul, no Sum
829        match ast.arena.get(ast.roots[0]) {
830            ConstraintExpr::Mul(_, _) => {} // correct
831            other => panic!("Expected Mul for single-term, got {:?}", other),
832        }
833    }
834
835    // =========================================================
836    // ConstraintAst method tests
837    // =========================================================
838
839    #[test]
840    fn max_degree_fibonacci() {
841        let program = TestFibProgram;
842        let ast = program.constraint_ast();
843
844        // Fib constraints: q * next_a (degree 2), q * curr_b (degree 2)
845        // The AST max degree should match the flat form.
846        let flat = program.constraints();
847        let flat_max = flat
848            .iter()
849            .flat_map(|c| c.terms.iter())
850            .map(|t| t.poly_ind.len())
851            .max()
852            .unwrap_or(0);
853
854        assert_eq!(ast.max_degree(), flat_max);
855    }
856
857    #[test]
858    fn max_degree_empty() {
859        let ast = ConstraintAst::<F> {
860            arena: ConstraintArena::new(),
861            roots: Vec::new(),
862            labels: Vec::new(),
863        };
864        assert_eq!(ast.max_degree(), 0);
865    }
866
867    #[test]
868    fn max_degree_builder_mul_chain() {
869        use crate::constraint::builder::ConstraintSystem;
870
871        let cs = ConstraintSystem::<F>::new();
872        let a = cs.col(0);
873        let b = cs.col(1);
874        let c = cs.col(2);
875
876        // a * b * c = degree 3
877        cs.constrain(a * b * c);
878        // a + b = degree 1
879        cs.constrain(a + b);
880
881        let ast = cs.build();
882        assert_eq!(ast.max_degree(), 3);
883    }
884
885    #[test]
886    fn to_constraints_roundtrip_fibonacci() {
887        let program = TestFibProgram;
888        let ast = program.constraint_ast();
889        let flat_from_ast = ast.to_constraints();
890        let flat_direct = program.constraints();
891
892        // Same number of constraints
893        assert_eq!(flat_from_ast.len(), flat_direct.len());
894
895        // Same number of terms per constraint
896        for (a, d) in flat_from_ast.iter().zip(flat_direct.iter()) {
897            assert_eq!(a.terms.len(), d.terms.len());
898        }
899    }
900
901    #[test]
902    fn to_constraints_from_builder() {
903        use crate::constraint::builder::ConstraintSystem;
904
905        let cs = ConstraintSystem::<F>::new();
906        let a = cs.col(0);
907        let b = cs.col(1);
908
909        // a + b = 0  →  should produce 2 flat terms: (ONE, [a]) + (ONE, [b])
910        cs.constrain(a + b);
911
912        let ast = cs.build();
913        let flat = ast.to_constraints();
914
915        assert_eq!(flat.len(), 1);
916        assert_eq!(flat[0].terms.len(), 2);
917
918        // Both terms should have exactly 1 cell each
919        for term in &flat[0].terms {
920            assert_eq!(term.coeff, F::ONE);
921            assert_eq!(term.poly_ind.len(), 1);
922        }
923    }
924
925    #[test]
926    fn evaluate_simple_constraint() {
927        use crate::constraint::builder::ConstraintSystem;
928        use hekate_math::Flat;
929
930        let cs = ConstraintSystem::<F>::new();
931        let a = cs.col(0);
932        let b = cs.col(1);
933
934        // a + b = 0
935        cs.constrain(a + b);
936
937        let ast = cs.build();
938
939        // Evaluate at a=3, b=3 -> 3 + 3 = 0 in GF(2^k) (XOR)
940        let current = vec![
941            Flat::from_raw(F::from(3u128)),
942            Flat::from_raw(F::from(3u128)),
943        ];
944        let next = vec![Flat::from_raw(F::ZERO); 2];
945
946        let evals = ast.evaluate(&current, &next);
947        assert_eq!(evals.len(), 1);
948        assert_eq!(evals[0], Flat::from_raw(F::ZERO)); // 3 XOR 3 = 0
949
950        // Evaluate at a=3, b=5 -> 3 XOR 5 = 6 ≠ 0
951        let current2 = vec![
952            Flat::from_raw(F::from(3u128)),
953            Flat::from_raw(F::from(5u128)),
954        ];
955        let evals2 = ast.evaluate(&current2, &next);
956        assert_ne!(evals2[0], Flat::from_raw(F::ZERO));
957    }
958
959    #[test]
960    fn evaluate_into_matches_evaluate() {
961        let cs = ConstraintSystem::<F>::new();
962
963        let a = cs.col(0);
964        let b = cs.col(1);
965        let na = cs.next(0);
966
967        cs.constrain(a + b);
968        cs.constrain(a * b);
969        cs.constrain(na + a);
970
971        let ast = cs.build();
972        let zero = Flat::from_raw(F::ZERO);
973
974        let current = vec![
975            Flat::from_raw(F::from(3u128)),
976            Flat::from_raw(F::from(5u128)),
977        ];
978        let next = vec![Flat::from_raw(F::from(7u128)), zero];
979
980        let expected = ast.evaluate(&current, &next);
981
982        let consts = ast.precompute_hardware_consts();
983
984        let mut buf = Vec::new();
985        ast.evaluate_into(&consts, &current, &next, &mut buf);
986
987        for (i, root) in ast.roots.iter().enumerate() {
988            assert_eq!(buf[root.0 as usize], expected[i]);
989        }
990    }
991
992    #[test]
993    fn table_shape_rejects_misordered_asts() {
994        for (case, expected, ast) in misordered_asts() {
995            let statics = TableStatics {
996                ast: &ast,
997                specs: &[],
998                fixed: &[],
999                boundary: &[],
1000            };
1001
1002            match TableShape::from_air(&TestFibProgram, &statics) {
1003                Err(Error::Protocol { message, .. }) => assert_eq!(message, expected, "{case}"),
1004                other => panic!("{case}: {other:?}"),
1005            }
1006        }
1007    }
1008}