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