Skip to main content

hekate_program/
chiplet.rs

1// SPDX-License-Identifier: Apache-2.0
2// This file is part of the hekate project.
3// Copyright (C) 2026 Andrei Kochergin <andrei@oumuamua.dev>
4// Copyright (C) 2026 Oumuamua Labs <info@oumuamua.dev>. All rights reserved.
5//
6// Licensed under the Apache License, Version 2.0 (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10//     http://www.apache.org/licenses/LICENSE-2.0
11//
12// Unless required by applicable law or agreed to in writing, software
13// distributed under the License is distributed on an "AS IS" BASIS,
14// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15// See the License for the specific language governing permissions and
16// limitations under the License.
17
18//! Independent AIR Chiplet definitions.
19//!
20//! A `ChipletDef` snapshots a chiplet's full AIR
21//! (constraints, layout, bus specs) into an owned struct.
22//! The prover runs an independent ZeroCheck per chiplet.
23//! The bus (GPA) reconnects chiplets to the main trace.
24
25use crate::constraint::{
26    BoundaryConstraint, BoundaryTarget, ConstraintAst, ConstraintExpr, ExprId,
27};
28use crate::expander::VirtualExpander;
29use crate::permutation::PermutationCheckSpec;
30use crate::{Air, LagrangePin, ProgramCell, validate_lagrange_pins};
31use alloc::boxed::Box;
32use alloc::string::String;
33use alloc::vec::Vec;
34use hekate_core::errors;
35use hekate_core::poly::PolyVariant;
36use hekate_core::trace::{ColumnTrace, ColumnType, Trace, TraceCompatibleField};
37use hekate_math::{Flat, HardwareField, PackableField, TowerField};
38
39/// Pre-computed chiplet AIR definition.
40pub struct ChipletDef<F: TowerField> {
41    name: String,
42    num_columns: usize,
43    constraint_ast: ConstraintAst<F>,
44    column_layout: Vec<ColumnType>,
45    virtual_column_layout: Vec<ColumnType>,
46    boundary_constraints: Vec<BoundaryConstraint<F>>,
47    lagrange_pins: Vec<LagrangePin>,
48    expander: Option<VirtualExpander>,
49    pub permutation_checks: Vec<(String, PermutationCheckSpec)>,
50}
51
52impl<F: TowerField> ChipletDef<F> {
53    /// Snapshot a chiplet's full AIR definition.
54    /// Call once at setup; the source
55    /// chiplet can be dropped after.
56    pub fn from_air<P: Air<F> + Send + 'static>(p: &P) -> errors::Result<Self>
57    where
58        F: TraceCompatibleField + PackableField + HardwareField + 'static,
59        <F as PackableField>::Packed: Copy + Send + Sync,
60    {
61        let permutation_checks = p.permutation_checks();
62        for (bus_id, spec) in &permutation_checks {
63            spec.validate_clock_stitching(bus_id)?;
64        }
65
66        let constraint_ast = p.constraint_ast();
67        validate_paired_bus_mutex(&permutation_checks, &constraint_ast)?;
68
69        let boundary_constraints = p.boundary_constraints();
70        validate_chiplet_boundaries(&boundary_constraints, p.num_columns())?;
71
72        let lagrange_pins = p.lagrange_pinned_columns();
73        validate_lagrange_pins(&lagrange_pins, p.num_columns(), None)?;
74
75        Ok(Self {
76            name: p.name(),
77            num_columns: p.num_columns(),
78            constraint_ast,
79            column_layout: p.column_layout().to_vec(),
80            virtual_column_layout: p.virtual_column_layout().to_vec(),
81            boundary_constraints,
82            lagrange_pins,
83            expander: p.virtual_expander().cloned(),
84            permutation_checks,
85        })
86    }
87
88    /// Prefixes internal bus_ids with a namespace.
89    /// Bus_ids listed in `exempt` are left unchanged.
90    pub fn prefix_bus_ids(&mut self, prefix: &str, exempt: &[String]) {
91        for (bus_id, _) in &mut self.permutation_checks {
92            if !exempt.contains(bus_id) {
93                let mut prefixed = String::from(prefix);
94                prefixed.push_str("::");
95                prefixed.push_str(bus_id);
96
97                *bus_id = prefixed;
98            }
99        }
100    }
101
102    /// Expand physical ColumnTrace into virtual
103    /// PolyVariants. Uses embedded expander
104    /// if present, else 1:1 mapping.
105    pub fn expand_variants<'a>(
106        &self,
107        trace: &'a ColumnTrace,
108    ) -> errors::Result<Vec<PolyVariant<'a, F>>>
109    where
110        F: TraceCompatibleField + 'static,
111    {
112        match &self.expander {
113            Some(e) => e.expand_variants(trace, 0),
114            None => trace.get_poly_variants::<F>(),
115        }
116    }
117
118    /// Reconstruct from deserialized wire data.
119    /// Validates every embedded `PermutationCheckSpec`.
120    #[allow(clippy::too_many_arguments)]
121    pub fn from_wire(
122        name: String,
123        num_columns: usize,
124        constraint_ast: ConstraintAst<F>,
125        column_layout: Vec<ColumnType>,
126        virtual_column_layout: Vec<ColumnType>,
127        boundary_constraints: Vec<BoundaryConstraint<F>>,
128        lagrange_pins: Vec<LagrangePin>,
129        expander: Option<VirtualExpander>,
130        permutation_checks: Vec<(String, PermutationCheckSpec)>,
131    ) -> errors::Result<Self> {
132        for (bus_id, spec) in &permutation_checks {
133            spec.validate_clock_stitching(bus_id)?;
134        }
135
136        validate_paired_bus_mutex(&permutation_checks, &constraint_ast)?;
137        validate_chiplet_boundaries(&boundary_constraints, num_columns)?;
138        validate_lagrange_pins(&lagrange_pins, num_columns, None)?;
139
140        Ok(Self {
141            name,
142            num_columns,
143            constraint_ast,
144            column_layout,
145            virtual_column_layout,
146            boundary_constraints,
147            lagrange_pins,
148            expander,
149            permutation_checks,
150        })
151    }
152}
153
154impl<F: TowerField> Clone for ChipletDef<F> {
155    fn clone(&self) -> Self {
156        Self {
157            name: self.name.clone(),
158            num_columns: self.num_columns,
159            constraint_ast: self.constraint_ast.clone(),
160            column_layout: self.column_layout.clone(),
161            virtual_column_layout: self.virtual_column_layout.clone(),
162            boundary_constraints: self.boundary_constraints.clone(),
163            lagrange_pins: self.lagrange_pins.clone(),
164            expander: self.expander.clone(),
165            permutation_checks: self.permutation_checks.clone(),
166        }
167    }
168}
169
170impl<F: TowerField> Air<F> for ChipletDef<F> {
171    fn name(&self) -> String {
172        self.name.clone()
173    }
174
175    fn num_columns(&self) -> usize {
176        self.num_columns
177    }
178
179    fn boundary_constraints(&self) -> Vec<BoundaryConstraint<F>> {
180        self.boundary_constraints.clone()
181    }
182
183    fn column_layout(&self) -> &[ColumnType] {
184        &self.column_layout
185    }
186
187    fn virtual_column_layout(&self) -> &[ColumnType] {
188        match &self.expander {
189            Some(e) => e.virtual_layout(),
190            None => &self.virtual_column_layout,
191        }
192    }
193
194    fn permutation_checks(&self) -> Vec<(String, PermutationCheckSpec)> {
195        self.permutation_checks.clone()
196    }
197
198    fn lagrange_pinned_columns(&self) -> Vec<LagrangePin> {
199        self.lagrange_pins.clone()
200    }
201
202    fn virtual_expander(&self) -> Option<&VirtualExpander> {
203        self.expander.as_ref()
204    }
205
206    fn parse_virtual_row(&self, bytes: &[u8], res: &mut Vec<Flat<F>>)
207    where
208        F: TraceCompatibleField,
209    {
210        if let Some(e) = &self.expander {
211            res.clear();
212
213            e.parse_row(bytes, res)
214                .expect("committed row byte length must match physical_row_bytes");
215            return;
216        }
217
218        res.clear();
219
220        let mut offset = 0;
221        for col_type in &self.column_layout {
222            let size = col_type.byte_size();
223            if offset + size <= bytes.len() {
224                res.push(col_type.parse_from_bytes(&bytes[offset..offset + size]));
225                offset += size;
226            }
227        }
228    }
229
230    fn constraint_ast(&self) -> ConstraintAst<F> {
231        self.constraint_ast.clone()
232    }
233}
234
235// =================================================================
236// Composite Chiplet Composition
237// =================================================================
238
239/// Factory trait for deterministic
240/// ChipletDef construction.
241trait AirFactory<F: TowerField>: Send + Sync {
242    fn build(&self) -> errors::Result<ChipletDef<F>>;
243    fn permutation_checks(&self) -> Vec<(String, PermutationCheckSpec)>;
244    fn clone_box(&self) -> Box<dyn AirFactory<F>>;
245}
246
247impl<F, A> AirFactory<F> for A
248where
249    F: TowerField + TraceCompatibleField + PackableField + HardwareField + 'static,
250    <F as PackableField>::Packed: Copy + Send + Sync,
251    A: Air<F> + Clone + Send + Sync + 'static,
252{
253    fn build(&self) -> errors::Result<ChipletDef<F>> {
254        ChipletDef::from_air(self)
255    }
256
257    fn permutation_checks(&self) -> Vec<(String, PermutationCheckSpec)> {
258        Air::permutation_checks(self)
259    }
260
261    fn clone_box(&self) -> Box<dyn AirFactory<F>> {
262        Box::new(self.clone())
263    }
264}
265
266impl<F: TowerField> Clone for Box<dyn AirFactory<F>> {
267    fn clone(&self) -> Self {
268        self.clone_box()
269    }
270}
271
272struct CompositeEntry<F: TraceCompatibleField> {
273    air: Box<dyn AirFactory<F>>,
274}
275
276impl<F: TraceCompatibleField> Clone for CompositeEntry<F> {
277    fn clone(&self) -> Self {
278        Self {
279            air: self.air.clone_box(),
280        }
281    }
282}
283
284/// Build-time composition of peer
285/// chiplets into a single unit.
286///
287/// Flattens into standard `ChipletDef` entries
288/// for the prover, no protocol-level awareness
289/// of hierarchy. Internal buses are namespaced
290/// with `"{name}::"` to prevent cross-composite
291/// collisions. External buses pass through
292/// unchanged.
293///
294/// All chiplets within a composite are peers, no mandatory root.
295pub struct CompositeChiplet<F: TraceCompatibleField> {
296    name: String,
297    chiplets: Vec<CompositeEntry<F>>,
298    external_bus_ids: Vec<String>,
299    external_buses: Vec<(String, PermutationCheckSpec)>,
300}
301
302impl<F: TraceCompatibleField> Clone for CompositeChiplet<F> {
303    fn clone(&self) -> Self {
304        Self {
305            name: self.name.clone(),
306            chiplets: self.chiplets.clone(),
307            external_bus_ids: self.external_bus_ids.clone(),
308            external_buses: self.external_buses.clone(),
309        }
310    }
311}
312
313impl<F: TraceCompatibleField> CompositeChiplet<F> {
314    /// Start building a composite
315    /// with the given namespace.
316    pub fn builder(name: &str) -> CompositeChipletBuilder<F> {
317        CompositeChipletBuilder {
318            name: String::from(name),
319            chiplets: Vec::new(),
320            external_bus_ids: Vec::new(),
321            external_buses: Vec::new(),
322        }
323    }
324
325    /// Produce fresh ChipletDefs for `Program::chiplet_defs()`.
326    pub fn flatten_defs(&self) -> errors::Result<Vec<ChipletDef<F>>> {
327        let mut out = Vec::with_capacity(self.chiplets.len());
328        for entry in &self.chiplets {
329            let mut def = entry.air.build()?;
330            def.prefix_bus_ids(&self.name, &self.external_bus_ids);
331
332            out.push(def);
333        }
334
335        Ok(out)
336    }
337
338    /// External buses for `Program::permutation_checks()`.
339    ///
340    /// These are main-trace-side specs, column indices
341    /// reference the main trace, not any chiplet trace.
342    pub fn external_buses(&self) -> Vec<(String, PermutationCheckSpec)> {
343        self.external_buses.clone()
344    }
345
346    /// Number of flattened
347    /// chiplets in this composite.
348    pub fn len(&self) -> usize {
349        self.chiplets.len()
350    }
351
352    /// Returns true if this
353    /// composite contains no chiplets.
354    pub fn is_empty(&self) -> bool {
355        self.chiplets.is_empty()
356    }
357
358    /// The composite's namespace.
359    pub fn name(&self) -> &str {
360        &self.name
361    }
362}
363
364/// Builder for `CompositeChiplet`.
365pub struct CompositeChipletBuilder<F: TraceCompatibleField> {
366    name: String,
367    chiplets: Vec<CompositeEntry<F>>,
368    external_bus_ids: Vec<String>,
369    external_buses: Vec<(String, PermutationCheckSpec)>,
370}
371
372impl<F: TraceCompatibleField> CompositeChipletBuilder<F> {
373    /// Add a sub-chiplet.
374    pub fn chiplet<A>(mut self, air: A) -> Self
375    where
376        A: Air<F> + Clone + Send + Sync + 'static,
377        F: TraceCompatibleField + PackableField + HardwareField + 'static,
378        <F as PackableField>::Packed: Copy + Send + Sync,
379    {
380        self.chiplets.push(CompositeEntry { air: Box::new(air) });
381
382        self
383    }
384
385    /// Declare an external bus
386    /// (connects to the main trace).
387    pub fn external_bus(mut self, bus_id: &str, spec: PermutationCheckSpec) -> Self {
388        self.external_bus_ids.push(String::from(bus_id));
389        self.external_buses.push((String::from(bus_id), spec));
390
391        self
392    }
393
394    /// Finalize the composite.
395    ///
396    /// Validates selector orthogonality:
397    /// two specs on different bus_ids must
398    /// not share a selector column index.
399    /// Same bus_id is exempt for
400    /// dual-spec intra-table check.
401    pub fn build(self) -> errors::Result<CompositeChiplet<F>> {
402        for (bus_id, spec) in &self.external_buses {
403            spec.validate_clock_stitching(bus_id)?;
404        }
405
406        for entry in &self.chiplets {
407            let checks = entry.air.permutation_checks();
408            for i in 0..checks.len() {
409                for j in (i + 1)..checks.len() {
410                    if checks[i].0 == checks[j].0 {
411                        continue;
412                    }
413
414                    if let (Some(sel_i), Some(sel_j)) = (checks[i].1.selector, checks[j].1.selector)
415                        && sel_i == sel_j
416                    {
417                        return Err(errors::Error::Protocol {
418                            protocol: "composite_chiplet",
419                            message: "different bus_ids share a selector column",
420                        });
421                    }
422                }
423            }
424        }
425
426        Ok(CompositeChiplet {
427            name: self.name,
428            chiplets: self.chiplets,
429            external_bus_ids: self.external_bus_ids,
430            external_buses: self.external_buses,
431        })
432    }
433}
434
435// =================================================================
436// Multi-Composite Helpers
437// =================================================================
438
439/// Flatten multiple composites
440/// into a single chiplet def list.
441///
442/// Validates that no two composites
443/// share the same name (would cause
444/// bus namespace collisions).
445pub fn compose_chiplet_defs<F: TraceCompatibleField>(
446    composites: &[&CompositeChiplet<F>],
447) -> errors::Result<Vec<ChipletDef<F>>> {
448    for i in 0..composites.len() {
449        for j in (i + 1)..composites.len() {
450            if composites[i].name == composites[j].name {
451                return Err(errors::Error::Protocol {
452                    protocol: "composite_chiplet",
453                    message: "duplicate composite name in compose_chiplet_defs",
454                });
455            }
456        }
457    }
458
459    let mut defs = Vec::new();
460    for composite in composites {
461        defs.extend(composite.flatten_defs()?);
462    }
463
464    let endpoints = defs
465        .iter()
466        .flat_map(|d| d.permutation_checks.iter().map(|(id, s)| (id.as_str(), s)));
467
468    crate::permutation::validate_bus_set(endpoints)?;
469
470    Ok(defs)
471}
472
473/// Collect external buses
474/// from multiple composites.
475pub fn compose_external_buses<F: TraceCompatibleField>(
476    composites: &[&CompositeChiplet<F>],
477) -> Vec<(String, PermutationCheckSpec)> {
478    let mut buses = Vec::new();
479    for composite in composites {
480        buses.extend(composite.external_buses());
481    }
482
483    buses
484}
485
486/// Without the mutex root, both selectors high
487/// collapse the bus numerator to zero in char-2;
488/// without the boolean roots, the mutex admits
489/// non-zero field-element selectors that bypass
490/// binary on/off semantics.
491pub fn validate_paired_bus_mutex<F: TowerField>(
492    specs: &[(String, PermutationCheckSpec)],
493    ast: &ConstraintAst<F>,
494) -> errors::Result<()> {
495    for (_bus_id, spec) in specs {
496        let (s_send, s_recv) = match (spec.selector, spec.recv_selector) {
497            (Some(send), Some(recv)) => (send, recv),
498            (None, Some(_)) => {
499                return Err(errors::Error::Protocol {
500                    protocol: "logup_bus",
501                    message: "paired bus has recv_selector without send selector",
502                });
503            }
504            _ => continue,
505        };
506
507        if !ast_contains_mutex_root(ast, s_send, s_recv) {
508            return Err(errors::Error::Protocol {
509                protocol: "logup_bus",
510                message: "paired bus requires `s_send ยท s_recv = 0` mutex root in the AST",
511            });
512        }
513
514        if !ast_contains_boolean_root(ast, s_send) {
515            return Err(errors::Error::Protocol {
516                protocol: "logup_bus",
517                message: "paired bus requires boolean-assertion root for s_send",
518            });
519        }
520
521        if !ast_contains_boolean_root(ast, s_recv) {
522            return Err(errors::Error::Protocol {
523                protocol: "logup_bus",
524                message: "paired bus requires boolean-assertion root for s_recv",
525            });
526        }
527    }
528
529    Ok(())
530}
531
532/// Chiplets carry no `public_inputs`, so a `PublicInput`
533/// boundary target is unsatisfiable; reject it at snapshot
534/// time. Also rejects out-of-range `col_idx`.
535fn validate_chiplet_boundaries<F>(
536    boundaries: &[BoundaryConstraint<F>],
537    num_columns: usize,
538) -> errors::Result<()> {
539    for bc in boundaries {
540        if bc.col_idx >= num_columns {
541            return Err(errors::Error::Protocol {
542                protocol: "boundary",
543                message: "chiplet boundary col_idx out of range",
544            });
545        }
546
547        if matches!(bc.target, BoundaryTarget::PublicInput(_)) {
548            return Err(errors::Error::Protocol {
549                protocol: "boundary",
550                message: "chiplet boundaries must use BoundaryTarget::Constant",
551            });
552        }
553    }
554
555    Ok(())
556}
557
558fn ast_contains_mutex_root<F: TowerField>(
559    ast: &ConstraintAst<F>,
560    s_send: usize,
561    s_recv: usize,
562) -> bool {
563    ast.roots
564        .iter()
565        .any(|root| is_mutex_product(ast, *root, s_send, s_recv))
566}
567
568fn ast_contains_boolean_root<F: TowerField>(ast: &ConstraintAst<F>, col: usize) -> bool {
569    ast.roots
570        .iter()
571        .any(|root| is_boolean_assertion(ast, *root, col))
572}
573
574fn is_boolean_assertion<F: TowerField>(ast: &ConstraintAst<F>, id: ExprId, col: usize) -> bool {
575    let ConstraintExpr::Add(a, b) = ast.arena.get(id) else {
576        return false;
577    };
578
579    matches_boolean_pair(ast, *a, *b, col) || matches_boolean_pair(ast, *b, *a, col)
580}
581
582fn matches_boolean_pair<F: TowerField>(
583    ast: &ConstraintAst<F>,
584    sq_id: ExprId,
585    cell_id: ExprId,
586    col: usize,
587) -> bool {
588    let ConstraintExpr::Mul(x, y) = ast.arena.get(sq_id) else {
589        return false;
590    };
591
592    current_col_idx(ast, *x) == Some(col)
593        && current_col_idx(ast, *y) == Some(col)
594        && current_col_idx(ast, cell_id) == Some(col)
595}
596
597fn is_mutex_product<F: TowerField>(
598    ast: &ConstraintAst<F>,
599    id: ExprId,
600    s_send: usize,
601    s_recv: usize,
602) -> bool {
603    let ConstraintExpr::Mul(a, b) = ast.arena.get(id) else {
604        return false;
605    };
606
607    let lhs = current_col_idx(ast, *a);
608    let rhs = current_col_idx(ast, *b);
609
610    matches!(
611        (lhs, rhs),
612        (Some(x), Some(y)) if (x == s_send && y == s_recv) || (x == s_recv && y == s_send)
613    )
614}
615
616fn current_col_idx<F: TowerField>(ast: &ConstraintAst<F>, id: ExprId) -> Option<usize> {
617    match ast.arena.get(id) {
618        ConstraintExpr::Cell(ProgramCell {
619            col_idx,
620            next_row: false,
621        }) => Some(*col_idx),
622        _ => None,
623    }
624}
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629    use crate::ConstraintAst;
630    use crate::constraint::builder::ConstraintSystem;
631    use crate::define_columns;
632    use crate::permutation::{BusKind, ChallengeLabel, PermutationCheckSpec, Source};
633    use alloc::string::String;
634    use alloc::vec;
635    use hekate_core::trace::ColumnType;
636    use hekate_math::Block128;
637
638    type F = Block128;
639
640    define_columns! {
641        PairedAirCols {
642            KEY: B32,
643            S_SEND: Bit,
644            S_RECV: Bit,
645        }
646    }
647
648    #[derive(Clone)]
649    struct OneBusAir {
650        spec: PermutationCheckSpec,
651    }
652
653    impl Air<F> for OneBusAir {
654        fn num_columns(&self) -> usize {
655            2
656        }
657
658        fn column_layout(&self) -> &[ColumnType] {
659            &[ColumnType::B32, ColumnType::Bit]
660        }
661
662        fn permutation_checks(&self) -> Vec<(String, PermutationCheckSpec)> {
663            vec![("test_bus".into(), self.spec.clone())]
664        }
665
666        fn constraint_ast(&self) -> ConstraintAst<F> {
667            ConstraintSystem::<F>::new().build()
668        }
669    }
670
671    #[derive(Clone)]
672    struct PairedAir {
673        with_mutex: bool,
674    }
675
676    impl Air<F> for PairedAir {
677        fn num_columns(&self) -> usize {
678            PairedAirCols::NUM_COLUMNS
679        }
680
681        fn column_layout(&self) -> &[ColumnType] {
682            static LAYOUT: std::sync::OnceLock<Vec<ColumnType>> = std::sync::OnceLock::new();
683            LAYOUT.get_or_init(PairedAirCols::build_layout)
684        }
685
686        fn permutation_checks(&self) -> Vec<(String, PermutationCheckSpec)> {
687            let sources = vec![
688                (Source::Column(PairedAirCols::KEY), b"k_a" as ChallengeLabel),
689                (Source::RowIndexLeBytes(4), b"k_clk" as ChallengeLabel),
690            ];
691
692            vec![(
693                "paired_test_bus".into(),
694                PermutationCheckSpec::new_paired(
695                    sources,
696                    PairedAirCols::S_SEND,
697                    PairedAirCols::S_RECV,
698                    BusKind::Permutation,
699                ),
700            )]
701        }
702
703        fn constraint_ast(&self) -> ConstraintAst<F> {
704            let cs = ConstraintSystem::<F>::new();
705
706            cs.assert_boolean(cs.col(PairedAirCols::S_SEND));
707            cs.assert_boolean(cs.col(PairedAirCols::S_RECV));
708
709            if self.with_mutex {
710                cs.constrain_named(
711                    "paired_bus_mutex",
712                    cs.col(PairedAirCols::S_SEND) * cs.col(PairedAirCols::S_RECV),
713                );
714            }
715
716            cs.build()
717        }
718    }
719
720    #[derive(Clone)]
721    struct PairedNoBoolAir;
722
723    impl Air<F> for PairedNoBoolAir {
724        fn num_columns(&self) -> usize {
725            PairedAirCols::NUM_COLUMNS
726        }
727
728        fn column_layout(&self) -> &[ColumnType] {
729            static LAYOUT: std::sync::OnceLock<Vec<ColumnType>> = std::sync::OnceLock::new();
730            LAYOUT.get_or_init(PairedAirCols::build_layout)
731        }
732
733        fn permutation_checks(&self) -> Vec<(String, PermutationCheckSpec)> {
734            let sources = vec![
735                (Source::Column(PairedAirCols::KEY), b"k_a" as ChallengeLabel),
736                (Source::RowIndexLeBytes(4), b"k_clk" as ChallengeLabel),
737            ];
738
739            vec![(
740                "paired_test_bus".into(),
741                PermutationCheckSpec::new_paired(
742                    sources,
743                    PairedAirCols::S_SEND,
744                    PairedAirCols::S_RECV,
745                    BusKind::Permutation,
746                ),
747            )]
748        }
749
750        fn constraint_ast(&self) -> ConstraintAst<F> {
751            let cs = ConstraintSystem::<F>::new();
752
753            cs.constrain_named(
754                "paired_bus_mutex",
755                cs.col(PairedAirCols::S_SEND) * cs.col(PairedAirCols::S_RECV),
756            );
757
758            cs.build()
759        }
760    }
761
762    fn key_only() -> Vec<(Source, ChallengeLabel)> {
763        vec![(Source::Column(0), b"k_a")]
764    }
765
766    fn key_with_clock() -> Vec<(Source, ChallengeLabel)> {
767        vec![
768            (Source::Column(0), b"k_a"),
769            (Source::RowIndexLeBytes(4), b"k_clk"),
770        ]
771    }
772
773    fn snapshot(spec: PermutationCheckSpec) -> errors::Result<ChipletDef<F>> {
774        ChipletDef::from_air(&OneBusAir { spec })
775    }
776
777    fn assert_logup_bus_err<T>(res: errors::Result<T>) {
778        match res {
779            Err(errors::Error::Protocol { protocol, .. }) => {
780                assert_eq!(protocol, "logup_bus");
781            }
782            Ok(_) => panic!("expected Err(Protocol {{ protocol: \"logup_bus\", .. }})"),
783            Err(other) => panic!("expected Err(Protocol), got {:?}", other),
784        }
785    }
786
787    #[test]
788    fn def_rejects_permutation_without_clock() {
789        let spec = PermutationCheckSpec::new(key_only(), Some(1));
790        assert_logup_bus_err(snapshot(spec));
791    }
792
793    #[test]
794    fn def_rejects_permutation_with_empty_waiver() {
795        let spec = PermutationCheckSpec::new(key_only(), Some(1)).with_clock_waiver("");
796        assert_logup_bus_err(snapshot(spec));
797    }
798
799    #[test]
800    fn def_rejects_permutation_with_clock_and_waiver() {
801        let spec =
802            PermutationCheckSpec::new(key_with_clock(), Some(1)).with_clock_waiver("redundant");
803        assert_logup_bus_err(snapshot(spec));
804    }
805
806    #[test]
807    fn def_rejects_lookup_with_waiver() {
808        let spec = PermutationCheckSpec::new_lookup(key_only(), Some(1)).with_clock_waiver("nope");
809        assert_logup_bus_err(snapshot(spec));
810    }
811
812    #[test]
813    fn def_accepts_permutation_with_row_index() {
814        let spec = PermutationCheckSpec::new(key_with_clock(), Some(1));
815        snapshot(spec).expect("permutation bus with row-index source must accept");
816    }
817
818    #[test]
819    fn def_accepts_permutation_with_clock_waiver() {
820        let spec = PermutationCheckSpec::new(key_only(), Some(1))
821            .with_clock_waiver("see foo.rs:42: structurally unique by AIR body");
822        snapshot(spec).expect("permutation bus with non-empty clock_waiver must accept");
823    }
824
825    #[test]
826    fn def_accepts_lookup_without_clock() {
827        let spec = PermutationCheckSpec::new_lookup(key_only(), Some(1));
828        snapshot(spec).expect("lookup bus without clock must accept");
829    }
830
831    #[test]
832    fn def_rejects_permutation_with_too_short_waiver() {
833        let spec = PermutationCheckSpec::new(key_only(), Some(1)).with_clock_waiver("see x.rs");
834        assert_logup_bus_err(snapshot(spec));
835    }
836
837    #[test]
838    fn def_rejects_permutation_with_missing_see_citation() {
839        let spec = PermutationCheckSpec::new(key_only(), Some(1))
840            .with_clock_waiver("structurally unique by AIR body but no file citation prefix here");
841        assert_logup_bus_err(snapshot(spec));
842    }
843
844    #[test]
845    fn paired_bus_emits_mutex_and_boolean_assertions() {
846        let cs = ConstraintSystem::<F>::new();
847
848        cs.assert_paired_bus_mutex(PairedAirCols::S_SEND, PairedAirCols::S_RECV);
849
850        let ast = cs.build();
851
852        let labels: Vec<_> = ast.labels.iter().filter_map(|l| *l).collect();
853
854        assert_eq!(
855            labels.iter().filter(|l| **l == "boolean").count(),
856            2,
857            "gadget must emit two boolean assertions"
858        );
859        assert_eq!(
860            labels.iter().filter(|l| **l == "paired_bus_mutex").count(),
861            1,
862            "gadget must emit exactly one mutex root"
863        );
864    }
865
866    #[test]
867    fn paired_bus_shares_cell_nodes() {
868        let cs = ConstraintSystem::<F>::new();
869
870        let send_first = cs.col(PairedAirCols::S_SEND);
871        let recv_first = cs.col(PairedAirCols::S_RECV);
872
873        cs.assert_paired_bus_mutex(PairedAirCols::S_SEND, PairedAirCols::S_RECV);
874
875        let send_again = cs.col(PairedAirCols::S_SEND);
876        let recv_again = cs.col(PairedAirCols::S_RECV);
877
878        assert_eq!(send_first.id, send_again.id, "S_SEND must dedup");
879        assert_eq!(recv_first.id, recv_again.id, "S_RECV must dedup");
880    }
881
882    #[test]
883    fn chiplet_def_rejects_paired_spec_without_mutex() {
884        let bad = PairedAir { with_mutex: false };
885        assert_logup_bus_err(ChipletDef::<F>::from_air(&bad));
886    }
887
888    #[test]
889    fn chiplet_def_accepts_paired_spec_with_mutex() {
890        let good = PairedAir { with_mutex: true };
891        ChipletDef::<F>::from_air(&good).expect("paired AIR with mutex must snapshot");
892    }
893
894    #[test]
895    fn chiplet_def_rejects_paired_spec_without_boolean_roots() {
896        assert_logup_bus_err(ChipletDef::<F>::from_air(&PairedNoBoolAir));
897    }
898
899    #[test]
900    fn validator_rejects_recv_selector_without_send_selector() {
901        let spec = PermutationCheckSpec {
902            sources: vec![
903                (Source::Column(PairedAirCols::KEY), b"k_a" as ChallengeLabel),
904                (Source::RowIndexLeBytes(4), b"k_clk" as ChallengeLabel),
905            ],
906            selector: None,
907            recv_selector: Some(PairedAirCols::S_RECV),
908            kind: BusKind::Permutation,
909            clock_waiver: None,
910        };
911
912        let ast = ConstraintSystem::<F>::new().build();
913
914        assert_logup_bus_err(validate_paired_bus_mutex(
915            &[("asym_bus".into(), spec)],
916            &ast,
917        ));
918    }
919}