Skip to main content

fib_quant/kv/
codec.rs

1use serde::{Deserialize, Serialize};
2
3use crate::{FibQuantError, FibQuantizer, Result};
4
5use super::{
6    block::{KvBlockEncodingV1, KvEncodedBlockV1},
7    layout::KvCacheLayoutV1,
8    page::KvEncodedPageV1,
9    profile::{KvAxisPolicyV1, KvCompressionProfileV1, KvFallbackModeV1},
10    receipt::{
11        kv_tensor_digest, now_unix_seconds, KvCompressionReceiptV1, KvDecodeReceiptV1,
12        KvOperationKindV1, KV_RECEIPT_SCHEMA,
13    },
14    shape::KvTensorShapeV1,
15};
16
17/// Encoded tensor artifact with pages and compression receipt.
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub struct KvEncodedTensorV1 {
20    /// Logical shape.
21    pub shape: KvTensorShapeV1,
22    /// Physical layout.
23    pub layout: KvCacheLayoutV1,
24    /// Compression profile.
25    pub profile: KvCompressionProfileV1,
26    /// Encoded pages.
27    pub pages: Vec<KvEncodedPageV1>,
28    /// Compression receipt.
29    pub receipt: KvCompressionReceiptV1,
30}
31
32/// Decoded tensor and receipt.
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34pub struct KvDecodedTensorV1 {
35    /// Canonical contiguous f32 values.
36    pub values: Vec<f32>,
37    /// Decode receipt.
38    pub receipt: KvDecodeReceiptV1,
39}
40
41/// Encode a canonical contiguous f32 KV tensor.
42pub fn encode_kv_tensor(
43    shape: KvTensorShapeV1,
44    layout: KvCacheLayoutV1,
45    profile: KvCompressionProfileV1,
46    values: &[f32],
47) -> Result<KvEncodedTensorV1> {
48    shape.validate()?;
49    layout.validate_for_shape(&shape)?;
50    profile.validate_for_shape(&shape)?;
51    if values.len() != shape.element_count()? {
52        return Err(FibQuantError::CorruptPayload(format!(
53            "kv input has {} values, expected {}",
54            values.len(),
55            shape.element_count()?
56        )));
57    }
58    if values.iter().any(|value| !value.is_finite()) {
59        return Err(FibQuantError::CorruptPayload(
60            "kv input contains non-finite value".into(),
61        ));
62    }
63
64    let quantizer = build_quantizer(&profile)?;
65    let source_digest = kv_tensor_digest(values)?;
66    let profile_digest = profile.digest(&shape)?;
67    let mut pages = Vec::new();
68    let mut compressed_blocks = 0u32;
69    let mut raw_fallback_blocks = 0u32;
70    let mut fallback_reasons = Vec::new();
71    let page_count = profile.page_geometry.page_count(&shape)?;
72
73    for page_id in 0..page_count {
74        let token_start = page_id * profile.page_geometry.tokens_per_page;
75        let token_end = (token_start + profile.page_geometry.tokens_per_page).min(shape.tokens);
76        let token_count = token_end - token_start;
77        let mut blocks = Vec::new();
78        for batch in 0..shape.batch {
79            for layer in 0..shape.layers {
80                for head in 0..shape.kv_heads {
81                    for token in token_start..token_end {
82                        let block_id = blocks.len() as u32;
83                        let vector = vector_slice(values, &shape, batch, layer, head, token)?;
84                        let protected = profile
85                            .protected_policy
86                            .is_protected(&shape, layer, head, token);
87                        let block = if protected {
88                            raw_block(
89                                block_id,
90                                batch,
91                                layer,
92                                head,
93                                token,
94                                vector,
95                                profile.page_geometry.encoded_block_bytes,
96                                "protected_region",
97                            )
98                        } else {
99                            encode_vector_block(
100                                &quantizer, &profile, block_id, batch, layer, head, token, vector,
101                            )?
102                        };
103                        if block.raw_fallback {
104                            raw_fallback_blocks += 1;
105                            if !fallback_reasons.contains(&block.reason) {
106                                fallback_reasons.push(block.reason.clone());
107                            }
108                        } else {
109                            compressed_blocks += 1;
110                        }
111                        blocks.push(block);
112                    }
113                }
114            }
115        }
116        pages.push(KvEncodedPageV1::new(
117            page_id,
118            token_start,
119            token_count,
120            source_digest.clone(),
121            profile_digest.clone(),
122            &shape,
123            profile.page_geometry.clone(),
124            blocks,
125        )?);
126    }
127
128    let page_digests = pages.iter().map(|page| page.page_digest.clone()).collect();
129    let receipt = KvCompressionReceiptV1 {
130        schema_version: KV_RECEIPT_SCHEMA.into(),
131        operation_kind: KvOperationKindV1::Compress,
132        source_digest,
133        profile_digest,
134        shape_digest: shape.digest()?,
135        page_digests,
136        codebook_digest: profile.codebook_digest.clone(),
137        rotation_digest: profile.rotation_digest.clone(),
138        encoded_pages: pages.len() as u32,
139        compressed_blocks,
140        raw_fallback_blocks,
141        fallback_reasons,
142        recorded_unix_seconds: now_unix_seconds(),
143    };
144    Ok(KvEncodedTensorV1 {
145        shape,
146        layout,
147        profile,
148        pages,
149        receipt,
150    })
151}
152
153/// Decode encoded pages into canonical contiguous f32 values.
154pub fn decode_kv_pages(encoded: &KvEncodedTensorV1) -> Result<KvDecodedTensorV1> {
155    encoded.shape.validate()?;
156    encoded.layout.validate_for_shape(&encoded.shape)?;
157    encoded.profile.validate_for_shape(&encoded.shape)?;
158    encoded.receipt.validate()?;
159    let profile_digest = encoded.profile.digest(&encoded.shape)?;
160    if encoded.receipt.profile_digest != profile_digest {
161        return Err(FibQuantError::ProfileDigestMismatch {
162            expected: profile_digest,
163            actual: encoded.receipt.profile_digest.clone(),
164        });
165    }
166    let quantizer = build_quantizer(&encoded.profile)?;
167    let mut values = vec![0.0; encoded.shape.element_count()?];
168    let mut page_digests = Vec::with_capacity(encoded.pages.len());
169    let mut raw_fallback_blocks = 0u32;
170    for page in &encoded.pages {
171        page.validate(&encoded.shape)?;
172        if page.profile_digest != encoded.receipt.profile_digest {
173            return Err(FibQuantError::ProfileDigestMismatch {
174                expected: encoded.receipt.profile_digest.clone(),
175                actual: page.profile_digest.clone(),
176            });
177        }
178        page_digests.push(page.page_digest.clone());
179        for block in &page.encoded_blocks {
180            if block.batch >= encoded.shape.batch
181                || block.layer >= encoded.shape.layers
182                || block.kv_head >= encoded.shape.kv_heads
183                || block.token >= encoded.shape.tokens
184            {
185                return Err(FibQuantError::CorruptPayload(
186                    "kv block index outside shape".into(),
187                ));
188            }
189            let decoded = match &block.encoding {
190                KvBlockEncodingV1::RawF32 { values } => {
191                    raw_fallback_blocks += 1;
192                    values.clone()
193                }
194                KvBlockEncodingV1::FibQuant { code } => quantizer.decode(code)?,
195            };
196            if decoded.len() != encoded.shape.head_dim as usize {
197                return Err(FibQuantError::CorruptPayload(
198                    "decoded kv vector head_dim mismatch".into(),
199                ));
200            }
201            let out = vector_slice_mut(
202                &mut values,
203                &encoded.shape,
204                block.batch,
205                block.layer,
206                block.kv_head,
207                block.token,
208            )?;
209            out.copy_from_slice(&decoded);
210        }
211    }
212    let decoded_digest = kv_tensor_digest(&values)?;
213    Ok(KvDecodedTensorV1 {
214        values,
215        receipt: KvDecodeReceiptV1 {
216            schema_version: KV_RECEIPT_SCHEMA.into(),
217            operation_kind: KvOperationKindV1::Decode,
218            decoded_digest,
219            profile_digest: encoded.receipt.profile_digest.clone(),
220            shape_digest: encoded.shape.digest()?,
221            page_digests,
222            codebook_digest: encoded.profile.codebook_digest.clone(),
223            rotation_digest: encoded.profile.rotation_digest.clone(),
224            decoded_pages: encoded.pages.len() as u32,
225            raw_fallback_blocks,
226            recorded_unix_seconds: now_unix_seconds(),
227        },
228    })
229}
230
231fn build_quantizer(profile: &KvCompressionProfileV1) -> Result<FibQuantizer> {
232    let quantizer = FibQuantizer::new(profile.fib_profile.clone())?;
233    if quantizer.codebook().codebook_digest != profile.codebook_digest {
234        return Err(FibQuantError::CodebookDigestMismatch {
235            expected: quantizer.codebook().codebook_digest.clone(),
236            actual: profile.codebook_digest.clone(),
237        });
238    }
239    Ok(quantizer)
240}
241
242#[allow(clippy::too_many_arguments)]
243fn encode_vector_block(
244    quantizer: &FibQuantizer,
245    profile: &KvCompressionProfileV1,
246    block_id: u32,
247    batch: u32,
248    layer: u32,
249    head: u32,
250    token: u32,
251    vector: &[f32],
252) -> Result<KvEncodedBlockV1> {
253    match profile.axis_policy {
254        KvAxisPolicyV1::Raw => Ok(raw_block(
255            block_id,
256            batch,
257            layer,
258            head,
259            token,
260            vector,
261            profile.page_geometry.encoded_block_bytes,
262            "raw_axis_policy",
263        )),
264        KvAxisPolicyV1::PerToken => match quantizer.encode(vector) {
265            Ok(code) => Ok(KvEncodedBlockV1::fib_quant(
266                block_id,
267                batch,
268                layer,
269                head,
270                token,
271                code,
272                profile.page_geometry.encoded_block_bytes,
273                "fib_quant_per_token",
274            )),
275            Err(err) if profile.fallback_policy.mode == KvFallbackModeV1::KeepRaw => Ok(raw_block(
276                block_id,
277                batch,
278                layer,
279                head,
280                token,
281                vector,
282                profile.page_geometry.encoded_block_bytes,
283                format!("encode_fallback:{err}"),
284            )),
285            Err(err) => Err(err),
286        },
287        KvAxisPolicyV1::PerChannel | KvAxisPolicyV1::RoleAwareKiviStyle => {
288            if profile.fallback_policy.mode == KvFallbackModeV1::KeepRaw {
289                Ok(raw_block(
290                    block_id,
291                    batch,
292                    layer,
293                    head,
294                    token,
295                    vector,
296                    profile.page_geometry.encoded_block_bytes,
297                    "unsupported_axis_raw_fallback",
298                ))
299            } else {
300                Err(FibQuantError::DependencyUnsupported(
301                    "CPU reference codec supports per-token FibQuant compression only".into(),
302                ))
303            }
304        }
305    }
306}
307
308#[allow(clippy::too_many_arguments)]
309fn raw_block(
310    block_id: u32,
311    batch: u32,
312    layer: u32,
313    head: u32,
314    token: u32,
315    vector: &[f32],
316    fixed_size_bytes: u32,
317    reason: impl Into<String>,
318) -> KvEncodedBlockV1 {
319    KvEncodedBlockV1::raw(
320        block_id,
321        batch,
322        layer,
323        head,
324        token,
325        vector.to_vec(),
326        fixed_size_bytes,
327        reason,
328    )
329}
330
331/// Decode a random-access slice of the KV tensor for a single layer, head,
332/// and token range without decoding the entire tensor.
333///
334/// Only pages overlapping `[token_start, token_end)` are visited, and within
335/// each page only blocks matching `layer`, `head`, and the token range are
336/// decoded. The decoded vectors are concatenated in ascending token order.
337///
338/// Currently decodes batch 0 (the common single-batch inference case).
339pub fn decode_kv_slice(
340    encoded: &KvEncodedTensorV1,
341    layer: u32,
342    head: u32,
343    token_start: u32,
344    token_end: u32,
345) -> Result<Vec<f32>> {
346    // ── validate tensor-level invariants (same as decode_kv_pages) ──────────
347    encoded.shape.validate()?;
348    encoded.layout.validate_for_shape(&encoded.shape)?;
349    encoded.profile.validate_for_shape(&encoded.shape)?;
350    encoded.receipt.validate()?;
351    let profile_digest = encoded.profile.digest(&encoded.shape)?;
352    if encoded.receipt.profile_digest != profile_digest {
353        return Err(FibQuantError::ProfileDigestMismatch {
354            expected: profile_digest,
355            actual: encoded.receipt.profile_digest.clone(),
356        });
357    }
358
359    // ── validate requested slice bounds ──────────────────────────────────────
360    if layer >= encoded.shape.layers {
361        return Err(FibQuantError::CorruptPayload(format!(
362            "decode_kv_slice: layer {layer} >= shape.layers {}",
363            encoded.shape.layers
364        )));
365    }
366    if head >= encoded.shape.kv_heads {
367        return Err(FibQuantError::CorruptPayload(format!(
368            "decode_kv_slice: head {head} >= shape.kv_heads {}",
369            encoded.shape.kv_heads
370        )));
371    }
372    if token_start >= token_end {
373        return Err(FibQuantError::CorruptPayload(format!(
374            "decode_kv_slice: token_start {token_start} >= token_end {token_end}"
375        )));
376    }
377    if token_start >= encoded.shape.tokens {
378        return Err(FibQuantError::CorruptPayload(format!(
379            "decode_kv_slice: token_start {token_start} >= shape.tokens {}",
380            encoded.shape.tokens
381        )));
382    }
383    if token_end > encoded.shape.tokens {
384        return Err(FibQuantError::CorruptPayload(format!(
385            "decode_kv_slice: token_end {token_end} > shape.tokens {}",
386            encoded.shape.tokens
387        )));
388    }
389
390    let quantizer = build_quantizer(&encoded.profile)?;
391    let head_dim = encoded.shape.head_dim as usize;
392
393    // Collect decoded vectors keyed by token so we can emit in ascending order
394    // regardless of page/block traversal order.
395    let mut decoded_map: std::collections::BTreeMap<u32, Vec<f32>> =
396        std::collections::BTreeMap::new();
397
398    for page in &encoded.pages {
399        let page_token_end = page.token_start + page.token_count;
400
401        // Skip pages entirely outside the requested token range.
402        if page.token_start >= token_end || page_token_end <= token_start {
403            continue;
404        }
405
406        // Validate the page (same checks as decode_kv_pages applies per-page).
407        page.validate(&encoded.shape)?;
408        if page.profile_digest != encoded.receipt.profile_digest {
409            return Err(FibQuantError::ProfileDigestMismatch {
410                expected: encoded.receipt.profile_digest.clone(),
411                actual: page.profile_digest.clone(),
412            });
413        }
414
415        for block in &page.encoded_blocks {
416            // Only batch 0 for the single-batch slice path.
417            if block.batch != 0 {
418                continue;
419            }
420            if block.layer != layer || block.kv_head != head {
421                continue;
422            }
423            if block.token < token_start || block.token >= token_end {
424                continue;
425            }
426
427            // Bounds-check block indices against shape.
428            if block.batch >= encoded.shape.batch
429                || block.layer >= encoded.shape.layers
430                || block.kv_head >= encoded.shape.kv_heads
431                || block.token >= encoded.shape.tokens
432            {
433                return Err(FibQuantError::CorruptPayload(
434                    "kv block index outside shape".into(),
435                ));
436            }
437
438            let decoded = match &block.encoding {
439                KvBlockEncodingV1::RawF32 { values } => values.clone(),
440                KvBlockEncodingV1::FibQuant { code } => quantizer.decode(code)?,
441            };
442
443            if decoded.len() != head_dim {
444                return Err(FibQuantError::CorruptPayload(
445                    "decoded kv vector head_dim mismatch".into(),
446                ));
447            }
448
449            decoded_map.insert(block.token, decoded);
450        }
451    }
452
453    // Concatenate decoded vectors in ascending token order.
454    let token_count = (token_end - token_start) as usize;
455    let mut result = Vec::with_capacity(token_count * head_dim);
456    for token in token_start..token_end {
457        match decoded_map.remove(&token) {
458            Some(vec) => result.extend_from_slice(&vec),
459            None => {
460                return Err(FibQuantError::CorruptPayload(format!(
461                    "decode_kv_slice: missing block for token {token} (layer {layer}, head {head})"
462                )));
463            }
464        }
465    }
466
467    Ok(result)
468}
469
470fn vector_offset(
471    shape: &KvTensorShapeV1,
472    batch: u32,
473    layer: u32,
474    head: u32,
475    token: u32,
476) -> Result<usize> {
477    if batch >= shape.batch
478        || layer >= shape.layers
479        || head >= shape.kv_heads
480        || token >= shape.tokens
481    {
482        return Err(FibQuantError::CorruptPayload(
483            "kv vector index outside shape".into(),
484        ));
485    }
486    let vectors_before = (((batch as usize * shape.layers as usize + layer as usize)
487        * shape.kv_heads as usize
488        + head as usize)
489        * shape.tokens as usize)
490        + token as usize;
491    vectors_before
492        .checked_mul(shape.head_dim as usize)
493        .ok_or_else(|| FibQuantError::ResourceLimitExceeded("kv vector offset overflow".into()))
494}
495
496fn vector_slice<'a>(
497    values: &'a [f32],
498    shape: &KvTensorShapeV1,
499    batch: u32,
500    layer: u32,
501    head: u32,
502    token: u32,
503) -> Result<&'a [f32]> {
504    let start = vector_offset(shape, batch, layer, head, token)?;
505    let end = start + shape.head_dim as usize;
506    values
507        .get(start..end)
508        .ok_or_else(|| FibQuantError::CorruptPayload("kv vector slice out of bounds".into()))
509}
510
511fn vector_slice_mut<'a>(
512    values: &'a mut [f32],
513    shape: &KvTensorShapeV1,
514    batch: u32,
515    layer: u32,
516    head: u32,
517    token: u32,
518) -> Result<&'a mut [f32]> {
519    let start = vector_offset(shape, batch, layer, head, token)?;
520    let end = start + shape.head_dim as usize;
521    values
522        .get_mut(start..end)
523        .ok_or_else(|| FibQuantError::CorruptPayload("kv vector slice out of bounds".into()))
524}
525
526// ────────────────────────────────────────────────────────────────────────────
527//  Tests
528// ────────────────────────────────────────────────────────────────────────────
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use crate::profile::FibQuantProfileV1;
534
535    use super::super::layout::{KvCacheLayoutV1, KvPageGeometryV1};
536    use super::super::profile::KvAxisPolicyV1;
537    use super::super::shape::{KvAttentionKind, KvDType, KvRole, KvRopeState, KvTensorShapeV1};
538
539    /// Build a small encoded tensor suitable for slice tests.
540    ///
541    /// Shape: batch=1, layers=2, kv_heads=2, tokens=6, head_dim=8
542    /// Page geometry: tokens_per_page=3 (so 2 pages — boundary at token 3)
543    /// FibQuant profile: k=4, N=32, ambient_dim=8
544    fn build_test_tensor() -> KvEncodedTensorV1 {
545        let shape = KvTensorShapeV1::new(
546            KvRole::Key,
547            KvAttentionKind::Mha,
548            1, // batch
549            2, // layers
550            2, // kv_heads
551            2, // query_heads (== kv_heads for MHA)
552            6, // tokens
553            8, // head_dim
554            KvDType::F32,
555            KvRopeState::PreRope,
556        );
557        let layout = KvCacheLayoutV1::canonical(&shape).expect("canonical layout");
558        let fib_profile =
559            FibQuantProfileV1::paper_default(8, 4, 32, 42).expect("build fib profile");
560        let quantizer = FibQuantizer::new(fib_profile.clone()).expect("build quantizer");
561        let page_geometry = KvPageGeometryV1::new(3, 8, 64); // tokens_per_page=3, head_dim=8
562        let profile = KvCompressionProfileV1::from_parts(
563            "test-profile",
564            &shape,
565            fib_profile,
566            quantizer.codebook().codebook_digest.clone(),
567            KvAxisPolicyV1::PerToken,
568            page_geometry,
569        )
570        .expect("build kv profile");
571
572        // Create deterministic input values.
573        let total = shape.element_count().expect("element count");
574        let values: Vec<f32> = (0..total).map(|i| (i as f32) * 0.1).collect();
575
576        encode_kv_tensor(shape, layout, profile, &values).expect("encode tensor")
577    }
578
579    /// Extract a single (layer, head) slice from the full decoded tensor
580    /// so we can compare against `decode_kv_slice`.
581    fn full_decode_slice(
582        full: &[f32],
583        shape: &KvTensorShapeV1,
584        layer: u32,
585        head: u32,
586        token_start: u32,
587        token_end: u32,
588    ) -> Vec<f32> {
589        let head_dim = shape.head_dim as usize;
590        let mut result = Vec::with_capacity((token_end - token_start) as usize * head_dim);
591        for token in token_start..token_end {
592            let offset = vector_offset(shape, 0, layer, head, token).expect("offset");
593            result.extend_from_slice(&full[offset..offset + head_dim]);
594        }
595        result
596    }
597
598    #[test]
599    fn slice_matches_full_decode() {
600        let encoded = build_test_tensor();
601        let full = decode_kv_pages(&encoded).expect("full decode");
602
603        // Request the entire token range for layer 1, head 1.
604        let slice = decode_kv_slice(&encoded, 1, 1, 0, 6).expect("slice decode");
605        let expected = full_decode_slice(&full.values, &encoded.shape, 1, 1, 0, 6);
606        assert_eq!(slice.len(), expected.len());
607        // Values may not be bit-identical after quant→dequant round-trip, but
608        // the slice should match the full decode exactly since both decode
609        // the same blocks.
610        for (i, (a, b)) in slice.iter().zip(expected.iter()).enumerate() {
611            assert_eq!(a, b, "mismatch at index {i}: slice={a}, full={b}");
612        }
613    }
614
615    #[test]
616    fn slice_single_token() {
617        let encoded = build_test_tensor();
618        let full = decode_kv_pages(&encoded).expect("full decode");
619
620        let slice = decode_kv_slice(&encoded, 0, 0, 2, 3).expect("single-token slice");
621        assert_eq!(slice.len(), encoded.shape.head_dim as usize);
622
623        let expected = full_decode_slice(&full.values, &encoded.shape, 0, 0, 2, 3);
624        assert_eq!(slice, expected);
625    }
626
627    #[test]
628    fn slice_spans_page_boundary() {
629        let encoded = build_test_tensor();
630        // Page 0 covers tokens 0..3, page 1 covers tokens 3..6.
631        // Request tokens 2..4 — spanning the boundary.
632        let full = decode_kv_pages(&encoded).expect("full decode");
633
634        let slice = decode_kv_slice(&encoded, 0, 1, 2, 4).expect("boundary-spanning slice");
635        assert_eq!(slice.len(), 2 * encoded.shape.head_dim as usize);
636
637        let expected = full_decode_slice(&full.values, &encoded.shape, 0, 1, 2, 4);
638        assert_eq!(slice, expected);
639    }
640
641    #[test]
642    fn slice_only_visits_overlapping_pages() {
643        let encoded = build_test_tensor();
644        // Request tokens 0..2 — entirely in page 0.
645        let slice = decode_kv_slice(&encoded, 0, 0, 0, 2).expect("partial slice");
646        assert_eq!(slice.len(), 2 * encoded.shape.head_dim as usize);
647
648        let full = decode_kv_pages(&encoded).expect("full decode");
649        let expected = full_decode_slice(&full.values, &encoded.shape, 0, 0, 0, 2);
650        assert_eq!(slice, expected);
651    }
652
653    #[test]
654    fn slice_invalid_layer_returns_error() {
655        let encoded = build_test_tensor();
656        let err = decode_kv_slice(&encoded, 99, 0, 0, 1).unwrap_err();
657        assert!(matches!(err, FibQuantError::CorruptPayload(ref msg)
658            if msg.contains("layer")));
659    }
660
661    #[test]
662    fn slice_invalid_head_returns_error() {
663        let encoded = build_test_tensor();
664        let err = decode_kv_slice(&encoded, 0, 99, 0, 1).unwrap_err();
665        assert!(matches!(err, FibQuantError::CorruptPayload(ref msg)
666            if msg.contains("head")));
667    }
668
669    #[test]
670    fn slice_invalid_token_range_returns_error() {
671        let encoded = build_test_tensor();
672        // token_start >= token_end
673        let err = decode_kv_slice(&encoded, 0, 0, 3, 3).unwrap_err();
674        assert!(matches!(err, FibQuantError::CorruptPayload(ref msg)
675            if msg.contains("token_start")));
676
677        // token_start beyond shape tokens
678        let err = decode_kv_slice(&encoded, 0, 0, 99, 100).unwrap_err();
679        assert!(matches!(err, FibQuantError::CorruptPayload(ref msg)
680            if msg.contains("token_start")));
681
682        // token_end beyond shape tokens
683        let err = decode_kv_slice(&encoded, 0, 0, 0, 100).unwrap_err();
684        assert!(matches!(err, FibQuantError::CorruptPayload(ref msg)
685            if msg.contains("token_end")));
686    }
687}