Skip to main content

hekate_program/permutation/
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
5use alloc::collections::BTreeMap;
6use alloc::string::String;
7use alloc::vec::Vec;
8use hekate_core::errors;
9use hekate_math::{Flat, HardwareField, TowerField};
10
11use crate::FixedColumn;
12
13mod rank;
14
15pub use rank::{
16    OMEGA, OMEGA_ORDER, RankClock, RankTable, TableHeight, rank_clocks, validate_ordered_buses,
17    validate_ordered_emits,
18};
19
20/// Slot identity;
21/// position fixes the `β` power, never the label.
22pub type ChallengeLabel = &'static [u8];
23
24/// Clock slot of an ordered service bus, on both endpoints.
25pub const EMIT_RANK_LABEL: ChallengeLabel = b"kappa_emit_rank";
26
27/// `eval_row_idx_le_mle` folds at most eight
28/// bytes; a wider clock truncates silently.
29pub const MAX_ROW_INDEX_BYTES: usize = 8;
30
31/// LogUp bus semantics.
32///
33/// Cross-bus check sums `claimed_sum` over all
34/// endpoints sharing a `bus_id` and rejects if
35/// the total is non-zero in `GF(2^128)`.
36#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
37pub enum BusKind {
38    #[default]
39    Permutation,
40    Lookup,
41}
42
43/// The half of an ordered bus an endpoint belongs to;
44/// the `t`-th request pairs with the `t`-th response.
45#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
46pub enum Side {
47    Request,
48    Response,
49}
50
51/// Byte source for the LogUp
52/// key `Σ β^j · source_j(i)`.
53#[derive(Clone, Debug, PartialEq, Eq)]
54pub enum Source {
55    /// Single byte column from the trace.
56    Column(usize),
57
58    /// Multiple byte columns stitched into one
59    /// key segment via the global `β` schedule.
60    Columns(Vec<usize>),
61
62    /// Virtual clock derived from the row index;
63    /// `num_bytes` controls the byte width. Pins per-row
64    /// uniqueness against char-2 parity collapse.
65    RowIndexLeBytes(usize),
66
67    /// Constant byte for cross-table domain separation.
68    Const(u128),
69
70    /// Virtual single byte `k` of the row index.
71    RowIndexByte(usize),
72
73    /// Column replacing the top byte of
74    /// a requester's row-index clock;
75    /// must be an overlay `fixed_columns()` pin.
76    PhaseColumn(usize),
77
78    /// `ω^(base + t)` on the endpoint's `t`-th emit,
79    /// computed by the verifier from the pinned selector.
80    EmitRank(Side),
81}
82
83/// ZeroCheck weights of one bus: its consistency
84/// term, and its bus-sum term with its claimed sum.
85#[derive(Clone, Copy)]
86pub struct BusWeights<F: HardwareField> {
87    pub consistency: Flat<F>,
88    pub bus_sum: Flat<F>,
89}
90
91/// One endpoint of a LogUp bus.
92///
93/// Per row `i`:
94/// `h(i) = s(i) / (γ + Σ β^j · source_j(i))`.
95/// The endpoint contributes `claimed_sum = Σ_i h(i)` (Permutation)
96/// or `Σ_i Eq(r_bus, i) · h(i)` (Lookup) to the cross-bus check.
97///
98/// # Security
99/// `Permutation` kind cancels in char-2 by multiset parity;
100/// endpoints with even per-key multiplicity collapse silently.
101/// Use `Source::EmitRank` or a row-index source in the key to force
102/// per-row uniqueness, or switch to `BusKind::Lookup` for positional binding.
103#[derive(Clone, Debug)]
104pub struct PermutationCheckSpec {
105    pub kind: BusKind,
106
107    /// Key sources;
108    /// slot order fixes the `β` schedule.
109    pub sources: Vec<(Source, ChallengeLabel)>,
110
111    /// Selector column gating the row's `h`.
112    /// `None` means the row is unconditionally active.
113    pub selector: Option<usize>,
114
115    /// Receive-side selector for paired buses.
116    /// AIR must enforce `s_send · s_recv = 0` (char-2 mutex).
117    pub recv_selector: Option<usize>,
118
119    /// Audited carve-out citation for `Permutation`
120    /// specs that intentionally omit a row-index source.
121    pub clock_waiver: Option<String>,
122}
123
124impl PermutationCheckSpec {
125    pub fn new(sources: Vec<(Source, ChallengeLabel)>, selector: Option<usize>) -> Self {
126        Self {
127            sources,
128            selector,
129            recv_selector: None,
130            kind: BusKind::Permutation,
131            clock_waiver: None,
132        }
133    }
134
135    /// Endpoints on a `Lookup` bus must be
136    /// pointwise-equal on the padded hypercube;
137    /// use only when positional binding holds.
138    pub fn new_lookup(sources: Vec<(Source, ChallengeLabel)>, selector: Option<usize>) -> Self {
139        Self {
140            sources,
141            selector,
142            recv_selector: None,
143            kind: BusKind::Lookup,
144            clock_waiver: None,
145        }
146    }
147
148    /// Caller must enforce `s_send · s_recv = 0` in the AIR;
149    /// without it, rows with both selectors high collapse to
150    /// zero in char-2 and slip past cross-bus cancellation.
151    pub fn new_paired(
152        sources: Vec<(Source, ChallengeLabel)>,
153        s_send: usize,
154        s_recv: usize,
155        kind: BusKind,
156    ) -> Self {
157        Self {
158            sources,
159            selector: Some(s_send),
160            recv_selector: Some(s_recv),
161            kind,
162            clock_waiver: None,
163        }
164    }
165
166    /// Audited escape hatch. `reason` must start
167    /// with `"see "` and cite the load-bearing AIR
168    /// constraint (`see <path>: <argument>`).
169    pub fn with_clock_waiver(mut self, reason: impl Into<String>) -> Self {
170        self.clock_waiver = Some(reason.into());
171        self
172    }
173
174    pub fn num_sources(&self) -> usize {
175        self.sources.len()
176    }
177
178    pub fn has_selector(&self) -> bool {
179        self.selector.is_some()
180    }
181
182    pub fn has_paired(&self) -> bool {
183        self.recv_selector.is_some()
184    }
185
186    /// Reindexes column references when the
187    /// chiplet is embedded into a wider trace.
188    pub fn shift_column_indices(&mut self, offset: usize) {
189        for (source, _) in &mut self.sources {
190            match source {
191                Source::Column(idx) | Source::PhaseColumn(idx) => *idx += offset,
192                Source::Columns(indices) => {
193                    for idx in indices {
194                        *idx += offset;
195                    }
196                }
197                _ => {}
198            }
199        }
200
201        if let Some(sel_idx) = &mut self.selector {
202            *sel_idx += offset;
203        }
204
205        if let Some(sel_idx) = &mut self.recv_selector {
206            *sel_idx += offset;
207        }
208    }
209
210    /// Only structural guarantor of per-row uniqueness;
211    /// label-only stitching is forgeable.
212    pub fn has_real_clock_source(&self) -> bool {
213        self.sources.iter().any(|(src, _)| {
214            matches!(
215                src,
216                Source::RowIndexLeBytes(_) | Source::RowIndexByte(_) | Source::EmitRank(_)
217            )
218        })
219    }
220
221    /// A Permutation spec needs a row-index or rank source or
222    /// a well-formed waiver, never both; a Lookup spec takes no waiver.
223    pub fn validate_clock_stitching(&self, _bus_id: &str) -> errors::Result<()> {
224        for (src, _) in &self.sources {
225            let width_ok = match src {
226                Source::RowIndexLeBytes(n) => *n >= 1 && *n <= MAX_ROW_INDEX_BYTES,
227                Source::RowIndexByte(k) => *k < MAX_ROW_INDEX_BYTES,
228                Source::Column(_)
229                | Source::Columns(_)
230                | Source::Const(_)
231                | Source::PhaseColumn(_)
232                | Source::EmitRank(_) => true,
233            };
234
235            if !width_ok {
236                return Err(errors::Error::Protocol {
237                    protocol: "logup_bus",
238                    message: "row-index clock wider than 8 bytes; the row-index \
239                              MLE folds at most 8 and truncates the rest silently",
240                });
241            }
242        }
243
244        let waiver_status = self.clock_waiver.as_deref().map(WaiverStatus::classify);
245
246        match (self.kind, self.has_real_clock_source(), waiver_status) {
247            (BusKind::Lookup, _, Some(_)) => Err(errors::Error::Protocol {
248                protocol: "logup_bus",
249                message: "lookup bus carries a clock_waiver; waivers only apply \
250                          to Permutation kind, drop the .with_clock_waiver(...) call",
251            }),
252            (BusKind::Permutation, true, Some(_)) => Err(errors::Error::Protocol {
253                protocol: "logup_bus",
254                message: "permutation bus carries both a clock source and a \
255                          clock_waiver; pick one shape",
256            }),
257            (BusKind::Permutation, false, Some(WaiverStatus::Empty)) => {
258                Err(errors::Error::Protocol {
259                    protocol: "logup_bus",
260                    message: "permutation bus has an empty clock_waiver; provide a \
261                              non-empty reason citing the load-bearing AIR constraint",
262                })
263            }
264            (BusKind::Permutation, false, Some(WaiverStatus::TooShort)) => {
265                Err(errors::Error::Protocol {
266                    protocol: "logup_bus",
267                    message: "permutation bus has an under-specified clock_waiver; \
268                              the reason must be at least 32 chars and cite a file/line \
269                              of the load-bearing AIR constraint",
270                })
271            }
272            (BusKind::Permutation, false, Some(WaiverStatus::MissingCitation)) => {
273                Err(errors::Error::Protocol {
274                    protocol: "logup_bus",
275                    message: "permutation bus clock_waiver lacks a 'see <path>' citation; \
276                              waiver text must start with 'see ' followed by the file path \
277                              of the load-bearing AIR constraint",
278                })
279            }
280            (BusKind::Permutation, false, None) => Err(errors::Error::Protocol {
281                protocol: "logup_bus",
282                message: "permutation bus lacks per-row clock stitching; give the \
283                          Service an EmitRank slot, give a raw spec a RowIndexLeBytes \
284                          or RowIndexByte source, or add a clock_waiver citing the AIR \
285                          constraint that forces per-row uniqueness",
286            }),
287            _ => Ok(()),
288        }
289    }
290}
291
292/// One key slot; its position fixes the `β` power.
293#[derive(Clone, Copy, Debug, PartialEq, Eq)]
294pub enum ServiceSlot {
295    /// Bound to a table column on each endpoint.
296    Value(ChallengeLabel),
297
298    /// Same constant on both endpoints
299    /// (cross-table domain separation).
300    Const(ChallengeLabel, u128),
301
302    /// Ordered-bus clock: the verifier supplies `ω^(base + t)`
303    /// on both endpoints, with no committed column on either side.
304    EmitRank,
305}
306
307/// Bus key schema declared once by the serving
308/// chiplet; both endpoints derive their spec from it.
309#[derive(Clone, Debug, PartialEq, Eq)]
310pub struct Service {
311    pub bus_id: &'static str,
312    pub kind: BusKind,
313    pub slots: Vec<ServiceSlot>,
314}
315
316impl Service {
317    pub fn num_values(&self) -> usize {
318        self.slots
319            .iter()
320            .filter(|s| matches!(s, ServiceSlot::Value(_)))
321            .count()
322    }
323
324    /// Requester endpoint: `values` bind the `Value` slots
325    /// in order; the `EmitRank` slot takes the request side.
326    pub fn request(
327        &self,
328        values: &[usize],
329        selector: usize,
330    ) -> errors::Result<PermutationCheckSpec> {
331        self.spec(values, Side::Request, selector)
332    }
333
334    /// Responder endpoint: `values` bind the `Value` slots
335    /// in order; the `EmitRank` slot takes the response side.
336    pub fn respond(
337        &self,
338        values: &[usize],
339        selector: usize,
340    ) -> errors::Result<PermutationCheckSpec> {
341        self.spec(values, Side::Response, selector)
342    }
343
344    fn spec(
345        &self,
346        values: &[usize],
347        side: Side,
348        selector: usize,
349    ) -> errors::Result<PermutationCheckSpec> {
350        let rank_slots = self
351            .slots
352            .iter()
353            .filter(|s| matches!(s, ServiceSlot::EmitRank))
354            .count();
355
356        let clock_ok = matches!(
357            (self.kind, rank_slots),
358            (BusKind::Permutation, 1) | (BusKind::Lookup, 0)
359        );
360
361        if !clock_ok {
362            return Err(errors::Error::Protocol {
363                protocol: "service",
364                message: "Permutation schema needs exactly one EmitRank slot; \
365                          Lookup schema must have none",
366            });
367        }
368
369        if values.len() != self.num_values() {
370            return Err(errors::Error::Protocol {
371                protocol: "service",
372                message: "value column count does not match the schema's Value slots",
373            });
374        }
375
376        let mut sources = Vec::with_capacity(self.slots.len());
377        let mut next_value = values.iter();
378
379        for slot in &self.slots {
380            match slot {
381                ServiceSlot::Value(label) => {
382                    let col = next_value.next().ok_or(errors::Error::Protocol {
383                        protocol: "service",
384                        message: "value column count does not match the schema's Value slots",
385                    })?;
386
387                    sources.push((Source::Column(*col), *label));
388                }
389                ServiceSlot::Const(label, v) => {
390                    sources.push((Source::Const(*v), *label));
391                }
392                ServiceSlot::EmitRank => {
393                    sources.push((Source::EmitRank(side), EMIT_RANK_LABEL));
394                }
395            }
396        }
397
398        Ok(match self.kind {
399            BusKind::Permutation => PermutationCheckSpec::new(sources, Some(selector)),
400            BusKind::Lookup => PermutationCheckSpec::new_lookup(sources, Some(selector)),
401        })
402    }
403}
404
405#[derive(Clone, Copy, Debug, PartialEq, Eq)]
406enum WaiverStatus {
407    Empty,
408    TooShort,
409    MissingCitation,
410    Ok,
411}
412
413impl WaiverStatus {
414    const MIN_WAIVER_LEN: usize = 32;
415    const REQUIRED_PREFIX: &'static str = "see ";
416
417    fn classify(s: &str) -> Self {
418        if s.is_empty() {
419            Self::Empty
420        } else if s.len() < Self::MIN_WAIVER_LEN {
421            Self::TooShort
422        } else if !s.starts_with(Self::REQUIRED_PREFIX) {
423            Self::MissingCitation
424        } else {
425            Self::Ok
426        }
427    }
428}
429
430/// A witness selector gives the prover freedom over which
431/// rows join the bus; every selector must be `None` or an
432/// overlay-pinned member of the table's `fixed_columns()`.
433pub fn validate_fixed_selectors<F>(
434    specs: &[(String, PermutationCheckSpec)],
435    fixed: &[FixedColumn<F>],
436) -> errors::Result<()> {
437    for (_, spec) in specs {
438        if spec.selector.is_none() && spec.recv_selector.is_some() {
439            return Err(errors::Error::Protocol {
440                protocol: "logup_bus",
441                message: "paired bus has recv_selector without send selector",
442            });
443        }
444
445        let phases = spec.sources.iter().filter_map(|(src, _)| match src {
446            Source::PhaseColumn(col) => Some(*col),
447            _ => None,
448        });
449
450        for sel in [spec.selector, spec.recv_selector]
451            .into_iter()
452            .flatten()
453            .chain(phases)
454        {
455            let Some(fc) = fixed.iter().find(|fc| fc.col_idx == sel) else {
456                return Err(errors::Error::Protocol {
457                    protocol: "logup_bus",
458                    message: "bus selector or clock phase reads a witness column; \
459                              every selector, recv_selector and PhaseColumn must be \
460                              None or a member of the table's fixed_columns",
461                });
462            };
463
464            if !fc.shape.is_overlay() {
465                return Err(errors::Error::Protocol {
466                    protocol: "logup_bus",
467                    message: "bus selector or clock phase is pinned to a substituted \
468                              shape; FirstRow/LastRow/Custom replace the committed \
469                              column with a virtual poly the bus cannot read; pin \
470                              with Cadence, Segments, Periodic, Sparse or Dense",
471                });
472            }
473        }
474    }
475
476    Ok(())
477}
478
479/// Rejects a `bus_id` with a non-graphic byte, or
480/// one whose endpoints mix `Permutation` and `Lookup`.
481pub fn validate_bus_set<'a, I>(endpoints: I) -> errors::Result<()>
482where
483    I: IntoIterator<Item = (&'a str, &'a PermutationCheckSpec)>,
484{
485    let mut by_bus: BTreeMap<&'a str, Vec<&'a PermutationCheckSpec>> = BTreeMap::new();
486
487    for (bus_id, spec) in endpoints {
488        if !bus_id.bytes().all(|b| b.is_ascii_graphic()) {
489            return Err(errors::Error::Protocol {
490                protocol: "logup_bus",
491                message: "bus_id has a non-graphic byte; bus ids must be \
492                          graphic ASCII (0x21..=0x7E). A space, zero-width, \
493                          or homoglyph byte forges a visually identical \
494                          second bus that balances on its own",
495            });
496        }
497
498        by_bus.entry(bus_id).or_default().push(spec);
499    }
500
501    for specs in by_bus.values() {
502        let any_lookup = specs.iter().any(|s| s.kind == BusKind::Lookup);
503        let any_perm = specs.iter().any(|s| s.kind == BusKind::Permutation);
504
505        if any_lookup && any_perm {
506            return Err(errors::Error::Protocol {
507                protocol: "logup_bus",
508                message: "bus_id has mixed BusKind across endpoints; \
509                          all endpoints must agree on Permutation or Lookup",
510            });
511        }
512    }
513
514    Ok(())
515}
516
517/// Folds `table_rows` into `heights` for each
518/// `Lookup`-kind spec, taking the running max.
519/// Used to derive the per-bus `N_max` absorbed
520/// into the transcript before `r_bus` is drawn.
521pub fn accumulate_lookup_heights(
522    specs: &[(String, PermutationCheckSpec)],
523    table_rows: u64,
524    heights: &mut BTreeMap<String, u64>,
525) {
526    for (bus_id, spec) in specs {
527        if spec.kind == BusKind::Lookup {
528            let entry = heights.entry(bus_id.clone()).or_insert(0);
529            *entry = (*entry).max(table_rows);
530        }
531    }
532}
533
534/// MLE of `Source::RowIndexLeBytes` at `r_final`. Linear
535/// in `r_final` because `F::from` is XOR-additive over char-2.
536pub fn eval_row_idx_le_mle<F>(num_bytes: usize, r_final: &[Flat<F>]) -> Flat<F>
537where
538    F: TowerField + HardwareField + From<u128>,
539{
540    let total_bits = (num_bytes.min(8) * 8).min(r_final.len());
541
542    let mut acc = Flat::from_raw(F::ZERO);
543    for (i, r) in r_final.iter().enumerate().take(total_bits) {
544        acc += F::from(1u128 << i).to_hardware() * *r;
545    }
546
547    acc
548}
549
550/// MLE of `Source::RowIndexByte` at `r_final`. Same char-2
551/// shortcut as `eval_row_idx_le_mle`, restricted to one byte.
552pub fn eval_row_idx_byte_mle<F>(byte_idx: usize, r_final: &[Flat<F>]) -> Flat<F>
553where
554    F: TowerField + HardwareField + From<u128>,
555{
556    let bit_start = byte_idx.saturating_mul(8);
557    if bit_start >= r_final.len() {
558        return Flat::from_raw(F::ZERO);
559    }
560
561    let end = (bit_start + 8).min(r_final.len());
562
563    let mut acc = Flat::from_raw(F::ZERO);
564    for (j, i) in (bit_start..end).enumerate() {
565        acc += F::from(1u128 << j).to_hardware() * r_final[i];
566    }
567
568    acc
569}
570
571/// Bus `k` from `start = α^o`: consistency at `α^(o+2k)`, bus-sum at
572/// `α^(o+2k+1)`. One shared power pins only the sum of the two terms,
573/// and a constant consistency residual then moves the claimed sum.
574pub fn bus_alpha_weights<F: HardwareField>(
575    start: Flat<F>,
576    alpha: Flat<F>,
577) -> impl Iterator<Item = BusWeights<F>> {
578    let alpha_sq = alpha * alpha;
579
580    core::iter::successors(Some(start), move |&w| Some(w * alpha_sq)).map(move |w| BusWeights {
581        consistency: w,
582        bus_sum: w * alpha,
583    })
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589    use crate::FixedShape;
590    use hekate_math::Block128;
591
592    fn test_service() -> Service {
593        Service {
594            bus_id: "svc",
595            kind: BusKind::Permutation,
596            slots: vec![
597                ServiceSlot::Value(b"k_a"),
598                ServiceSlot::Const(b"k_dom", 7),
599                ServiceSlot::EmitRank,
600                ServiceSlot::Value(b"k_dir"),
601            ],
602        }
603    }
604
605    #[test]
606    fn permutation_spec_creation() {
607        let sources = vec![
608            (Source::Column(0), b"kappa_0" as ChallengeLabel),
609            (Source::Column(1), b"kappa_1" as ChallengeLabel),
610            (Source::RowIndexLeBytes(4), b"kappa_clk" as ChallengeLabel),
611        ];
612
613        let spec = PermutationCheckSpec::new(sources, Some(2));
614
615        assert_eq!(spec.num_sources(), 3);
616        assert!(spec.has_selector());
617        assert_eq!(spec.selector, Some(2));
618    }
619
620    #[test]
621    fn source_variants() {
622        let col = Source::Column(5);
623        let cols = Source::Columns(vec![0, 1, 2, 3]);
624        let clock = Source::RowIndexLeBytes(4);
625        let constant = Source::Const(0x01);
626
627        assert_eq!(col, Source::Column(5));
628        assert_eq!(cols, Source::Columns(vec![0, 1, 2, 3]));
629        assert_eq!(clock, Source::RowIndexLeBytes(4));
630        assert_eq!(constant, Source::Const(0x01));
631    }
632
633    #[test]
634    fn new_paired_populates_both_selectors() {
635        let spec = PermutationCheckSpec::new_paired(
636            vec![(Source::Column(0), b"k_a" as ChallengeLabel)],
637            3,
638            5,
639            BusKind::Permutation,
640        );
641
642        assert!(spec.has_selector());
643        assert!(spec.has_paired());
644        assert_eq!(spec.selector, Some(3));
645        assert_eq!(spec.recv_selector, Some(5));
646        assert_eq!(spec.kind, BusKind::Permutation);
647    }
648
649    #[test]
650    fn new_defaults_recv_selector_none() {
651        let spec =
652            PermutationCheckSpec::new(vec![(Source::Column(0), b"k_a" as ChallengeLabel)], Some(1));
653
654        assert!(!spec.has_paired());
655        assert_eq!(spec.recv_selector, None);
656    }
657
658    #[test]
659    fn new_lookup_defaults_recv_selector_none() {
660        let spec = PermutationCheckSpec::new_lookup(
661            vec![(Source::Column(0), b"k_a" as ChallengeLabel)],
662            Some(1),
663        );
664
665        assert!(!spec.has_paired());
666        assert_eq!(spec.recv_selector, None);
667    }
668
669    #[test]
670    fn shift_column_indices_covers_recv_selector() {
671        let mut spec = PermutationCheckSpec::new_paired(
672            vec![
673                (Source::Column(0), b"k_a" as ChallengeLabel),
674                (Source::Columns(vec![1, 2]), b"k_b" as ChallengeLabel),
675            ],
676            3,
677            5,
678            BusKind::Lookup,
679        );
680
681        spec.shift_column_indices(10);
682
683        assert_eq!(spec.selector, Some(13));
684        assert_eq!(spec.recv_selector, Some(15));
685
686        match &spec.sources[0].0 {
687            Source::Column(idx) => assert_eq!(*idx, 10),
688            other => panic!("expected Column, got {other:?}"),
689        }
690
691        match &spec.sources[1].0 {
692            Source::Columns(idxs) => assert_eq!(idxs, &vec![11, 12]),
693            other => panic!("expected Columns, got {other:?}"),
694        }
695    }
696
697    #[test]
698    fn validate_bus_set_rejects_homoglyph_split_bus() {
699        let clockless =
700            PermutationCheckSpec::new(vec![(Source::Column(0), b"k" as ChallengeLabel)], Some(1));
701        let other = clockless.clone();
702
703        // A zero-width twin splits this into
704        // two buses; the ASCII check blocks it.
705        assert!(
706            validate_bus_set(vec![("ram_link", &clockless), ("ram_link\u{200b}", &other)]).is_err()
707        );
708    }
709
710    #[test]
711    fn validate_bus_set_accepts_ascii_bus_id() {
712        let spec =
713            PermutationCheckSpec::new(vec![(Source::Column(0), b"k" as ChallengeLabel)], Some(1));
714
715        assert!(validate_bus_set(vec![("ram_link", &spec)]).is_ok());
716    }
717
718    #[test]
719    fn service_endpoints_share_schema() {
720        let svc = test_service();
721
722        let req = svc.request(&[10, 11], 12).unwrap();
723        let resp = svc.respond(&[20, 21], 23).unwrap();
724
725        let req_labels: Vec<_> = req.sources.iter().map(|(_, l)| *l).collect();
726        let resp_labels: Vec<_> = resp.sources.iter().map(|(_, l)| *l).collect();
727
728        assert_eq!(req_labels, resp_labels);
729        assert_eq!(req_labels[2], EMIT_RANK_LABEL);
730
731        assert_eq!(req.sources[0].0, Source::Column(10));
732        assert_eq!(req.sources[1].0, Source::Const(7));
733        assert_eq!(req.sources[2].0, Source::EmitRank(Side::Request));
734        assert_eq!(req.sources[3].0, Source::Column(11));
735        assert_eq!(req.selector, Some(12));
736
737        assert_eq!(resp.sources[0].0, Source::Column(20));
738        assert_eq!(resp.sources[1].0, Source::Const(7));
739        assert_eq!(resp.sources[2].0, Source::EmitRank(Side::Response));
740        assert_eq!(resp.sources[3].0, Source::Column(21));
741        assert_eq!(resp.selector, Some(23));
742
743        req.validate_clock_stitching("svc").unwrap();
744        resp.validate_clock_stitching("svc").unwrap();
745
746        validate_bus_set(vec![("svc", &req), ("svc", &resp)]).unwrap();
747    }
748
749    #[test]
750    fn service_rejects_schema_mismatches() {
751        let svc = test_service();
752
753        assert!(svc.request(&[10], 12).is_err());
754        assert!(svc.respond(&[20, 21, 22], 23).is_err());
755
756        let lookup = Service {
757            bus_id: "rom",
758            kind: BusKind::Lookup,
759            slots: vec![ServiceSlot::Value(b"k_a"), ServiceSlot::EmitRank],
760        };
761
762        assert!(lookup.respond(&[3], 4).is_err());
763
764        let clockless = Service {
765            bus_id: "bad",
766            kind: BusKind::Permutation,
767            slots: vec![ServiceSlot::Value(b"k_a")],
768        };
769
770        assert!(clockless.request(&[1], 2).is_err());
771
772        let two_ranks = Service {
773            bus_id: "bad",
774            kind: BusKind::Permutation,
775            slots: vec![
776                ServiceSlot::EmitRank,
777                ServiceSlot::Value(b"k_a"),
778                ServiceSlot::EmitRank,
779            ],
780        };
781
782        assert!(two_ranks.request(&[1], 2).is_err());
783    }
784
785    #[test]
786    fn lookup_service_derives_lookup_specs() {
787        let svc = Service {
788            bus_id: "rom",
789            kind: BusKind::Lookup,
790            slots: vec![ServiceSlot::Value(b"k_a")],
791        };
792
793        let req = svc.request(&[1], 2).unwrap();
794        let resp = svc.respond(&[3], 4).unwrap();
795
796        assert_eq!(req.kind, BusKind::Lookup);
797        assert_eq!(resp.kind, BusKind::Lookup);
798        assert_eq!(req.sources.len(), 1);
799    }
800
801    #[test]
802    fn column_clock_needs_waiver_whatever_its_label() {
803        let spoofed = PermutationCheckSpec::new(
804            vec![
805                (Source::Column(0), b"k_a" as ChallengeLabel),
806                (Source::Column(1), EMIT_RANK_LABEL),
807            ],
808            Some(2),
809        );
810
811        assert!(spoofed.validate_clock_stitching("svc").is_err());
812
813        spoofed
814            .with_clock_waiver(
815                "see permutation/mod.rs: a body constraint forces per-row uniqueness",
816            )
817            .validate_clock_stitching("svc")
818            .unwrap();
819    }
820
821    #[test]
822    fn witness_clock_phase_is_rejected() {
823        let phased = PermutationCheckSpec::new(
824            vec![
825                (Source::Column(10), b"k_a" as ChallengeLabel),
826                (Source::RowIndexByte(0), b"k_clk_b0" as ChallengeLabel),
827                (Source::PhaseColumn(60), b"k_clk_b1" as ChallengeLabel),
828            ],
829            Some(12),
830        );
831        let specs = vec![(String::from("svc"), phased)];
832
833        let selector_only = vec![FixedColumn::<Block128> {
834            col_idx: 12,
835            shape: FixedShape::Periodic {
836                period: 1,
837                values: vec![Block128::ONE],
838            },
839        }];
840
841        assert!(validate_fixed_selectors(&specs, &selector_only).is_err());
842
843        let mut pinned = selector_only;
844        pinned.push(FixedColumn {
845            col_idx: 60,
846            shape: FixedShape::Periodic {
847                period: 2,
848                values: vec![Block128::ZERO, Block128::ONE],
849            },
850        });
851
852        validate_fixed_selectors(&specs, &pinned).unwrap();
853
854        let mut substituted = pinned;
855        substituted[1].shape = FixedShape::LastRow;
856
857        assert!(validate_fixed_selectors(&specs, &substituted).is_err());
858    }
859
860    #[test]
861    fn bus_alpha_weights_take_consecutive_powers() {
862        let alpha = Block128::from(0x9E37_79B9u128).to_hardware();
863        let start = Block128::from(0x1234u128).to_hardware();
864
865        let mut expected = start;
866        for w in bus_alpha_weights(start, alpha).take(4) {
867            assert_eq!(w.consistency, expected);
868            expected *= alpha;
869
870            assert_eq!(w.bus_sum, expected);
871            expected *= alpha;
872        }
873    }
874}