1use alloc::collections::BTreeMap;
19use alloc::string::String;
20use alloc::vec::Vec;
21use hekate_core::errors;
22
23pub type ChallengeLabel = &'static [u8];
25
26pub const REQUEST_IDX_LABEL: ChallengeLabel = b"kappa_request_idx";
31
32#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
38pub enum BusKind {
39 #[default]
40 Permutation,
41 Lookup,
42}
43
44#[derive(Clone, Debug, PartialEq, Eq)]
47pub enum Source {
48 Column(usize),
50
51 Columns(Vec<usize>),
54
55 RowIndexLeBytes(usize),
61
62 Const(u128),
65
66 RowIndexByte(usize),
68}
69
70#[derive(Clone, Debug)]
86pub struct PermutationCheckSpec {
87 pub kind: BusKind,
88
89 pub sources: Vec<(Source, ChallengeLabel)>,
92
93 pub selector: Option<usize>,
96
97 pub recv_selector: Option<usize>,
100
101 pub clock_waiver: Option<String>,
104}
105
106impl PermutationCheckSpec {
107 pub fn new(sources: Vec<(Source, ChallengeLabel)>, selector: Option<usize>) -> Self {
108 Self {
109 sources,
110 selector,
111 recv_selector: None,
112 kind: BusKind::Permutation,
113 clock_waiver: None,
114 }
115 }
116
117 pub fn new_lookup(sources: Vec<(Source, ChallengeLabel)>, selector: Option<usize>) -> Self {
121 Self {
122 sources,
123 selector,
124 recv_selector: None,
125 kind: BusKind::Lookup,
126 clock_waiver: None,
127 }
128 }
129
130 pub fn new_paired(
134 sources: Vec<(Source, ChallengeLabel)>,
135 s_send: usize,
136 s_recv: usize,
137 kind: BusKind,
138 ) -> Self {
139 Self {
140 sources,
141 selector: Some(s_send),
142 recv_selector: Some(s_recv),
143 kind,
144 clock_waiver: None,
145 }
146 }
147
148 pub fn with_clock_waiver(mut self, reason: impl Into<String>) -> Self {
152 self.clock_waiver = Some(reason.into());
153 self
154 }
155
156 pub fn num_sources(&self) -> usize {
157 self.sources.len()
158 }
159
160 pub fn has_selector(&self) -> bool {
161 self.selector.is_some()
162 }
163
164 pub fn has_paired(&self) -> bool {
165 self.recv_selector.is_some()
166 }
167
168 pub fn shift_column_indices(&mut self, offset: usize) {
171 for (source, _) in &mut self.sources {
172 match source {
173 Source::Column(idx) => *idx += offset,
174 Source::Columns(indices) => {
175 for idx in indices {
176 *idx += offset;
177 }
178 }
179 _ => {}
180 }
181 }
182
183 if let Some(sel_idx) = &mut self.selector {
184 *sel_idx += offset;
185 }
186
187 if let Some(sel_idx) = &mut self.recv_selector {
188 *sel_idx += offset;
189 }
190 }
191
192 pub fn has_real_clock_source(&self) -> bool {
195 self.sources
196 .iter()
197 .any(|(src, _)| matches!(src, Source::RowIndexLeBytes(_) | Source::RowIndexByte(_)))
198 }
199
200 pub fn has_request_idx_column(&self) -> bool {
201 self.sources
202 .iter()
203 .any(|(src, label)| matches!(src, Source::Column(_)) && *label == REQUEST_IDX_LABEL)
204 }
205
206 pub fn validate_clock_stitching(&self, _bus_id: &str) -> errors::Result<()> {
209 let waiver_status = self.clock_waiver.as_deref().map(WaiverStatus::classify);
210
211 let has_clock_marker = self.has_real_clock_source() || self.has_request_idx_column();
212
213 match (self.kind, has_clock_marker, waiver_status) {
214 (BusKind::Lookup, _, Some(_)) => Err(errors::Error::Protocol {
215 protocol: "logup_bus",
216 message: "lookup bus carries a clock_waiver; waivers only apply \
217 to Permutation kind, drop the .with_clock_waiver(...) call",
218 }),
219 (BusKind::Permutation, true, Some(_)) => Err(errors::Error::Protocol {
220 protocol: "logup_bus",
221 message: "permutation bus carries both a clock source and a \
222 clock_waiver; pick one shape",
223 }),
224 (BusKind::Permutation, false, Some(WaiverStatus::Empty)) => {
225 Err(errors::Error::Protocol {
226 protocol: "logup_bus",
227 message: "permutation bus has an empty clock_waiver; provide a \
228 non-empty reason citing the load-bearing AIR constraint",
229 })
230 }
231 (BusKind::Permutation, false, Some(WaiverStatus::TooShort)) => {
232 Err(errors::Error::Protocol {
233 protocol: "logup_bus",
234 message: "permutation bus has an under-specified clock_waiver; \
235 the reason must be at least 32 chars and cite a file/line \
236 of the load-bearing AIR constraint",
237 })
238 }
239 (BusKind::Permutation, false, Some(WaiverStatus::MissingCitation)) => {
240 Err(errors::Error::Protocol {
241 protocol: "logup_bus",
242 message: "permutation bus clock_waiver lacks a 'see <path>' citation; \
243 waiver text must start with 'see ' followed by the file path \
244 of the load-bearing AIR constraint",
245 })
246 }
247 (BusKind::Permutation, false, None) => Err(errors::Error::Protocol {
248 protocol: "logup_bus",
249 message: "permutation bus lacks per-row clock stitching; add \
250 Source::RowIndexLeBytes, pair both endpoints with a \
251 committed B32 column labelled REQUEST_IDX_LABEL whose \
252 value matches the partner row index, switch to \
253 BusKind::Lookup via new_lookup, or document the \
254 carve-out via .with_clock_waiver(reason)",
255 }),
256 _ => Ok(()),
257 }
258 }
259}
260
261#[derive(Clone, Copy, Debug, PartialEq, Eq)]
262enum WaiverStatus {
263 Empty,
264 TooShort,
265 MissingCitation,
266 Ok,
267}
268
269impl WaiverStatus {
270 const MIN_WAIVER_LEN: usize = 32;
271 const REQUIRED_PREFIX: &'static str = "see ";
272
273 fn classify(s: &str) -> Self {
274 if s.is_empty() {
275 Self::Empty
276 } else if s.len() < Self::MIN_WAIVER_LEN {
277 Self::TooShort
278 } else if !s.starts_with(Self::REQUIRED_PREFIX) {
279 Self::MissingCitation
280 } else {
281 Self::Ok
282 }
283 }
284}
285
286pub fn validate_bus_set<'a, I>(endpoints: I) -> errors::Result<()>
292where
293 I: IntoIterator<Item = (&'a str, &'a PermutationCheckSpec)>,
294{
295 let mut by_bus: BTreeMap<&'a str, Vec<&'a PermutationCheckSpec>> = BTreeMap::new();
296
297 for (bus_id, spec) in endpoints {
298 by_bus.entry(bus_id).or_default().push(spec);
299 }
300
301 for (bus_id, specs) in &by_bus {
302 let any_lookup = specs.iter().any(|s| s.kind == BusKind::Lookup);
303 let any_perm = specs.iter().any(|s| s.kind == BusKind::Permutation);
304
305 if any_lookup && any_perm {
306 return Err(errors::Error::Protocol {
307 protocol: "logup_bus",
308 message: "bus_id has mixed BusKind across endpoints; \
309 all endpoints must agree on Permutation or Lookup",
310 });
311 }
312
313 if any_lookup {
314 continue;
315 }
316
317 if specs.len() < 2 {
318 continue;
319 }
320
321 let any_real_clock = specs.iter().any(|s| s.has_real_clock_source());
322 let all_waivered = specs.iter().all(|s| s.clock_waiver.is_some());
323
324 if !any_real_clock && !all_waivered {
325 let _ = bus_id;
326 return Err(errors::Error::Protocol {
327 protocol: "logup_bus",
328 message: "permutation bus_id has no endpoint owning a real \
329 Source::RowIndexLeBytes/RowIndexByte clock and not all \
330 endpoints declare a clock_waiver; label-only stitching \
331 is forgeable and admits char-2 parity collapse",
332 });
333 }
334 }
335
336 Ok(())
337}
338
339pub fn accumulate_lookup_heights(
344 specs: &[(String, PermutationCheckSpec)],
345 table_rows: u64,
346 heights: &mut BTreeMap<String, u64>,
347) {
348 for (bus_id, spec) in specs {
349 if spec.kind == BusKind::Lookup {
350 let entry = heights.entry(bus_id.clone()).or_insert(0);
351 *entry = (*entry).max(table_rows);
352 }
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 #[test]
361 fn permutation_spec_creation() {
362 let sources = vec![
363 (Source::Column(0), b"kappa_0" as ChallengeLabel),
364 (Source::Column(1), b"kappa_1" as ChallengeLabel),
365 (Source::RowIndexLeBytes(4), b"kappa_clk" as ChallengeLabel),
366 ];
367
368 let spec = PermutationCheckSpec::new(sources, Some(2));
369
370 assert_eq!(spec.num_sources(), 3);
371 assert!(spec.has_selector());
372 assert_eq!(spec.selector, Some(2));
373 }
374
375 #[test]
376 fn source_variants() {
377 let col = Source::Column(5);
378 let cols = Source::Columns(vec![0, 1, 2, 3]);
379 let clock = Source::RowIndexLeBytes(4);
380 let constant = Source::Const(0x01);
381
382 assert_eq!(col, Source::Column(5));
383 assert_eq!(cols, Source::Columns(vec![0, 1, 2, 3]));
384 assert_eq!(clock, Source::RowIndexLeBytes(4));
385 assert_eq!(constant, Source::Const(0x01));
386 }
387
388 #[test]
389 fn new_paired_populates_both_selectors() {
390 let spec = PermutationCheckSpec::new_paired(
391 vec![(Source::Column(0), b"k_a" as ChallengeLabel)],
392 3,
393 5,
394 BusKind::Permutation,
395 );
396
397 assert!(spec.has_selector());
398 assert!(spec.has_paired());
399 assert_eq!(spec.selector, Some(3));
400 assert_eq!(spec.recv_selector, Some(5));
401 assert_eq!(spec.kind, BusKind::Permutation);
402 }
403
404 #[test]
405 fn new_defaults_recv_selector_none() {
406 let spec =
407 PermutationCheckSpec::new(vec![(Source::Column(0), b"k_a" as ChallengeLabel)], Some(1));
408
409 assert!(!spec.has_paired());
410 assert_eq!(spec.recv_selector, None);
411 }
412
413 #[test]
414 fn new_lookup_defaults_recv_selector_none() {
415 let spec = PermutationCheckSpec::new_lookup(
416 vec![(Source::Column(0), b"k_a" as ChallengeLabel)],
417 Some(1),
418 );
419
420 assert!(!spec.has_paired());
421 assert_eq!(spec.recv_selector, None);
422 }
423
424 #[test]
425 fn shift_column_indices_covers_recv_selector() {
426 let mut spec = PermutationCheckSpec::new_paired(
427 vec![
428 (Source::Column(0), b"k_a" as ChallengeLabel),
429 (Source::Columns(vec![1, 2]), b"k_b" as ChallengeLabel),
430 ],
431 3,
432 5,
433 BusKind::Lookup,
434 );
435
436 spec.shift_column_indices(10);
437
438 assert_eq!(spec.selector, Some(13));
439 assert_eq!(spec.recv_selector, Some(15));
440
441 match &spec.sources[0].0 {
442 Source::Column(idx) => assert_eq!(*idx, 10),
443 other => panic!("expected Column, got {other:?}"),
444 }
445
446 match &spec.sources[1].0 {
447 Source::Columns(idxs) => assert_eq!(idxs, &vec![11, 12]),
448 other => panic!("expected Columns, got {other:?}"),
449 }
450 }
451}