oxideav_opus/celt_pulse_cache.rs
1//! CELT §4.3.4.1 *Bits to Pulses* pulse-cost cache
2//! (RFC 6716 §4.3.4 / §4.3.4.1).
3//!
4//! The §4.3.4 *Shape* decode codes each band's unit-norm spectral
5//! shape with Pulse Vector Quantisation (PVQ): a band of `N` MDCT
6//! coefficients is represented by `K` signed integer pulse positions
7//! whose absolute values sum to `K`. Before the shape can be decoded
8//! the allocator must answer the §4.3.4.1 *Bits to Pulses* question:
9//! given a per-band bit budget `B`, what is the largest pulse count
10//! `K` whose PVQ codeword cost does not exceed `B`?
11//!
12//! The exact cost is `log2(V(N, K))` plus a small framing overhead,
13//! where `V(N, K)` is the number of PVQ codepoints with `K` pulses in
14//! `N` slots (the round-32 [`crate::celt_pvq_v`] recurrence). Computing
15//! that at decode time for every candidate `K` is expensive, so the
16//! §4.3.4.1 procedure precomputes the cost curve for the `(band, LM)`
17//! combinations that occur at the codec's normal operating bitrates and
18//! stores them as two flat tables:
19//!
20//! * [`CACHE_INDEX50`] — a 105-entry `i16` table mapping each
21//! `(band, LM)` tuple to a byte offset into [`CACHE_BITS50`], or the
22//! sentinel `-1` for tuples the allocator handles with a closed-form
23//! path.
24//! * [`CACHE_BITS50`] — a 392-byte run-packed table; each run holds the
25//! monotone cost curve `qbits[1..=maxK]` for one `(N, max-pulses)`
26//! profile, in the codec's `BITRES = 3` units (1/8 bits / Q3).
27//!
28//! This module owns only the §4.3.4.1 *cost-cache lookup surface*: the
29//! two tables, the `(band, LM)` → offset indexing rule, the run reader,
30//! and the bits-to-pulses inversion scan. The §4.3.4 PVQ shape decode
31//! that consumes the returned `K` ([`crate::celt_pvq_decode`]) and the
32//! §4.3.3 allocator that computes the per-band budget `B` run at their
33//! own call sites.
34//!
35//! ## Indexing
36//!
37//! The 21 CELT nominal bands are indexed `band ∈ 0..=20`
38//! ([`crate::celt_band_layout::CELT_NUM_BANDS`]). Each band's actual
39//! coefficient count `N` depends on the frame size through
40//! `LM = log2(frame_size / 120)`, with `LM ∈ 0..=4` covering the
41//! 2.5 / 5 / 10 / 20 ms frames plus the short-block transient variant.
42//! There are `21 × 5 = 105` distinct `(band, LM)` tuples, which is
43//! exactly the length of [`CACHE_INDEX50`]. The tuple maps to the flat
44//! index in **band-major** order:
45//!
46//! ```text
47//! i = band * CACHE_LM_COUNT + LM
48//! offset = CACHE_INDEX50[i]
49//! ```
50//!
51//! A `-1` offset is a sentinel ([`CACHE_INDEX_SENTINEL`]) meaning the
52//! `(band, LM)` has no cached cost curve — the band is a single
53//! coefficient (no pulse packing) or small enough that the allocator
54//! uses a direct formula. The eight sentinels are band 0 at all five
55//! LM values plus band 1 at `LM ∈ {0, 1, 2}`.
56//!
57//! ## Run format
58//!
59//! A run at byte `off` in [`CACHE_BITS50`] is:
60//!
61//! ```text
62//! CACHE_BITS50[off] = maxK (1 byte: max K this run supports)
63//! CACHE_BITS50[off + 1] = qbits[1] (cost for K = 1, in 1/8 bits)
64//! CACHE_BITS50[off + 2] = qbits[2]
65//! ...
66//! CACHE_BITS50[off + maxK] = qbits[maxK]
67//! ```
68//!
69//! so the run occupies `1 + maxK` bytes. `K = 0` is implicit (cost 0)
70//! and never stored. `qbits[1..=maxK]` is monotone non-decreasing.
71//! Several `(band, LM)` tuples that share the same `(N, max-pulses)`
72//! profile point at the same run, so the 105-entry index resolves to
73//! only 23 distinct runs.
74//!
75//! ## Bits to pulses
76//!
77//! Given a per-band budget `b_target` in 1/8 bits,
78//! [`bits_to_pulses`] performs the §4.3.4.1 inversion: scan the run's
79//! `qbits[1..=maxK]` and return the largest `K` whose cost fits the
80//! budget. The scan is a linear walk over a constant-bounded run
81//! (`maxK ≤ 40`); the monotone property would also admit a binary
82//! search. A sentinel `(band, LM)` returns
83//! [`PulseCacheError::SentinelTuple`] so the caller can route to its
84//! closed-form path rather than guessing.
85//!
86//! ## Units
87//!
88//! All cost values are in 1/8 bits (Q3, the `BITRES = 3` convention
89//! shared with [`crate::celt_alloc_search`]). `bits_to_pulses` takes
90//! the budget `b_target` in the same units and returns a pure pulse
91//! count.
92//!
93//! ## Provenance
94//!
95//! Narrative: RFC 6716 §4.3.4 / §4.3.4.1 (*Bits to Pulses*) in
96//! `docs/audio/opus/rfc6716-opus.txt`, plus the run-format trace
97//! `docs/audio/opus/pulse-cache-format-trace.md` (the band-major
98//! indexing rule, run packing, sentinel pattern, and qbits → bits
99//! conversion). Numeric tables: the 105-entry `cache_index50` and
100//! 392-byte `cache_bits50` sequences from
101//! `docs/audio/opus/tables/cache-index50.csv` and
102//! `docs/audio/opus/tables/cache-bits50.csv` (see the `.meta`
103//! sidecars for the canonical layout). The values are reproduced
104//! inline so the cache is available without filesystem I/O at runtime.
105
106use crate::celt_band_layout::CELT_NUM_BANDS;
107
108/// Number of frame-size (`LM`) columns indexing [`CACHE_INDEX50`].
109///
110/// `LM ∈ 0..=4` covers the 2.5 / 5 / 10 / 20 ms frames plus the
111/// short-block transient variant (RFC 6716 §4.3.4; the run-format
112/// trace §2). The cache is keyed on five LM values, unlike the
113/// four-value `LM ∈ 0..=3` axis of [`crate::celt_cache_caps50`].
114pub const CACHE_LM_COUNT: usize = 5;
115
116/// Total entries in [`CACHE_INDEX50`]: `21 × 5 = 105`.
117pub const CACHE_INDEX_LEN: usize = CELT_NUM_BANDS * CACHE_LM_COUNT;
118
119/// Total bytes in [`CACHE_BITS50`].
120pub const CACHE_BITS_LEN: usize = 392;
121
122/// Sentinel value in [`CACHE_INDEX50`]: the `(band, LM)` tuple has no
123/// cached cost curve and the allocator must use its closed-form path
124/// (run-format trace §2 / §5).
125pub const CACHE_INDEX_SENTINEL: i16 = -1;
126
127/// Upper bound on a single run's `maxK` (run-format trace §4: the four
128/// largest runs cap at 40).
129pub const CACHE_MAX_PULSES: u8 = 40;
130
131/// §4.3.4.1 `cache_index50`: maps the band-major `(band, LM)` flat
132/// index to a byte offset into [`CACHE_BITS50`], or
133/// [`CACHE_INDEX_SENTINEL`].
134///
135/// Layout: `i = band * CACHE_LM_COUNT + LM`, band-major, so the first
136/// five entries are band 0 at `LM = 0..=4`. The eight `-1` entries are
137/// band 0 (all LM) plus band 1 (`LM ∈ {0, 1, 2}`).
138///
139/// Numeric facts from `docs/audio/opus/tables/cache-index50.csv`.
140pub static CACHE_INDEX50: [i16; CACHE_INDEX_LEN] = [
141 -1, -1, -1, -1, -1, -1, -1, -1, 0, 0, 0, 0, 41, 41, 41, 82, 82, 123, 164, 200, 222, 0, 0, 0, 0,
142 0, 0, 0, 0, 41, 41, 41, 41, 123, 123, 123, 164, 164, 240, 266, 283, 295, 41, 41, 41, 41, 41,
143 41, 41, 41, 123, 123, 123, 123, 240, 240, 240, 266, 266, 305, 318, 328, 336, 123, 123, 123,
144 123, 123, 123, 123, 123, 240, 240, 240, 240, 305, 305, 305, 318, 318, 343, 351, 358, 364, 240,
145 240, 240, 240, 240, 240, 240, 240, 305, 305, 305, 305, 343, 343, 343, 351, 351, 370, 376, 382,
146 387,
147];
148
149/// §4.3.4.1 `cache_bits50`: run-packed PVQ cost curves.
150///
151/// Each run is `[maxK, qbits[1], …, qbits[maxK]]` in 1/8 bits (Q3).
152/// Walk a run via the offset stored in [`CACHE_INDEX50`]; see the
153/// module docs for the run format. The 23 distinct runs pack into
154/// exactly [`CACHE_BITS_LEN`] bytes.
155///
156/// Numeric facts from `docs/audio/opus/tables/cache-bits50.csv`.
157pub static CACHE_BITS50: [u8; CACHE_BITS_LEN] = [
158 40, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
159 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 40, 15, 23, 28, 31, 34, 36, 38, 39, 41, 42, 43, 44, 45, 46, 47,
160 47, 49, 50, 51, 52, 53, 54, 55, 55, 57, 58, 59, 60, 61, 62, 63, 63, 65, 66, 67, 68, 69, 70, 71,
161 71, 40, 20, 33, 41, 48, 53, 57, 61, 64, 66, 69, 71, 73, 75, 76, 78, 80, 82, 85, 87, 89, 91, 92,
162 94, 96, 98, 101, 103, 105, 107, 108, 110, 112, 114, 117, 119, 121, 123, 124, 126, 128, 40, 23,
163 39, 51, 60, 67, 73, 79, 83, 87, 91, 94, 97, 100, 102, 105, 107, 111, 115, 118, 121, 124, 126,
164 129, 131, 135, 139, 142, 145, 148, 150, 153, 155, 159, 163, 166, 169, 172, 174, 177, 179, 35,
165 28, 49, 65, 78, 89, 99, 107, 114, 120, 126, 132, 136, 141, 145, 149, 153, 159, 165, 171, 176,
166 180, 185, 189, 192, 199, 205, 211, 216, 220, 225, 229, 232, 239, 245, 251, 21, 33, 58, 79, 97,
167 112, 125, 137, 148, 157, 166, 174, 182, 189, 195, 201, 207, 217, 227, 235, 243, 251, 17, 35,
168 63, 86, 106, 123, 139, 152, 165, 177, 187, 197, 206, 214, 222, 230, 237, 250, 25, 31, 55, 75,
169 91, 105, 117, 128, 138, 146, 154, 161, 168, 174, 180, 185, 190, 200, 208, 215, 222, 229, 235,
170 240, 245, 255, 16, 36, 65, 89, 110, 128, 144, 159, 173, 185, 196, 207, 217, 226, 234, 242, 250,
171 11, 41, 74, 103, 128, 151, 172, 191, 209, 225, 241, 255, 9, 43, 79, 110, 138, 163, 186, 207,
172 227, 246, 12, 39, 71, 99, 123, 144, 164, 182, 198, 214, 228, 241, 253, 9, 44, 81, 113, 142,
173 168, 192, 214, 235, 255, 7, 49, 90, 127, 160, 191, 220, 247, 6, 51, 95, 134, 170, 203, 234, 7,
174 47, 87, 123, 155, 184, 212, 237, 6, 52, 97, 137, 174, 208, 240, 5, 57, 106, 151, 192, 231, 5,
175 59, 111, 158, 202, 243, 5, 55, 103, 147, 187, 224, 5, 60, 113, 161, 206, 248, 4, 65, 122, 175,
176 224, 4, 67, 127, 182, 234,
177];
178
179/// Errors returned by the §4.3.4.1 cache lookup.
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub enum PulseCacheError {
182 /// `band` is outside `0..21` (RFC 6716 §4.3 Table 55 band count).
183 BandOutOfRange { band: usize },
184 /// `lm` is outside `0..5` (the cache's five-column LM axis).
185 LmOutOfRange { lm: usize },
186 /// The `(band, LM)` tuple maps to [`CACHE_INDEX_SENTINEL`]: it has
187 /// no cached cost curve and the caller must use the §4.3.4.1
188 /// closed-form path.
189 SentinelTuple { band: usize, lm: usize },
190 /// `k` is outside the run's stored `1..=maxK` cost curve (`k = 0`
191 /// has implicit cost 0 and is never stored; `k > maxK` exceeds the
192 /// run's supported pulse count).
193 PulseCountOutOfRange { k: u8, max_k: u8 },
194}
195
196/// Resolve the band-major `(band, LM)` flat index into [`CACHE_INDEX50`]
197/// (RFC 6716 §4.3.4.1; run-format trace §2).
198///
199/// Returns [`PulseCacheError::BandOutOfRange`] / `LmOutOfRange` for
200/// out-of-range inputs.
201pub const fn cache_flat_index(band: usize, lm: usize) -> Result<usize, PulseCacheError> {
202 if band >= CELT_NUM_BANDS {
203 return Err(PulseCacheError::BandOutOfRange { band });
204 }
205 if lm >= CACHE_LM_COUNT {
206 return Err(PulseCacheError::LmOutOfRange { lm });
207 }
208 Ok(band * CACHE_LM_COUNT + lm)
209}
210
211/// Return the [`CACHE_BITS50`] byte offset for a `(band, LM)` tuple, or
212/// the sentinel error.
213///
214/// Returns [`PulseCacheError::SentinelTuple`] when the index entry is
215/// [`CACHE_INDEX_SENTINEL`] (the caller must route to its closed-form
216/// path), or the range errors from [`cache_flat_index`].
217pub const fn cache_run_offset(band: usize, lm: usize) -> Result<usize, PulseCacheError> {
218 let i = match cache_flat_index(band, lm) {
219 Ok(i) => i,
220 Err(e) => return Err(e),
221 };
222 let off = CACHE_INDEX50[i];
223 if off == CACHE_INDEX_SENTINEL {
224 return Err(PulseCacheError::SentinelTuple { band, lm });
225 }
226 Ok(off as usize)
227}
228
229/// Return the maximum pulse count `maxK` the run for `(band, LM)`
230/// supports (the run's leading byte).
231///
232/// Returns the same errors as [`cache_run_offset`].
233pub const fn cache_max_pulses(band: usize, lm: usize) -> Result<u8, PulseCacheError> {
234 let off = match cache_run_offset(band, lm) {
235 Ok(off) => off,
236 Err(e) => return Err(e),
237 };
238 Ok(CACHE_BITS50[off])
239}
240
241/// Return the §4.3.4.1 cost `qbits[k]` (1/8 bits) of coding exactly `k`
242/// pulses for `(band, LM)`.
243///
244/// `k` must be in `1..=maxK`. `k = 0` has implicit cost 0 (no codeword)
245/// and is rejected. Returns [`PulseCacheError::SentinelTuple`] for a
246/// sentinel tuple, or [`PulseCacheError::PulseCountOutOfRange`] when
247/// `k` is 0 or exceeds the run's `maxK`.
248pub const fn cache_pulse_cost(band: usize, lm: usize, k: u8) -> Result<u8, PulseCacheError> {
249 let off = match cache_run_offset(band, lm) {
250 Ok(off) => off,
251 Err(e) => return Err(e),
252 };
253 let max_k = CACHE_BITS50[off];
254 if k == 0 || k > max_k {
255 return Err(PulseCacheError::PulseCountOutOfRange { k, max_k });
256 }
257 Ok(CACHE_BITS50[off + k as usize])
258}
259
260/// §4.3.4.1 *Bits to Pulses* inversion: the largest pulse count `K`
261/// whose cost fits the per-band budget `b_target` (1/8 bits).
262///
263/// Scans the run's monotone cost curve `qbits[1..=maxK]` and returns
264/// the largest `K` with `qbits[K] <= b_target`, or `0` when not even a
265/// single pulse fits. `K = maxK` is allowed (the whole run fits).
266///
267/// Returns [`PulseCacheError::SentinelTuple`] for a sentinel tuple
268/// (the caller uses its closed-form path), or the range errors from
269/// [`cache_flat_index`].
270pub const fn bits_to_pulses(band: usize, lm: usize, b_target: u8) -> Result<u8, PulseCacheError> {
271 let off = match cache_run_offset(band, lm) {
272 Ok(off) => off,
273 Err(e) => return Err(e),
274 };
275 let max_k = CACHE_BITS50[off];
276 let mut k: u8 = 0;
277 let mut probe: u8 = 1;
278 while probe <= max_k {
279 if CACHE_BITS50[off + probe as usize] > b_target {
280 break;
281 }
282 k = probe;
283 probe += 1;
284 }
285 Ok(k)
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn index_table_length_matches_band_lm_grid() {
294 assert_eq!(CACHE_INDEX50.len(), CACHE_INDEX_LEN);
295 assert_eq!(CACHE_INDEX_LEN, CELT_NUM_BANDS * CACHE_LM_COUNT);
296 assert_eq!(CACHE_INDEX_LEN, 105);
297 }
298
299 #[test]
300 fn bits_table_length_is_392() {
301 assert_eq!(CACHE_BITS50.len(), CACHE_BITS_LEN);
302 assert_eq!(CACHE_BITS_LEN, 392);
303 }
304
305 #[test]
306 fn exactly_eight_sentinels_in_bands_zero_and_one() {
307 let sentinels: Vec<usize> = (0..CACHE_INDEX_LEN)
308 .filter(|&i| CACHE_INDEX50[i] == CACHE_INDEX_SENTINEL)
309 .collect();
310 assert_eq!(sentinels.len(), 8);
311 // Band 0 at all five LM = flat indices 0..=4; band 1 at
312 // LM 0,1,2 = flat indices 5,6,7.
313 assert_eq!(sentinels, vec![0, 1, 2, 3, 4, 5, 6, 7]);
314 }
315
316 #[test]
317 fn sentinel_lookup_returns_sentinel_error() {
318 // Band 0, LM 0 is a sentinel.
319 assert_eq!(
320 cache_run_offset(0, 0),
321 Err(PulseCacheError::SentinelTuple { band: 0, lm: 0 })
322 );
323 // Band 1, LM 2 is a sentinel; LM 3 is the first non-sentinel.
324 assert_eq!(
325 cache_run_offset(1, 2),
326 Err(PulseCacheError::SentinelTuple { band: 1, lm: 2 })
327 );
328 assert_eq!(cache_run_offset(1, 3), Ok(0));
329 }
330
331 #[test]
332 fn twenty_three_distinct_run_offsets() {
333 let mut offsets: Vec<i16> = CACHE_INDEX50
334 .iter()
335 .copied()
336 .filter(|&o| o != CACHE_INDEX_SENTINEL)
337 .collect();
338 offsets.sort_unstable();
339 offsets.dedup();
340 assert_eq!(offsets.len(), 23);
341 assert_eq!(
342 offsets,
343 vec![
344 0, 41, 82, 123, 164, 200, 222, 240, 266, 283, 295, 305, 318, 328, 336, 343, 351,
345 358, 364, 370, 376, 382, 387,
346 ]
347 );
348 }
349
350 #[test]
351 fn runs_pack_every_byte_exactly() {
352 // Walking each distinct run by its leading maxK byte must cover
353 // all 392 bytes with no gaps or overlaps.
354 let mut offsets: Vec<usize> = CACHE_INDEX50
355 .iter()
356 .copied()
357 .filter(|&o| o != CACHE_INDEX_SENTINEL)
358 .map(|o| o as usize)
359 .collect();
360 offsets.sort_unstable();
361 offsets.dedup();
362 let mut total = 0usize;
363 for &off in &offsets {
364 let max_k = CACHE_BITS50[off] as usize;
365 total += 1 + max_k;
366 }
367 assert_eq!(total, CACHE_BITS_LEN);
368 }
369
370 #[test]
371 fn each_run_cost_curve_is_monotone_nondecreasing() {
372 let mut offsets: Vec<usize> = CACHE_INDEX50
373 .iter()
374 .copied()
375 .filter(|&o| o != CACHE_INDEX_SENTINEL)
376 .map(|o| o as usize)
377 .collect();
378 offsets.sort_unstable();
379 offsets.dedup();
380 for &off in &offsets {
381 let max_k = CACHE_BITS50[off] as usize;
382 for k in 2..=max_k {
383 assert!(
384 CACHE_BITS50[off + k] >= CACHE_BITS50[off + k - 1],
385 "run at {off} not monotone at k={k}"
386 );
387 }
388 }
389 }
390
391 #[test]
392 fn max_pulses_caps_at_forty() {
393 let mut offsets: Vec<usize> = CACHE_INDEX50
394 .iter()
395 .copied()
396 .filter(|&o| o != CACHE_INDEX_SENTINEL)
397 .map(|o| o as usize)
398 .collect();
399 offsets.sort_unstable();
400 offsets.dedup();
401 for &off in &offsets {
402 assert!(CACHE_BITS50[off] <= CACHE_MAX_PULSES);
403 }
404 }
405
406 #[test]
407 fn first_run_is_flat_seven() {
408 // Run at offset 0 (band 1 / LM 3): maxK = 40, qbits[1..=40] = 7.
409 assert_eq!(cache_max_pulses(1, 3), Ok(40));
410 for k in 1..=40u8 {
411 assert_eq!(cache_pulse_cost(1, 3, k), Ok(7));
412 }
413 }
414
415 #[test]
416 fn pulse_cost_rejects_zero_and_overflow_k() {
417 // Run at offset 0 has maxK = 40.
418 assert_eq!(
419 cache_pulse_cost(1, 3, 0),
420 Err(PulseCacheError::PulseCountOutOfRange { k: 0, max_k: 40 })
421 );
422 assert_eq!(
423 cache_pulse_cost(1, 3, 41),
424 Err(PulseCacheError::PulseCountOutOfRange { k: 41, max_k: 40 })
425 );
426 }
427
428 #[test]
429 fn bits_to_pulses_flat_run_fits_all_at_budget_seven() {
430 // Flat run (qbits all 7): a budget of 7 fits all 40 pulses.
431 assert_eq!(bits_to_pulses(1, 3, 7), Ok(40));
432 // A budget of 6 fits none (every cost is 7 > 6).
433 assert_eq!(bits_to_pulses(1, 3, 6), Ok(0));
434 }
435
436 #[test]
437 fn bits_to_pulses_picks_exact_threshold() {
438 // Run at offset 41 (band 2 / LM 2): qbits[1]=15, qbits[2]=23,
439 // qbits[3]=28, ... A budget of 23 should fit exactly K=2.
440 assert_eq!(cache_pulse_cost(2, 2, 1), Ok(15));
441 assert_eq!(cache_pulse_cost(2, 2, 2), Ok(23));
442 assert_eq!(cache_pulse_cost(2, 2, 3), Ok(28));
443 assert_eq!(bits_to_pulses(2, 2, 23), Ok(2));
444 // One below the K=2 cost fits only K=1.
445 assert_eq!(bits_to_pulses(2, 2, 22), Ok(1));
446 // One below the K=1 cost fits nothing.
447 assert_eq!(bits_to_pulses(2, 2, 14), Ok(0));
448 }
449
450 #[test]
451 fn bits_to_pulses_saturating_budget_returns_max_k() {
452 // A budget of 255 (the max byte) fits the entire run.
453 let max_k = cache_max_pulses(2, 2).unwrap();
454 assert_eq!(bits_to_pulses(2, 2, 255), Ok(max_k));
455 }
456
457 #[test]
458 fn bits_to_pulses_on_sentinel_signals_closed_form() {
459 assert_eq!(
460 bits_to_pulses(0, 0, 100),
461 Err(PulseCacheError::SentinelTuple { band: 0, lm: 0 })
462 );
463 }
464
465 #[test]
466 fn lookup_rejects_out_of_range_band_and_lm() {
467 assert_eq!(
468 cache_flat_index(CELT_NUM_BANDS, 0),
469 Err(PulseCacheError::BandOutOfRange {
470 band: CELT_NUM_BANDS
471 })
472 );
473 assert_eq!(
474 cache_flat_index(0, CACHE_LM_COUNT),
475 Err(PulseCacheError::LmOutOfRange { lm: CACHE_LM_COUNT })
476 );
477 }
478
479 #[test]
480 fn bits_to_pulses_monotone_in_budget() {
481 // For a fixed non-sentinel tuple, K is non-decreasing in budget.
482 let mut prev = 0u8;
483 for b in 0..=255u8 {
484 let k = bits_to_pulses(5, 4, b).unwrap();
485 assert!(k >= prev, "K decreased at budget {b}");
486 prev = k;
487 }
488 // At saturation it reaches the run's maxK.
489 assert_eq!(prev, cache_max_pulses(5, 4).unwrap());
490 }
491
492 #[test]
493 fn last_run_at_offset_387_has_max_k_four() {
494 // Run at offset 387 (band 20 / LM 4): maxK = 4, qbits =
495 // [67, 127, 182, 234].
496 assert_eq!(cache_run_offset(20, 4), Ok(387));
497 assert_eq!(cache_max_pulses(20, 4), Ok(4));
498 assert_eq!(cache_pulse_cost(20, 4, 1), Ok(67));
499 assert_eq!(cache_pulse_cost(20, 4, 4), Ok(234));
500 }
501}