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