Skip to main content

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