bloq_graph/height.rs
1//! Cube height: a symbolic linear function of the code distance.
2//!
3//! Writing a cube's height as `k*d + n` splits two things a plain integer
4//! multiplier conflated — **cells**, the lattice cells it occupies along the
5//! time axis, and **rounds**, the syndrome rounds it compiles to — so a cube can
6//! ask for fewer rounds than its footprint suggests. `height=2d/3` occupies one cell
7//! but compiles to `ceil(2d/3)` rounds, which is what makes TELS expressible: a
8//! CCZ factory's Z-readout layers carry a second distance-2 discard redundancy
9//! and so do not need the full `d` rounds a bare merge does.
10
11use std::fmt;
12use std::str::FromStr;
13
14use crate::BlockError;
15
16/// Largest supported cube footprint along the time axis, in cells.
17///
18/// A cube eagerly materializes one reserved offset per occupied cell, so this
19/// limit bounds allocations from both parsed and programmatic graph input. It
20/// also keeps [`CubeHeight::rounds`] inside `i64`: rounds are at most
21/// `cells * distance`, and `65535 * u32::MAX` is comfortably representable.
22pub const MAX_CUBE_HEIGHT_CELLS: u32 = 65_535;
23
24/// A cube's height, written `k*d + n` over the code distance `d`.
25///
26/// `k = numerator / denominator` is a positive rational kept in lowest terms so
27/// that `2d/4 == d/2`; `n` is a signed integer offset. Blog syntax is
28/// `height=<expr>` — `d`, `2d`, `d/2`, `3d/2`, `3d+2`, `d/2 - 1` — defaulting to
29/// `height=d`. [`Ord`] is structural, present only to give [`Block`](crate::Block) a
30/// total order; it does not compare realized heights, which depend on `d`.
31///
32/// ```
33/// use bloq_graph::CubeHeight;
34///
35/// let half: CubeHeight = "d/2".parse()?;
36/// assert_eq!((half.cells(), half.rounds(7)), (1, 4)); // one cell, ceil(7/2) rounds
37/// # Ok::<(), bloq_graph::BlockError>(())
38/// ```
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
40pub struct CubeHeight {
41 numerator: u32,
42 denominator: u32,
43 offset: i32,
44}
45
46impl CubeHeight {
47 /// `height=d`: one code distance of rounds, one cell.
48 pub const DEFAULT: Self = Self {
49 numerator: 1,
50 denominator: 1,
51 offset: 0,
52 };
53
54 /// Constructs `numerator/denominator * d + offset`, reduced to lowest terms.
55 ///
56 /// # Errors
57 ///
58 /// Returns [`BlockError::NonPositiveCubeHeight`] if either `numerator` or
59 /// `denominator` is zero (`k <= 0` is meaningless), or
60 /// [`BlockError::CubeHeightExceedsRange`] if the footprint would exceed
61 /// [`MAX_CUBE_HEIGHT_CELLS`].
62 pub fn new(numerator: u32, denominator: u32, offset: i32) -> Result<Self, BlockError> {
63 if numerator == 0 || denominator == 0 {
64 return Err(BlockError::NonPositiveCubeHeight {
65 numerator,
66 denominator,
67 });
68 }
69 let divisor = gcd(numerator, denominator);
70 let height = Self {
71 numerator: numerator / divisor,
72 denominator: denominator / divisor,
73 offset,
74 };
75 if height.cells() > MAX_CUBE_HEIGHT_CELLS {
76 return Err(BlockError::CubeHeightExceedsRange {
77 cells: height.cells(),
78 });
79 }
80 Ok(height)
81 }
82
83 /// The reduced numerator of `k`.
84 pub const fn numerator(self) -> u32 {
85 self.numerator
86 }
87
88 /// The reduced denominator of `k`.
89 pub const fn denominator(self) -> u32 {
90 self.denominator
91 }
92
93 /// The integer offset `n`.
94 pub const fn offset(self) -> i32 {
95 self.offset
96 }
97
98 /// Whether this is `height=d`, the height a cube has when nothing is written.
99 pub const fn is_default(self) -> bool {
100 self.numerator == 1 && self.denominator == 1 && self.offset == 0
101 }
102
103 /// Cells occupied along the time axis: `ceil(k)`, always at least 1.
104 ///
105 /// The offset `n` deliberately does not participate. `n` buys or refunds
106 /// individual syndrome rounds inside the cube's own worldline; it does not
107 /// change which lattice cells the cube claims, so `height=d-1` and `height=d+1` are
108 /// both single-cell cubes.
109 pub const fn cells(self) -> u32 {
110 self.numerator.div_ceil(self.denominator)
111 }
112
113 /// Compiled syndrome rounds at `distance`: `ceil(k*d) + n`.
114 ///
115 /// Since `n` is an integer this equals `ceil(k*d + n)` exactly, and it is
116 /// computed that way — integer `div_ceil` on the numerator, then the offset
117 /// — rather than through floating point.
118 ///
119 /// The result is signed because a large negative `n` can drive it below
120 /// zero; callers that need a usable round count must check it (a cube needs
121 /// at least 2 rounds, one for its initialization stage and one for its
122 /// measurement stage).
123 pub const fn rounds(self, distance: u32) -> i64 {
124 let scaled = (self.numerator as u64 * distance as u64).div_ceil(self.denominator as u64);
125 // `scaled <= cells() * distance <= 65535 * u32::MAX`, so the cast is
126 // lossless and the sum cannot overflow `i64`.
127 scaled as i64 + self.offset as i64
128 }
129}
130
131/// Greatest common divisor, for reducing `k` at construction.
132const fn gcd(mut a: u32, mut b: u32) -> u32 {
133 while b != 0 {
134 let remainder = a % b;
135 a = b;
136 b = remainder;
137 }
138 a
139}
140
141impl Default for CubeHeight {
142 fn default() -> Self {
143 Self::DEFAULT
144 }
145}
146
147impl fmt::Display for CubeHeight {
148 /// Emits the canonical form: the fraction reduced, a `/1` denominator and a
149 /// `+0` offset omitted. This is exactly what the blog writer puts after
150 /// `height=`, so it must re-parse to an equal value.
151 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152 if self.numerator != 1 {
153 write!(f, "{}", self.numerator)?;
154 }
155 f.write_str("d")?;
156 if self.denominator != 1 {
157 write!(f, "/{}", self.denominator)?;
158 }
159 match self.offset {
160 0 => Ok(()),
161 n if n > 0 => write!(f, "+{n}"),
162 n => write!(f, "-{}", n.unsigned_abs()),
163 }
164 }
165}
166
167impl FromStr for CubeHeight {
168 type Err = BlockError;
169
170 /// Parses `k*d + n`, sharing the blog grammar so the two cannot drift.
171 fn from_str(s: &str) -> Result<Self, Self::Err> {
172 crate::parser::cube_height_from_str(s)
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 #[test]
181 fn accepted_spellings_parse_to_the_documented_value() {
182 for (text, (numerator, denominator, offset)) in [
183 ("d", (1, 1, 0)),
184 ("2d", (2, 1, 0)),
185 ("3d", (3, 1, 0)),
186 ("d/2", (1, 2, 0)),
187 ("3d/2", (3, 2, 0)),
188 ("2d/3", (2, 3, 0)),
189 ("3d+2", (3, 1, 2)),
190 ("d/2 + 1", (1, 2, 1)),
191 ("d - 1", (1, 1, -1)),
192 (" 2 d / 4 ", (1, 2, 0)),
193 ("D/2", (1, 2, 0)),
194 ] {
195 let height: CubeHeight = text.parse().unwrap_or_else(|e| panic!("{text}: {e}"));
196 assert_eq!(
197 (height.numerator(), height.denominator(), height.offset()),
198 (numerator, denominator, offset),
199 "parsing {text}"
200 );
201 }
202 }
203
204 #[test]
205 fn non_positive_and_malformed_expressions_are_rejected() {
206 for text in [
207 "0d",
208 "d/0",
209 "0d/0",
210 "2",
211 "",
212 "dd",
213 "d/",
214 "d+",
215 "d/2/3",
216 "d*2",
217 "d+2147483648",
218 "d-2147483649",
219 ] {
220 assert!(
221 text.parse::<CubeHeight>().is_err(),
222 "{text} should be rejected"
223 );
224 }
225 }
226
227 #[test]
228 fn display_is_canonical_and_round_trips() {
229 for (input, canonical) in [
230 ("d", "d"),
231 ("1d/1", "d"),
232 ("2d/4", "d/2"),
233 ("2d", "2d"),
234 ("3d/2", "3d/2"),
235 ("3d + 2", "3d+2"),
236 ("d/2 - 1", "d/2-1"),
237 ("d+0", "d"),
238 ("d+2147483647", "d+2147483647"),
239 ("d-2147483648", "d-2147483648"),
240 ] {
241 let height: CubeHeight = input.parse().expect("valid height");
242 assert_eq!(height.to_string(), canonical);
243 assert_eq!(
244 canonical
245 .parse::<CubeHeight>()
246 .expect("canonical re-parses"),
247 height
248 );
249 }
250 }
251
252 #[test]
253 fn cells_is_ceil_k_and_ignores_the_offset() {
254 for (text, cells) in [
255 ("d", 1),
256 ("d/2", 1),
257 ("2d/3", 1),
258 ("2d", 2),
259 ("3d/2", 2),
260 ("3d", 3),
261 ("d-1", 1),
262 ("d+5", 1),
263 ] {
264 assert_eq!(
265 text.parse::<CubeHeight>().expect("valid").cells(),
266 cells,
267 "cells of {text}"
268 );
269 }
270 }
271
272 #[test]
273 fn rounds_is_ceil_kd_plus_the_offset() {
274 for (text, distance, rounds) in [
275 ("d", 7, 7),
276 ("2d", 7, 14),
277 ("d/2", 7, 4),
278 ("d/2", 8, 4),
279 ("3d+2", 7, 23),
280 ("d/2 + 1", 7, 5),
281 ("2d/3", 7, 5),
282 // A negative offset can drive the count below the two-round floor,
283 // and even below zero; the compiler is what rejects that.
284 ("d-1", 3, 2),
285 ("d/2-3", 3, -1),
286 ] {
287 assert_eq!(
288 text.parse::<CubeHeight>().expect("valid").rounds(distance),
289 rounds,
290 "rounds of {text} at d={distance}"
291 );
292 }
293 }
294
295 #[test]
296 fn footprint_is_bounded_before_reserved_offset_allocation() {
297 CubeHeight::new(MAX_CUBE_HEIGHT_CELLS, 1, 0).unwrap();
298 assert!(matches!(
299 CubeHeight::new(MAX_CUBE_HEIGHT_CELLS + 1, 1, 0),
300 Err(BlockError::CubeHeightExceedsRange { cells }) if cells == MAX_CUBE_HEIGHT_CELLS + 1
301 ));
302 // A huge numerator is fine as long as the reduced ceiling is small.
303 CubeHeight::new(u32::MAX, u32::MAX, 0).unwrap();
304 }
305}