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, FixedColumn, ProgramCell, validate_fixed_columns};
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    fixed_columns: Vec<FixedColumn<F>>,
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 fixed_columns = p.fixed_columns();
73        validate_fixed_columns(&fixed_columns, p.virtual_column_layout(), 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            fixed_columns,
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        fixed_columns: Vec<FixedColumn<F>>,
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
139        let virt_layout = match &expander {
140            Some(e) => e.virtual_layout(),
141            None => virtual_column_layout.as_slice(),
142        };
143
144        validate_fixed_columns(&fixed_columns, virt_layout, None)?;
145
146        Ok(Self {
147            name,
148            num_columns,
149            constraint_ast,
150            column_layout,
151            virtual_column_layout,
152            boundary_constraints,
153            fixed_columns,
154            expander,
155            permutation_checks,
156        })
157    }
158}
159
160impl<F: TowerField> Clone for ChipletDef<F> {
161    fn clone(&self) -> Self {
162        Self {
163            name: self.name.clone(),
164            num_columns: self.num_columns,
165            constraint_ast: self.constraint_ast.clone(),
166            column_layout: self.column_layout.clone(),
167            virtual_column_layout: self.virtual_column_layout.clone(),
168            boundary_constraints: self.boundary_constraints.clone(),
169            fixed_columns: self.fixed_columns.clone(),
170            expander: self.expander.clone(),
171            permutation_checks: self.permutation_checks.clone(),
172        }
173    }
174}
175
176impl<F: TowerField> Air<F> for ChipletDef<F> {
177    fn name(&self) -> String {
178        self.name.clone()
179    }
180
181    fn num_columns(&self) -> usize {
182        self.num_columns
183    }
184
185    fn boundary_constraints(&self) -> Vec<BoundaryConstraint<F>> {
186        self.boundary_constraints.clone()
187    }
188
189    fn column_layout(&self) -> &[ColumnType] {
190        &self.column_layout
191    }
192
193    fn virtual_column_layout(&self) -> &[ColumnType] {
194        match &self.expander {
195            Some(e) => e.virtual_layout(),
196            None => &self.virtual_column_layout,
197        }
198    }
199
200    fn permutation_checks(&self) -> Vec<(String, PermutationCheckSpec)> {
201        self.permutation_checks.clone()
202    }
203
204    fn fixed_columns(&self) -> Vec<FixedColumn<F>> {
205        self.fixed_columns.clone()
206    }
207
208    fn virtual_expander(&self) -> Option<&VirtualExpander> {
209        self.expander.as_ref()
210    }
211
212    fn parse_virtual_row(&self, bytes: &[u8], res: &mut Vec<Flat<F>>)
213    where
214        F: TraceCompatibleField,
215    {
216        if let Some(e) = &self.expander {
217            res.clear();
218
219            e.parse_row(bytes, res)
220                .expect("committed row byte length must match physical_row_bytes");
221            return;
222        }
223
224        res.clear();
225
226        let mut offset = 0;
227        for col_type in &self.column_layout {
228            let size = col_type.byte_size();
229            if offset + size <= bytes.len() {
230                res.push(col_type.parse_from_bytes(&bytes[offset..offset + size]));
231                offset += size;
232            }
233        }
234    }
235
236    fn constraint_ast(&self) -> ConstraintAst<F> {
237        self.constraint_ast.clone()
238    }
239}
240
241// =================================================================
242// Composite Chiplet Composition
243// =================================================================
244
245/// Factory trait for deterministic
246/// ChipletDef construction.
247trait AirFactory<F: TowerField>: Send + Sync {
248    fn build(&self) -> errors::Result<ChipletDef<F>>;
249    fn permutation_checks(&self) -> Vec<(String, PermutationCheckSpec)>;
250    fn clone_box(&self) -> Box<dyn AirFactory<F>>;
251}
252
253impl<F, A> AirFactory<F> for A
254where
255    F: TowerField + TraceCompatibleField + PackableField + HardwareField + 'static,
256    <F as PackableField>::Packed: Copy + Send + Sync,
257    A: Air<F> + Clone + Send + Sync + 'static,
258{
259    fn build(&self) -> errors::Result<ChipletDef<F>> {
260        ChipletDef::from_air(self)
261    }
262
263    fn permutation_checks(&self) -> Vec<(String, PermutationCheckSpec)> {
264        Air::permutation_checks(self)
265    }
266
267    fn clone_box(&self) -> Box<dyn AirFactory<F>> {
268        Box::new(self.clone())
269    }
270}
271
272impl<F: TowerField> Clone for Box<dyn AirFactory<F>> {
273    fn clone(&self) -> Self {
274        self.clone_box()
275    }
276}
277
278struct CompositeEntry<F: TraceCompatibleField> {
279    air: Box<dyn AirFactory<F>>,
280}
281
282impl<F: TraceCompatibleField> Clone for CompositeEntry<F> {
283    fn clone(&self) -> Self {
284        Self {
285            air: self.air.clone_box(),
286        }
287    }
288}
289
290/// Build-time composition of peer
291/// chiplets into a single unit.
292///
293/// Flattens into standard `ChipletDef` entries
294/// for the prover, no protocol-level awareness
295/// of hierarchy. Internal buses are namespaced
296/// with `"{name}::"` to prevent cross-composite
297/// collisions. External buses pass through
298/// unchanged.
299///
300/// All chiplets within a composite are peers, no mandatory root.
301pub struct CompositeChiplet<F: TraceCompatibleField> {
302    name: String,
303    chiplets: Vec<CompositeEntry<F>>,
304    external_bus_ids: Vec<String>,
305    external_buses: Vec<(String, PermutationCheckSpec)>,
306}
307
308impl<F: TraceCompatibleField> Clone for CompositeChiplet<F> {
309    fn clone(&self) -> Self {
310        Self {
311            name: self.name.clone(),
312            chiplets: self.chiplets.clone(),
313            external_bus_ids: self.external_bus_ids.clone(),
314            external_buses: self.external_buses.clone(),
315        }
316    }
317}
318
319impl<F: TraceCompatibleField> CompositeChiplet<F> {
320    /// Start building a composite
321    /// with the given namespace.
322    pub fn builder(name: &str) -> CompositeChipletBuilder<F> {
323        CompositeChipletBuilder {
324            name: String::from(name),
325            chiplets: Vec::new(),
326            external_bus_ids: Vec::new(),
327            external_buses: Vec::new(),
328        }
329    }
330
331    /// Produce fresh ChipletDefs for `Program::chiplet_defs()`.
332    pub fn flatten_defs(&self) -> errors::Result<Vec<ChipletDef<F>>> {
333        let mut out = Vec::with_capacity(self.chiplets.len());
334        for entry in &self.chiplets {
335            let mut def = entry.air.build()?;
336            def.prefix_bus_ids(&self.name, &self.external_bus_ids);
337
338            out.push(def);
339        }
340
341        Ok(out)
342    }
343
344    /// External buses for `Program::permutation_checks()`.
345    ///
346    /// These are main-trace-side specs, column indices
347    /// reference the main trace, not any chiplet trace.
348    pub fn external_buses(&self) -> Vec<(String, PermutationCheckSpec)> {
349        self.external_buses.clone()
350    }
351
352    /// Number of flattened
353    /// chiplets in this composite.
354    pub fn len(&self) -> usize {
355        self.chiplets.len()
356    }
357
358    /// Returns true if this
359    /// composite contains no chiplets.
360    pub fn is_empty(&self) -> bool {
361        self.chiplets.is_empty()
362    }
363
364    /// The composite's namespace.
365    pub fn name(&self) -> &str {
366        &self.name
367    }
368}
369
370/// Builder for `CompositeChiplet`.
371pub struct CompositeChipletBuilder<F: TraceCompatibleField> {
372    name: String,
373    chiplets: Vec<CompositeEntry<F>>,
374    external_bus_ids: Vec<String>,
375    external_buses: Vec<(String, PermutationCheckSpec)>,
376}
377
378impl<F: TraceCompatibleField> CompositeChipletBuilder<F> {
379    /// Add a sub-chiplet.
380    pub fn chiplet<A>(mut self, air: A) -> Self
381    where
382        A: Air<F> + Clone + Send + Sync + 'static,
383        F: TraceCompatibleField + PackableField + HardwareField + 'static,
384        <F as PackableField>::Packed: Copy + Send + Sync,
385    {
386        self.chiplets.push(CompositeEntry { air: Box::new(air) });
387
388        self
389    }
390
391    /// Declare an external bus
392    /// (connects to the main trace).
393    pub fn external_bus(mut self, bus_id: &str, spec: PermutationCheckSpec) -> Self {
394        self.external_bus_ids.push(String::from(bus_id));
395        self.external_buses.push((String::from(bus_id), spec));
396
397        self
398    }
399
400    /// Finalize the composite.
401    ///
402    /// Validates selector orthogonality:
403    /// two specs on different bus_ids must
404    /// not share a selector column index.
405    /// Same bus_id is exempt for
406    /// dual-spec intra-table check.
407    pub fn build(self) -> errors::Result<CompositeChiplet<F>> {
408        for (bus_id, spec) in &self.external_buses {
409            spec.validate_clock_stitching(bus_id)?;
410        }
411
412        for entry in &self.chiplets {
413            let checks = entry.air.permutation_checks();
414            for i in 0..checks.len() {
415                for j in (i + 1)..checks.len() {
416                    if checks[i].0 == checks[j].0 {
417                        continue;
418                    }
419
420                    if let (Some(sel_i), Some(sel_j)) = (checks[i].1.selector, checks[j].1.selector)
421                        && sel_i == sel_j
422                    {
423                        return Err(errors::Error::Protocol {
424                            protocol: "composite_chiplet",
425                            message: "different bus_ids share a selector column",
426                        });
427                    }
428                }
429            }
430        }
431
432        Ok(CompositeChiplet {
433            name: self.name,
434            chiplets: self.chiplets,
435            external_bus_ids: self.external_bus_ids,
436            external_buses: self.external_buses,
437        })
438    }
439}
440
441// =================================================================
442// Multi-Composite Helpers
443// =================================================================
444
445/// Flatten multiple composites
446/// into a single chiplet def list.
447///
448/// Validates that no two composites
449/// share the same name (would cause
450/// bus namespace collisions).
451pub fn compose_chiplet_defs<F: TraceCompatibleField>(
452    composites: &[&CompositeChiplet<F>],
453) -> errors::Result<Vec<ChipletDef<F>>> {
454    for i in 0..composites.len() {
455        for j in (i + 1)..composites.len() {
456            if composites[i].name == composites[j].name {
457                return Err(errors::Error::Protocol {
458                    protocol: "composite_chiplet",
459                    message: "duplicate composite name in compose_chiplet_defs",
460                });
461            }
462        }
463    }
464
465    let mut defs = Vec::new();
466    for composite in composites {
467        defs.extend(composite.flatten_defs()?);
468    }
469
470    let endpoints = defs
471        .iter()
472        .flat_map(|d| d.permutation_checks.iter().map(|(id, s)| (id.as_str(), s)));
473
474    crate::permutation::validate_bus_set(endpoints)?;
475
476    Ok(defs)
477}
478
479/// Collect external buses
480/// from multiple composites.
481pub fn compose_external_buses<F: TraceCompatibleField>(
482    composites: &[&CompositeChiplet<F>],
483) -> Vec<(String, PermutationCheckSpec)> {
484    let mut buses = Vec::new();
485    for composite in composites {
486        buses.extend(composite.external_buses());
487    }
488
489    buses
490}
491
492/// Without the mutex root, both selectors high
493/// collapse the bus numerator to zero in char-2;
494/// without the boolean roots, the mutex admits
495/// non-zero field-element selectors that bypass
496/// binary on/off semantics.
497pub fn validate_paired_bus_mutex<F: TowerField>(
498    specs: &[(String, PermutationCheckSpec)],
499    ast: &ConstraintAst<F>,
500) -> errors::Result<()> {
501    for (_bus_id, spec) in specs {
502        let (s_send, s_recv) = match (spec.selector, spec.recv_selector) {
503            (Some(send), Some(recv)) => (send, recv),
504            (None, Some(_)) => {
505                return Err(errors::Error::Protocol {
506                    protocol: "logup_bus",
507                    message: "paired bus has recv_selector without send selector",
508                });
509            }
510            _ => continue,
511        };
512
513        if !ast_contains_mutex_root(ast, s_send, s_recv) {
514            return Err(errors::Error::Protocol {
515                protocol: "logup_bus",
516                message: "paired bus requires `s_send · s_recv = 0` mutex root in the AST",
517            });
518        }
519
520        if !ast_contains_boolean_root(ast, s_send) {
521            return Err(errors::Error::Protocol {
522                protocol: "logup_bus",
523                message: "paired bus requires boolean-assertion root for s_send",
524            });
525        }
526
527        if !ast_contains_boolean_root(ast, s_recv) {
528            return Err(errors::Error::Protocol {
529                protocol: "logup_bus",
530                message: "paired bus requires boolean-assertion root for s_recv",
531            });
532        }
533    }
534
535    Ok(())
536}
537
538/// Chiplets carry no `public_inputs`, so a `PublicInput`
539/// boundary target is unsatisfiable; reject it at snapshot
540/// time. Also rejects out-of-range `col_idx`.
541fn validate_chiplet_boundaries<F>(
542    boundaries: &[BoundaryConstraint<F>],
543    num_columns: usize,
544) -> errors::Result<()> {
545    for bc in boundaries {
546        if bc.col_idx >= num_columns {
547            return Err(errors::Error::Protocol {
548                protocol: "boundary",
549                message: "chiplet boundary col_idx out of range",
550            });
551        }
552
553        if matches!(bc.target, BoundaryTarget::PublicInput(_)) {
554            return Err(errors::Error::Protocol {
555                protocol: "boundary",
556                message: "chiplet boundaries must use BoundaryTarget::Constant",
557            });
558        }
559    }
560
561    Ok(())
562}
563
564/// Non-paired `Bit` selectors with no direct
565/// `s·s + s` boolean root, each tagged with the
566/// declaring `bus_id`. Advisory only: booleanness
567/// can hold indirectly (one-hot, disjoint
568/// products), callers warn rather than reject.
569pub fn unconstrained_bit_selectors<'a, F: TowerField>(
570    specs: &'a [(String, PermutationCheckSpec)],
571    ast: &ConstraintAst<F>,
572    virtual_layout: &[ColumnType],
573) -> Vec<(usize, &'a str)> {
574    let mut flagged: Vec<(usize, &str)> = Vec::new();
575    for (bus_id, spec) in specs {
576        if spec.recv_selector.is_some() {
577            continue;
578        }
579
580        let Some(sel) = spec.selector else {
581            continue;
582        };
583
584        if virtual_layout.get(sel) != Some(&ColumnType::Bit) {
585            continue;
586        }
587
588        if !ast_contains_boolean_root(ast, sel) && !flagged.iter().any(|(s, _)| *s == sel) {
589            flagged.push((sel, bus_id.as_str()));
590        }
591    }
592
593    flagged
594}
595
596fn ast_contains_mutex_root<F: TowerField>(
597    ast: &ConstraintAst<F>,
598    s_send: usize,
599    s_recv: usize,
600) -> bool {
601    ast.roots
602        .iter()
603        .any(|root| is_mutex_product(ast, *root, s_send, s_recv))
604}
605
606fn ast_contains_boolean_root<F: TowerField>(ast: &ConstraintAst<F>, col: usize) -> bool {
607    ast.roots
608        .iter()
609        .any(|root| is_boolean_assertion(ast, *root, col))
610}
611
612fn is_boolean_assertion<F: TowerField>(ast: &ConstraintAst<F>, id: ExprId, col: usize) -> bool {
613    let ConstraintExpr::Add(a, b) = ast.arena.get(id) else {
614        return false;
615    };
616
617    matches_boolean_pair(ast, *a, *b, col) || matches_boolean_pair(ast, *b, *a, col)
618}
619
620fn matches_boolean_pair<F: TowerField>(
621    ast: &ConstraintAst<F>,
622    sq_id: ExprId,
623    cell_id: ExprId,
624    col: usize,
625) -> bool {
626    let ConstraintExpr::Mul(x, y) = ast.arena.get(sq_id) else {
627        return false;
628    };
629
630    current_col_idx(ast, *x) == Some(col)
631        && current_col_idx(ast, *y) == Some(col)
632        && current_col_idx(ast, cell_id) == Some(col)
633}
634
635fn is_mutex_product<F: TowerField>(
636    ast: &ConstraintAst<F>,
637    id: ExprId,
638    s_send: usize,
639    s_recv: usize,
640) -> bool {
641    let ConstraintExpr::Mul(a, b) = ast.arena.get(id) else {
642        return false;
643    };
644
645    let lhs = current_col_idx(ast, *a);
646    let rhs = current_col_idx(ast, *b);
647
648    matches!(
649        (lhs, rhs),
650        (Some(x), Some(y)) if (x == s_send && y == s_recv) || (x == s_recv && y == s_send)
651    )
652}
653
654fn current_col_idx<F: TowerField>(ast: &ConstraintAst<F>, id: ExprId) -> Option<usize> {
655    match ast.arena.get(id) {
656        ConstraintExpr::Cell(ProgramCell {
657            col_idx,
658            next_row: false,
659        }) => Some(*col_idx),
660        _ => None,
661    }
662}
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667    use crate::ConstraintAst;
668    use crate::constraint::builder::ConstraintSystem;
669    use crate::define_columns;
670    use crate::permutation::{BusKind, ChallengeLabel, PermutationCheckSpec, Source};
671    use alloc::string::String;
672    use alloc::vec;
673    use hekate_core::trace::ColumnType;
674    use hekate_math::Block128;
675
676    type F = Block128;
677
678    define_columns! {
679        PairedAirCols {
680            KEY: B32,
681            S_SEND: Bit,
682            S_RECV: Bit,
683        }
684    }
685
686    #[derive(Clone)]
687    struct OneBusAir {
688        spec: PermutationCheckSpec,
689    }
690
691    impl Air<F> for OneBusAir {
692        fn num_columns(&self) -> usize {
693            2
694        }
695
696        fn column_layout(&self) -> &[ColumnType] {
697            &[ColumnType::B32, ColumnType::Bit]
698        }
699
700        fn permutation_checks(&self) -> Vec<(String, PermutationCheckSpec)> {
701            vec![("test_bus".into(), self.spec.clone())]
702        }
703
704        fn constraint_ast(&self) -> ConstraintAst<F> {
705            ConstraintSystem::<F>::new().build()
706        }
707    }
708
709    #[derive(Clone)]
710    struct PairedAir {
711        with_mutex: bool,
712    }
713
714    impl Air<F> for PairedAir {
715        fn num_columns(&self) -> usize {
716            PairedAirCols::NUM_COLUMNS
717        }
718
719        fn column_layout(&self) -> &[ColumnType] {
720            static LAYOUT: std::sync::OnceLock<Vec<ColumnType>> = std::sync::OnceLock::new();
721            LAYOUT.get_or_init(PairedAirCols::build_layout)
722        }
723
724        fn permutation_checks(&self) -> Vec<(String, PermutationCheckSpec)> {
725            let sources = vec![
726                (Source::Column(PairedAirCols::KEY), b"k_a" as ChallengeLabel),
727                (Source::RowIndexLeBytes(4), b"k_clk" as ChallengeLabel),
728            ];
729
730            vec![(
731                "paired_test_bus".into(),
732                PermutationCheckSpec::new_paired(
733                    sources,
734                    PairedAirCols::S_SEND,
735                    PairedAirCols::S_RECV,
736                    BusKind::Permutation,
737                ),
738            )]
739        }
740
741        fn constraint_ast(&self) -> ConstraintAst<F> {
742            let cs = ConstraintSystem::<F>::new();
743
744            cs.assert_boolean(cs.col(PairedAirCols::S_SEND));
745            cs.assert_boolean(cs.col(PairedAirCols::S_RECV));
746
747            if self.with_mutex {
748                cs.constrain_named(
749                    "paired_bus_mutex",
750                    cs.col(PairedAirCols::S_SEND) * cs.col(PairedAirCols::S_RECV),
751                );
752            }
753
754            cs.build()
755        }
756    }
757
758    #[derive(Clone)]
759    struct PairedNoBoolAir;
760
761    impl Air<F> for PairedNoBoolAir {
762        fn num_columns(&self) -> usize {
763            PairedAirCols::NUM_COLUMNS
764        }
765
766        fn column_layout(&self) -> &[ColumnType] {
767            static LAYOUT: std::sync::OnceLock<Vec<ColumnType>> = std::sync::OnceLock::new();
768            LAYOUT.get_or_init(PairedAirCols::build_layout)
769        }
770
771        fn permutation_checks(&self) -> Vec<(String, PermutationCheckSpec)> {
772            let sources = vec![
773                (Source::Column(PairedAirCols::KEY), b"k_a" as ChallengeLabel),
774                (Source::RowIndexLeBytes(4), b"k_clk" as ChallengeLabel),
775            ];
776
777            vec![(
778                "paired_test_bus".into(),
779                PermutationCheckSpec::new_paired(
780                    sources,
781                    PairedAirCols::S_SEND,
782                    PairedAirCols::S_RECV,
783                    BusKind::Permutation,
784                ),
785            )]
786        }
787
788        fn constraint_ast(&self) -> ConstraintAst<F> {
789            let cs = ConstraintSystem::<F>::new();
790
791            cs.constrain_named(
792                "paired_bus_mutex",
793                cs.col(PairedAirCols::S_SEND) * cs.col(PairedAirCols::S_RECV),
794            );
795
796            cs.build()
797        }
798    }
799
800    fn key_only() -> Vec<(Source, ChallengeLabel)> {
801        vec![(Source::Column(0), b"k_a")]
802    }
803
804    fn key_with_clock() -> Vec<(Source, ChallengeLabel)> {
805        vec![
806            (Source::Column(0), b"k_a"),
807            (Source::RowIndexLeBytes(4), b"k_clk"),
808        ]
809    }
810
811    fn snapshot(spec: PermutationCheckSpec) -> errors::Result<ChipletDef<F>> {
812        ChipletDef::from_air(&OneBusAir { spec })
813    }
814
815    fn assert_logup_bus_err<T>(res: errors::Result<T>) {
816        match res {
817            Err(errors::Error::Protocol { protocol, .. }) => {
818                assert_eq!(protocol, "logup_bus");
819            }
820            Ok(_) => panic!("expected Err(Protocol {{ protocol: \"logup_bus\", .. }})"),
821            Err(other) => panic!("expected Err(Protocol), got {:?}", other),
822        }
823    }
824
825    #[test]
826    fn def_rejects_permutation_without_clock() {
827        let spec = PermutationCheckSpec::new(key_only(), Some(1));
828        assert_logup_bus_err(snapshot(spec));
829    }
830
831    #[test]
832    fn def_rejects_permutation_with_empty_waiver() {
833        let spec = PermutationCheckSpec::new(key_only(), Some(1)).with_clock_waiver("");
834        assert_logup_bus_err(snapshot(spec));
835    }
836
837    #[test]
838    fn def_rejects_permutation_with_clock_and_waiver() {
839        let spec =
840            PermutationCheckSpec::new(key_with_clock(), Some(1)).with_clock_waiver("redundant");
841        assert_logup_bus_err(snapshot(spec));
842    }
843
844    #[test]
845    fn def_rejects_lookup_with_waiver() {
846        let spec = PermutationCheckSpec::new_lookup(key_only(), Some(1)).with_clock_waiver("nope");
847        assert_logup_bus_err(snapshot(spec));
848    }
849
850    #[test]
851    fn def_accepts_permutation_with_row_index() {
852        let spec = PermutationCheckSpec::new(key_with_clock(), Some(1));
853        snapshot(spec).expect("permutation bus with row-index source must accept");
854    }
855
856    #[test]
857    fn def_accepts_permutation_with_clock_waiver() {
858        let spec = PermutationCheckSpec::new(key_only(), Some(1))
859            .with_clock_waiver("see foo.rs:42: structurally unique by AIR body");
860        snapshot(spec).expect("permutation bus with non-empty clock_waiver must accept");
861    }
862
863    #[test]
864    fn def_accepts_lookup_without_clock() {
865        let spec = PermutationCheckSpec::new_lookup(key_only(), Some(1));
866        snapshot(spec).expect("lookup bus without clock must accept");
867    }
868
869    #[test]
870    fn def_rejects_permutation_with_too_short_waiver() {
871        let spec = PermutationCheckSpec::new(key_only(), Some(1)).with_clock_waiver("see x.rs");
872        assert_logup_bus_err(snapshot(spec));
873    }
874
875    #[test]
876    fn def_rejects_permutation_with_missing_see_citation() {
877        let spec = PermutationCheckSpec::new(key_only(), Some(1))
878            .with_clock_waiver("structurally unique by AIR body but no file citation prefix here");
879        assert_logup_bus_err(snapshot(spec));
880    }
881
882    #[test]
883    fn paired_bus_emits_mutex_and_boolean_assertions() {
884        let cs = ConstraintSystem::<F>::new();
885
886        cs.assert_paired_bus_mutex(PairedAirCols::S_SEND, PairedAirCols::S_RECV);
887
888        let ast = cs.build();
889
890        let labels: Vec<_> = ast.labels.iter().filter_map(|l| *l).collect();
891
892        assert_eq!(
893            labels.iter().filter(|l| **l == "boolean").count(),
894            2,
895            "gadget must emit two boolean assertions"
896        );
897        assert_eq!(
898            labels.iter().filter(|l| **l == "paired_bus_mutex").count(),
899            1,
900            "gadget must emit exactly one mutex root"
901        );
902    }
903
904    #[test]
905    fn paired_bus_shares_cell_nodes() {
906        let cs = ConstraintSystem::<F>::new();
907
908        let send_first = cs.col(PairedAirCols::S_SEND);
909        let recv_first = cs.col(PairedAirCols::S_RECV);
910
911        cs.assert_paired_bus_mutex(PairedAirCols::S_SEND, PairedAirCols::S_RECV);
912
913        let send_again = cs.col(PairedAirCols::S_SEND);
914        let recv_again = cs.col(PairedAirCols::S_RECV);
915
916        assert_eq!(send_first.id, send_again.id, "S_SEND must dedup");
917        assert_eq!(recv_first.id, recv_again.id, "S_RECV must dedup");
918    }
919
920    #[test]
921    fn chiplet_def_rejects_paired_spec_without_mutex() {
922        let bad = PairedAir { with_mutex: false };
923        assert_logup_bus_err(ChipletDef::<F>::from_air(&bad));
924    }
925
926    #[test]
927    fn chiplet_def_accepts_paired_spec_with_mutex() {
928        let good = PairedAir { with_mutex: true };
929        ChipletDef::<F>::from_air(&good).expect("paired AIR with mutex must snapshot");
930    }
931
932    #[test]
933    fn chiplet_def_rejects_paired_spec_without_boolean_roots() {
934        assert_logup_bus_err(ChipletDef::<F>::from_air(&PairedNoBoolAir));
935    }
936
937    #[test]
938    fn validator_rejects_recv_selector_without_send_selector() {
939        let spec = PermutationCheckSpec {
940            sources: vec![
941                (Source::Column(PairedAirCols::KEY), b"k_a" as ChallengeLabel),
942                (Source::RowIndexLeBytes(4), b"k_clk" as ChallengeLabel),
943            ],
944            selector: None,
945            recv_selector: Some(PairedAirCols::S_RECV),
946            kind: BusKind::Permutation,
947            clock_waiver: None,
948        };
949
950        let ast = ConstraintSystem::<F>::new().build();
951
952        assert_logup_bus_err(validate_paired_bus_mutex(
953            &[("asym_bus".into(), spec)],
954            &ast,
955        ));
956    }
957
958    #[test]
959    fn flags_bit_selector_without_boolean_root() {
960        let ast = ConstraintSystem::<F>::new().build();
961        let layout = vec![ColumnType::B32, ColumnType::Bit];
962        let specs = vec![(
963            String::from("bus"),
964            PermutationCheckSpec::new(vec![(Source::Column(0), b"k" as ChallengeLabel)], Some(1)),
965        )];
966
967        assert_eq!(
968            unconstrained_bit_selectors(&specs, &ast, &layout),
969            vec![(1, "bus")]
970        );
971    }
972
973    #[test]
974    fn boolean_root_clears_bit_selector() {
975        let cs = ConstraintSystem::<F>::new();
976        let sel = cs.col(1);
977        cs.assert_boolean(sel);
978        let ast = cs.build();
979
980        let layout = vec![ColumnType::B32, ColumnType::Bit];
981        let specs = vec![(
982            String::from("bus"),
983            PermutationCheckSpec::new(vec![(Source::Column(0), b"k" as ChallengeLabel)], Some(1)),
984        )];
985
986        assert!(unconstrained_bit_selectors(&specs, &ast, &layout).is_empty());
987    }
988}