viterbi 0.2.0

Pure-Rust, no-unsafe convolutional encoder + Viterbi (MLSE) decoder — CCSDS inner FEC: hard & soft-decision (LLR), rate 1/2 & 1/3, puncturing (2/3–7/8), generic constraint length K<=9.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
// Author: BolivarTech
// Version: 1.0.0
// Date: 2026-07-05
//! Structural-validation tests for [`viterbi::PunctureMatrix`] (Task 7).
//!
//! These cover only the structural surface built in Task 7: `PunctureMatrix::new`
//! validation order, the accessors, and the Yasuda `ccsds_rate` presets. The
//! catastrophic guard (`Puncturer`/`DePuncturer`) is exercised in later tasks.

use proptest::prelude::*;
// `Puncturer`/`DePuncturer` are re-exported at the crate root (spec §3.3), so they resolve here
// without the `viterbi::puncture::` submodule path.
use viterbi::{
    BranchMetric, CodeParams, CodedBlock, DePuncturer, HardHamming, PunctureError, PunctureMatrix,
    PunctureOrder, PuncturedRate, Puncturer, SoftLlr, ViterbiDecoder, ViterbiEncoder,
};

// --- `new` validation order (Caspar) ------------------------------------------

#[test]
fn new_rejects_no_rows_as_empty_pattern() {
    let err = PunctureMatrix::new(Vec::new(), PunctureOrder::ByColumn).unwrap_err();
    assert_eq!(err, PunctureError::EmptyPattern);
}

#[test]
fn new_rejects_zero_length_rows_as_empty_pattern() {
    // Checked FIRST, before any column indexing (a zero-period row would panic an
    // eager period check).
    let err =
        PunctureMatrix::new(vec![Vec::new(), Vec::new()], PunctureOrder::ByColumn).unwrap_err();
    assert_eq!(err, PunctureError::EmptyPattern);
}

#[test]
fn new_rejects_ragged_rows() {
    let err = PunctureMatrix::new(vec![vec![true, true], vec![true]], PunctureOrder::ByColumn)
        .unwrap_err();
    assert_eq!(err, PunctureError::RaggedRows);
}

#[test]
fn new_rejects_more_than_three_rows() {
    // n > 3 has no engine support (n ∈ {2,3}) and would shift-overflow the catastrophic-check
    // keepmask (`1u32 << (n-1-j)`) for n > 32; rejected structurally.
    let err = PunctureMatrix::new(vec![vec![true]; 4], PunctureOrder::ByColumn).unwrap_err();
    assert_eq!(err, PunctureError::RowCountMismatch { rows: 4, n: 3 });
}

#[test]
fn new_rejects_period_above_max() {
    // A single row of length MAX_PERIOD + 1 is over the cap.
    let period = viterbi::puncture::MAX_PERIOD + 1;
    let err = PunctureMatrix::new(vec![vec![true; period]], PunctureOrder::ByColumn).unwrap_err();
    assert_eq!(
        err,
        PunctureError::PeriodTooLarge {
            period,
            cap: viterbi::puncture::MAX_PERIOD,
        }
    );
}

#[test]
fn new_rejects_all_void_column() {
    // Column 1 has no kept bit in any row → AllVoidPeriod.
    let err = PunctureMatrix::new(
        vec![vec![true, false], vec![true, false]],
        PunctureOrder::ByColumn,
    )
    .unwrap_err();
    assert_eq!(err, PunctureError::AllVoidPeriod);
}

// --- `new` acceptance + accessors ---------------------------------------------

#[test]
fn new_accepts_valid_pattern_and_caches_metadata() {
    let m = PunctureMatrix::new(
        vec![vec![true, true], vec![true, false]],
        PunctureOrder::ByColumn,
    )
    .unwrap();
    assert_eq!(m.n(), 2);
    assert_eq!(m.period(), 2);
    assert_eq!(m.kept_per_period(), 3);
    assert_eq!(m.order(), PunctureOrder::ByColumn);
}

// --- `ccsds_rate` presets ------------------------------------------------------

#[test]
fn ccsds_rate_r3_4_has_expected_shape() {
    // Yasuda 3/4 keep-matrix [[1,0,1],[1,1,0]] built with Interleaved order.
    let m = PunctureMatrix::ccsds_rate(2, PuncturedRate::R3_4).unwrap();
    assert_eq!(m.n(), 2);
    assert_eq!(m.period(), 3);
    assert_eq!(m.kept_per_period(), 4);
    assert_eq!(m.order(), PunctureOrder::Interleaved);
    assert_eq!(m.code_rate(), (3, 4));
}

#[test]
fn ccsds_rate_all_supported_rates_match_yasuda() {
    let cases = [
        (PuncturedRate::R2_3, 2usize, 3usize, (2usize, 3usize)),
        (PuncturedRate::R3_4, 3, 4, (3, 4)),
        (PuncturedRate::R5_6, 5, 6, (5, 6)),
        (PuncturedRate::R7_8, 7, 8, (7, 8)),
    ];
    for (rate, period, kept, code_rate) in cases {
        let m = PunctureMatrix::ccsds_rate(2, rate).unwrap();
        assert_eq!(m.n(), 2);
        assert_eq!(m.period(), period);
        assert_eq!(m.kept_per_period(), kept);
        assert_eq!(m.code_rate(), code_rate);
        assert_eq!(m.order(), PunctureOrder::Interleaved);
    }
}

#[test]
fn ccsds_rate_rejects_n1() {
    let err = PunctureMatrix::ccsds_rate(1, PuncturedRate::R2_3).unwrap_err();
    assert_eq!(err, PunctureError::UnsupportedRate);
}

#[test]
fn ccsds_rate_rejects_n3() {
    // No standard CCSDS rate-1/3 punctured family — n=3 is unsupported here.
    let err = PunctureMatrix::ccsds_rate(3, PuncturedRate::R3_4).unwrap_err();
    assert_eq!(err, PunctureError::UnsupportedRate);
}

#[test]
fn ccsds_rate_rejects_n4() {
    let err = PunctureMatrix::ccsds_rate(4, PuncturedRate::R2_3).unwrap_err();
    assert_eq!(err, PunctureError::UnsupportedRate);
}

// --- Task 8: `Puncturer` catastrophic-puncture guard ---------------------------
//
// The verdicts below were independently verified with an offline product-graph /
// GF(2) computation (NOT by running this crate's implementation). If a test fails,
// the bug is in the product-graph code, never in the expected verdict.
//
// CONCRETE catastrophic vectors (Melchior/Caspar): puncturing away all but one
// non-monomial generator makes the punctured code effectively rate-1/1 with a
// single non-monomial generator ⇒ catastrophic.

#[test]
fn puncturer_rejects_catastrophic_n2() {
    // Keep C1 (0o171, non-monomial), puncture ALL of C2 (period 2, per-column still ≥1 kept):
    let m = PunctureMatrix::new(
        vec![vec![true, true], vec![false, false]],
        PunctureOrder::ByColumn,
    )
    .unwrap();
    assert_eq!(
        Puncturer::new(m, &CodeParams::ccsds_r1_2()).unwrap_err(),
        PunctureError::Catastrophic
    );
}

#[test]
fn puncturer_rejects_catastrophic_n3() {
    // Keep only g0 (0o133, non-monomial), puncture g1 and g2 (period 2):
    let m = PunctureMatrix::new(
        vec![vec![true, true], vec![false, false], vec![false, false]],
        PunctureOrder::ByColumn,
    )
    .unwrap();
    assert_eq!(
        Puncturer::new(m, &CodeParams::ccsds_r1_3()).unwrap_err(),
        PunctureError::Catastrophic
    );
}

#[test]
fn puncturer_rejects_catastrophic_period3() {
    // period-3 catastrophic, HAND-COMPUTED (Caspar — exercises phase 0→1→2→0): keep C1 all 3 columns,
    // puncture C2 all 3 ⇒ punctured code = single generator 0o171 (non-monomial) ⇒ catastrophic by the
    // single-generator GF(2) rule (independent of the impl). Each column keeps ≥1 bit.
    let m = PunctureMatrix::new(
        vec![vec![true, true, true], vec![false, false, false]],
        PunctureOrder::ByColumn,
    )
    .unwrap();
    assert_eq!(
        Puncturer::new(m, &CodeParams::ccsds_r1_2()).unwrap_err(),
        PunctureError::Catastrophic
    );
}

#[test]
fn puncturer_accepts_known_good_2_3() {
    let m = PunctureMatrix::ccsds_rate(2, PuncturedRate::R2_3).unwrap();
    assert!(Puncturer::new(m, &CodeParams::ccsds_r1_2()).is_ok());
}

#[test]
fn puncturer_accepts_known_good_n3() {
    // Guards against a false-POSITIVE catastrophic check (Caspar): a light rate-1/3 puncture that keeps
    // ALL three generators active over the period stays non-catastrophic and MUST be accepted.
    // period 2: keep C1 both, C2 at t=0, C3 at t=1 — every generator still contributes.
    let m = PunctureMatrix::new(
        vec![vec![true, true], vec![true, false], vec![false, true]],
        PunctureOrder::ByColumn,
    )
    .unwrap();
    assert!(Puncturer::new(m, &CodeParams::ccsds_r1_3()).is_ok());
}

#[test]
fn puncturer_rejects_catastrophic_phase_interaction() {
    // PHASE-INTERACTION catastrophic (Caspar): period-3, BOTH generators partially kept — C1(0o171) only
    // at col 2, C2(0o133) at cols 0,1 — so this is NOT single-generator reduction; catastrophicity
    // emerges from the phase arrangement, gating the (state,phase) product-graph indexing. Verified
    // INDEPENDENTLY by an offline product-graph / GF(2) computation, NOT by the impl.
    // C1 = [F,F,T], C2 = [T,T,F].
    let m = PunctureMatrix::new(
        vec![vec![false, false, true], vec![true, true, false]],
        PunctureOrder::ByColumn,
    )
    .unwrap();
    assert_eq!(
        Puncturer::new(m, &CodeParams::ccsds_r1_2()).unwrap_err(),
        PunctureError::Catastrophic
    );
}

#[test]
fn puncturer_new_rejects_n_mismatch() {
    // A rate-1/2 matrix (n=2) applied to a rate-1/3 mother code (n=3) → NMismatch (never a silent apply).
    let m = PunctureMatrix::ccsds_rate(2, PuncturedRate::R2_3).unwrap();
    assert_eq!(
        Puncturer::new(m, &CodeParams::ccsds_r1_3()).unwrap_err(),
        PunctureError::NMismatch {
            matrix_n: 2,
            params_n: 3,
        }
    );
}

// --- Task 8: `Puncturer::puncture` length + alignment guards --------------------

#[test]
fn puncture_deletes_body_and_keeps_tail_length() {
    // R2_3: period 2, kept_per_period 3. 8 body stages = 4 full periods → 12 kept body bits;
    // tail = M*n = 6*2 = 12 verbatim. Output = 12 + 12 = 24 coded bits.
    let enc = ViterbiEncoder::new(CodeParams::ccsds_r1_2()).unwrap();
    let coded = enc.encode(&[0xB2]).unwrap(); // 8 info bits → (8+6)*2 = 28 coded bits
    assert_eq!(coded.nbits, 28);
    let p = Puncturer::new(
        PunctureMatrix::ccsds_rate(2, PuncturedRate::R2_3).unwrap(),
        &CodeParams::ccsds_r1_2(),
    )
    .unwrap();
    let punctured = p.puncture(&coded, 8).unwrap();
    assert_eq!(punctured.nbits, 24);
}

#[test]
fn puncture_rejects_inconsistent_nbits() {
    // coded.nbits = 28 (from 8 payload bits), but the caller claims nbits=7 → expected (7+6)*2 = 26 ≠ 28.
    let enc = ViterbiEncoder::new(CodeParams::ccsds_r1_2()).unwrap();
    let coded = enc.encode(&[0xB2]).unwrap();
    let p = Puncturer::new(
        PunctureMatrix::ccsds_rate(2, PuncturedRate::R2_3).unwrap(),
        &CodeParams::ccsds_r1_2(),
    )
    .unwrap();
    assert_eq!(
        p.puncture(&coded, 7).unwrap_err(),
        PunctureError::MisalignedInput {
            got: 28,
            expected: 26,
        }
    );
}

#[test]
fn puncture_rejects_near_usize_max_nbits_without_overflow() {
    // An adversarial near-usize::MAX nbits must fail gracefully (over the payload cap), never overflow-panic.
    let enc = ViterbiEncoder::new(CodeParams::ccsds_r1_2()).unwrap();
    let coded = enc.encode(&[0xB2]).unwrap();
    let p = Puncturer::new(
        PunctureMatrix::ccsds_rate(2, PuncturedRate::R2_3).unwrap(),
        &CodeParams::ccsds_r1_2(),
    )
    .unwrap();
    assert!(matches!(
        p.puncture(&coded, usize::MAX - 1),
        Err(PunctureError::PayloadTooLarge { .. })
    ));
}

#[test]
fn puncture_rejects_short_coded_bytes_without_panic() {
    // A hand-built CodedBlock whose `nbits` passes the (nbits+m)*n check (28 == (8+6)*2) but whose
    // `bytes` is far too short (1 byte = 8 bits): the bit-reading loop would index out of bounds →
    // panic, violating the crate no-panic invariant (the module documents "never index-panics").
    // `puncture` must guard `bytes.len()` vs `nbits` (mirroring `decode_block`) and return
    // MisalignedInput instead.
    let p = Puncturer::new(
        PunctureMatrix::ccsds_rate(2, PuncturedRate::R3_4).unwrap(),
        &CodeParams::ccsds_r1_2(),
    )
    .unwrap();
    assert_eq!(28, (8 + 6) * 2); // confirms the block passes the nbits check and reaches the bit loop
    let malformed = CodedBlock {
        bytes: vec![0u8; 1], // only 8 bits available, but nbits claims 28
        nbits: 28,
    };
    assert!(matches!(
        p.puncture(&malformed, 8),
        Err(PunctureError::MisalignedInput { .. })
    ));
}

// --- Task 9: `DePuncturer<M>` erasure insertion + alignment guards --------------
//
// The receive side is the inverse of `Puncturer::puncture`: it rebuilds the full
// `(nbits+M)*n` sample stream, inserting `M::erasure()` at the punctured BODY
// positions and copying the `M*n` tail samples verbatim. `SoftLlr::erasure() == 0`
// (an easy-to-assert sentinel), so the soft de-puncturer is used below.

#[test]
fn depuncture_inserts_erasures_at_body_and_copies_tail_verbatim() {
    // Matrix [[T,T],[T,F]] (n=2, period 2): over the body only stage`t`, output j=1 at odd phase
    // is punctured. With nbits=2 stages → punctured body position is (t=1, j=1) = full-stream index 3.
    // Interleaved order: the on-air emission order equals the reconstructed `t*n+j` index order, so the
    // received samples map straight to body positions 0,1,2 (erasure at 3) — the erasure-insertion +
    // verbatim-tail behaviour this test checks is order-agnostic; Interleaved keeps it directly legible.
    let matrix = PunctureMatrix::new(
        vec![vec![true, true], vec![true, false]],
        PunctureOrder::Interleaved,
    )
    .unwrap();
    let dp = DePuncturer::<SoftLlr>::new(matrix, &CodeParams::ccsds_r1_2()).unwrap();

    // nbits=2 → kept body bits = 3 (stage0 j0, stage0 j1, stage1 j0); tail = M*n = 6*2 = 12 verbatim.
    // expected on-air length = 3 + 12 = 15; full reconstructed stream = (2+6)*2 = 16 samples.
    assert_eq!(dp.expected_punctured_len(2), Some(15));

    // Distinct nonzero received samples so the inserted erasure (0) is unambiguous.
    let received: Vec<i8> = vec![
        10, 11, 12, // kept body samples
        21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, // 12 tail samples
    ];
    assert_eq!(received.len(), 15);

    let full = dp.depuncture(&received, 2).unwrap();
    assert_eq!(full.len(), 16); // (nbits + M) * n

    // Body: [10, 11, 12, <erasure=0>]
    assert_eq!(&full[0..3], &[10, 11, 12]);
    assert_eq!(full[3], SoftLlr::erasure()); // punctured body position → erasure (0)
                                             // Tail: the 12 verbatim samples copied unchanged.
    assert_eq!(&full[4..16], &received[3..15]);
}

#[test]
fn depuncture_rejects_wrong_length_input() {
    let matrix = PunctureMatrix::new(
        vec![vec![true, true], vec![true, false]],
        PunctureOrder::ByColumn,
    )
    .unwrap();
    let dp = DePuncturer::<SoftLlr>::new(matrix, &CodeParams::ccsds_r1_2()).unwrap();
    // expected(2) == 15; supply 14 → MisalignedInput { got: 14, expected: 15 }.
    let received = vec![0i8; 14];
    assert_eq!(
        dp.depuncture(&received, 2).unwrap_err(),
        PunctureError::MisalignedInput {
            got: 14,
            expected: 15,
        }
    );
}

#[test]
fn depuncture_new_rejects_n_mismatch() {
    // A rate-1/2 matrix (n=2) applied to a rate-1/3 mother code (n=3) → NMismatch (same guard as TX).
    let m = PunctureMatrix::ccsds_rate(2, PuncturedRate::R2_3).unwrap();
    assert_eq!(
        DePuncturer::<SoftLlr>::new(m, &CodeParams::ccsds_r1_3()).unwrap_err(),
        PunctureError::NMismatch {
            matrix_n: 2,
            params_n: 3,
        }
    );
}

#[test]
fn depuncture_new_rejects_catastrophic() {
    // Same shared catastrophic gate as `Puncturer::new`: keep C1 (0o171, non-monomial), puncture all C2.
    let m = PunctureMatrix::new(
        vec![vec![true, true], vec![false, false]],
        PunctureOrder::ByColumn,
    )
    .unwrap();
    assert_eq!(
        DePuncturer::<SoftLlr>::new(m, &CodeParams::ccsds_r1_2()).unwrap_err(),
        PunctureError::Catastrophic
    );
}

#[test]
fn expected_punctured_len_strictly_increasing_over_aligned_nbits() {
    // Period-aligned nbits (period, 2*period, 3*period) must yield strictly increasing lengths —
    // the collision-immunity property (each added period adds `kept_per_period` body bits).
    let matrix = PunctureMatrix::ccsds_rate(2, PuncturedRate::R2_3).unwrap(); // period 2, kept 3
    let dp = DePuncturer::<SoftLlr>::new(matrix, &CodeParams::ccsds_r1_2()).unwrap();
    let l1 = dp.expected_punctured_len(2).unwrap();
    let l2 = dp.expected_punctured_len(4).unwrap();
    let l3 = dp.expected_punctured_len(6).unwrap();
    assert!(
        l1 < l2 && l2 < l3,
        "lengths must be strictly increasing: {l1} {l2} {l3}"
    );
    // Concretely: 3 kept/period + 12 tail → 15, 18, 21.
    assert_eq!((l1, l2, l3), (15, 18, 21));
}

#[test]
fn depuncture_near_usize_max_nbits_fails_gracefully() {
    // An adversarial near-usize::MAX nbits: expected_punctured_len still overflows → None, and depuncture
    // now rejects it up front via the payload cap (PayloadTooLarge) — never an overflow/index panic.
    let matrix = PunctureMatrix::ccsds_rate(2, PuncturedRate::R2_3).unwrap();
    let dp = DePuncturer::<SoftLlr>::new(matrix, &CodeParams::ccsds_r1_2()).unwrap();
    assert!(dp.expected_punctured_len(usize::MAX).is_none());
    assert!(matches!(
        dp.depuncture(&[], usize::MAX),
        Err(PunctureError::PayloadTooLarge { .. })
    ));
}

// =============================================================================
// Task 10: end-to-end puncture round-trip (both n), tail-exemption vectors, and
// the length-collision search.
// =============================================================================
//
// Step 0 — the CANONICAL, documented `CodedBlock` → `M::Sample` conversions. Both
// the property tests and the hand-computed vectors use ONLY these two functions,
// so the bit→sample mapping is one path, not ad-hoc per test (Caspar).

/// Hard: each coded bit (MSB-first) → a `HardHamming` sample `0`/`1`.
fn coded_to_hard(coded: &CodedBlock) -> Vec<u8> {
    (0..coded.nbits)
        .map(|i| (coded.bytes[i / 8] >> (7 - (i % 8))) & 1)
        .collect()
}

/// Soft: each coded bit → a clean high-confidence LLR (`bit 0 → +64`, `bit 1 → -64`;
/// the `λ>0 ⇒ bit 0` convention). Never `0`, so it can never collide with the
/// `SoftLlr::erasure()` (= `0`) sentinel a de-puncturer reinserts.
fn coded_to_soft(coded: &CodedBlock) -> Vec<i8> {
    coded_to_hard(coded)
        .into_iter()
        .map(|b| if b == 0 { 64 } else { -64 })
        .collect()
}

/// First `nbits` bits of `bytes` (MSB-first) as a `0/1` vector — for comparing a
/// decoded payload against the source bits, ignoring the final byte's padding.
fn first_bits(bytes: &[u8], nbits: usize) -> Vec<u8> {
    (0..nbits)
        .map(|i| (bytes[i / 8] >> (7 - (i % 8))) & 1)
        .collect()
}

/// Independently reconstruct the full de-punctured sample stream the de-puncturer
/// SHOULD produce, from the ORIGINAL full rate-`1/n` samples and the KNOWN
/// keep-pattern — WITHOUT consulting the crate's internal keep-map. A punctured
/// **body** position (`keep[j][t % period] == false`) becomes `erasure`; a kept
/// body position keeps its original sample; the `M·n` **tail** samples
/// (`t >= nbits`) are copied verbatim (tail exemption, §4.4). Asserting the
/// de-puncturer equals this is the "restores every non-punctured position" check.
fn expected_depunctured<S: Copy>(
    orig: &[S],
    keep: &[Vec<bool>],
    n: usize,
    nbits: usize,
    erasure: S,
) -> Vec<S> {
    let period = keep[0].len();
    let mut out = Vec::with_capacity(orig.len());
    for t in 0..nbits {
        let phase = t % period;
        for j in 0..n {
            if keep[j][phase] {
                out.push(orig[t * n + j]);
            } else {
                out.push(erasure);
            }
        }
    }
    // Tail: verbatim (exempt from puncturing).
    for &s in &orig[nbits * n..] {
        out.push(s);
    }
    out
}

/// The Yasuda keep-patterns over the rate-1/2 mother (mirror of `ccsds_rate`), by
/// selector `0..4` = 2/3, 3/4, 5/6, 7/8. Defined locally so the round-trip has the
/// keep-matrix for the independent `expected_depunctured` reconstruction.
fn yasuda_keep(sel: usize) -> Vec<Vec<bool>> {
    let rows: &[&[u8]] = match sel {
        0 => &[&[1, 1], &[1, 0]],
        1 => &[&[1, 0, 1], &[1, 1, 0]],
        2 => &[&[1, 0, 1, 0, 1], &[1, 1, 0, 1, 0]],
        _ => &[&[1, 0, 0, 0, 1, 0, 1], &[1, 1, 1, 1, 0, 1, 0]],
    };
    rows.iter()
        .map(|row| row.iter().map(|&b| b == 1).collect())
        .collect()
}

/// A known-good (non-catastrophic) rate-1/3 keep-pattern: every generator stays
/// active over the period (period 2: `C1` both, `C2` at t=0, `C3` at t=1).
fn n3_keep() -> Vec<Vec<bool>> {
    vec![vec![true, true], vec![true, false], vec![false, true]]
}

/// The shared end-to-end round-trip check, threaded EXPLICITLY through the Step-0
/// `CodedBlock`→sample conversions:
/// 1. `encode_bits(data, nbits)` → full rate-`1/n` `coded`;
/// 2. `puncture(coded, nbits)` → on-air punctured bits;
/// 3. convert punctured bits → samples (hard AND soft), `depuncture(..)` → full
///    stream, and assert it EQUALS the independent `expected_depunctured` (so
///    every non-punctured position is restored and erasures land exactly);
/// 4. `decode(full, nbits)` clean round-trips back to the source bits.
fn check_punctured_roundtrip(
    params: &CodeParams,
    keep: Vec<Vec<bool>>,
    data: &[u8],
    nbits: usize,
) -> Result<(), TestCaseError> {
    let n = params.n();
    let matrix = PunctureMatrix::new(keep.clone(), PunctureOrder::Interleaved).unwrap();
    let enc = ViterbiEncoder::new(params.clone()).unwrap();
    let coded = enc.encode_bits(data, nbits).unwrap();

    let punc = Puncturer::new(matrix.clone(), params).unwrap();
    let punctured = punc.puncture(&coded, nbits).unwrap();

    // --- hard path ---
    let dp_h = DePuncturer::<HardHamming>::new(matrix.clone(), params).unwrap();
    let full_h = dp_h.depuncture(&coded_to_hard(&punctured), nbits).unwrap();
    let exp_h = expected_depunctured(
        &coded_to_hard(&coded),
        &keep,
        n,
        nbits,
        HardHamming::erasure(),
    );
    prop_assert_eq!(
        &full_h,
        &exp_h,
        "hard depuncture must restore non-punctured positions"
    );
    let mut dec_h = ViterbiDecoder::<64, HardHamming>::new(params.clone(), nbits).unwrap();
    let out_h = dec_h.decode(&full_h, nbits).unwrap();
    prop_assert_eq!(
        first_bits(&out_h.bytes, nbits),
        first_bits(data, nbits),
        "hard clean round-trip must recover the payload"
    );

    // --- soft path ---
    let dp_s = DePuncturer::<SoftLlr>::new(matrix, params).unwrap();
    let full_s = dp_s.depuncture(&coded_to_soft(&punctured), nbits).unwrap();
    let exp_s = expected_depunctured(&coded_to_soft(&coded), &keep, n, nbits, SoftLlr::erasure());
    prop_assert_eq!(
        &full_s,
        &exp_s,
        "soft depuncture must restore non-punctured positions"
    );
    let mut dec_s = ViterbiDecoder::<64, SoftLlr>::new(params.clone(), nbits).unwrap();
    let out_s = dec_s.decode(&full_s, nbits).unwrap();
    prop_assert_eq!(
        first_bits(&out_s.bytes, nbits),
        first_bits(data, nbits),
        "soft clean round-trip must recover the payload"
    );
    Ok(())
}

/// Strategy: a payload `nbits` in `1..=160` (INCLUDING non-period-aligned values)
/// paired with just enough random `data` bytes to hold it.
fn data_and_nbits() -> impl Strategy<Value = (Vec<u8>, usize)> {
    (1usize..=160).prop_flat_map(|nbits| {
        (
            proptest::collection::vec(any::<u8>(), nbits.div_ceil(8)),
            Just(nbits),
        )
    })
}

proptest! {
    /// RP4 (n=2): over random payloads and random `nbits` (incl. non-period-aligned),
    /// each Yasuda punctured rate round-trips — every non-punctured position is
    /// restored and a clean decode recovers the source, hard AND soft.
    #[test]
    fn punctured_roundtrip_n2_restores_and_decodes(
        (data, nbits) in data_and_nbits(),
        sel in 0usize..4,
    ) {
        check_punctured_roundtrip(&CodeParams::ccsds_r1_2(), yasuda_keep(sel), &data, nbits)?;
    }

    /// RP4 (n=3): the same round-trip over the rate-1/3 mother with a non-standard
    /// (caller-provided) keep-pattern — the primary defense against a body/tail
    /// cyclic-indexing off-by-one on the wider `n=3` symbol (Caspar).
    #[test]
    fn punctured_roundtrip_n3_restores_and_decodes(
        (data, nbits) in data_and_nbits(),
    ) {
        check_punctured_roundtrip(&CodeParams::ccsds_r1_3(), n3_keep(), &data, nbits)?;
    }
}

// --- Hand-computed tail-exemption vectors (n=2 AND n=3, non-period-aligned nbits) --
//
// SoftLlr is used so the erasure sentinel is the easy-to-read `0` and the kept/tail
// samples are distinct nonzero values. Body positions follow the cyclic-with-prefix
// keep-pattern; the last `M·n` samples are copied verbatim.

#[test]
fn depuncture_tail_exemption_hand_vector_n2_non_aligned() {
    // Rate 3/4 (period 3), nbits = 4 (4 mod 3 = 1 → NON-period-aligned).
    // keep: C1=[1,0,1], C2=[1,1,0]. Body kept/punctured per stage t (phase = t%3):
    //   t0 φ0: C1 keep, C2 keep     → idx0 K, idx1 K
    //   t1 φ1: C1 punct, C2 keep    → idx2 P, idx3 K
    //   t2 φ2: C1 keep, C2 punct    → idx4 K, idx5 P
    //   t3 φ0: C1 keep, C2 keep     → idx6 K, idx7 K
    // Kept body = 6; tail = M*n = 6*2 = 12 verbatim; expected on-air len = 18;
    // full reconstructed stream = (4+6)*2 = 20.
    let matrix = PunctureMatrix::new(
        vec![vec![true, false, true], vec![true, true, false]],
        PunctureOrder::Interleaved,
    )
    .unwrap();
    let dp = DePuncturer::<SoftLlr>::new(matrix, &CodeParams::ccsds_r1_2()).unwrap();
    assert_eq!(dp.expected_punctured_len(4), Some(18));

    // 6 distinct nonzero body samples, then 12 distinct nonzero tail samples.
    let on_air: Vec<i8> = vec![
        1, 2, 3, 4, 5, 6, // kept body
        101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, // 12 verbatim tail
    ];
    assert_eq!(on_air.len(), 18);

    let full = dp.depuncture(&on_air, 4).unwrap();
    assert_eq!(full.len(), 20);
    #[rustfmt::skip]
    let expected: Vec<i8> = vec![
        1, 2,          // t0: both kept
        0, 3,          // t1: C1 erased, C2 kept
        4, 0,          // t2: C1 kept, C2 erased
        5, 6,          // t3: both kept
        101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, // tail verbatim
    ];
    assert_eq!(full, expected);
    // Tail is exactly the last M*n = 12 on-air samples, verbatim.
    assert_eq!(&full[8..20], &on_air[6..18]);
}

#[test]
fn depuncture_tail_exemption_hand_vector_n3_non_aligned() {
    // Rate-1/3 mother, keep C1=[1,1], C2=[1,0], C3=[0,1] (period 2), nbits = 3
    // (3 mod 2 = 1 → NON-period-aligned). Body per stage t (phase = t%2):
    //   t0 φ0: C1 K, C2 K, C3 P → idx0 K, idx1 K, idx2 P
    //   t1 φ1: C1 K, C2 P, C3 K → idx3 K, idx4 P, idx5 K
    //   t2 φ0: C1 K, C2 K, C3 P → idx6 K, idx7 K, idx8 P
    // Kept body = 6; tail = M*n = 6*3 = 18 verbatim; expected on-air len = 24;
    // full reconstructed stream = (3+6)*3 = 27.
    let matrix = PunctureMatrix::new(n3_keep(), PunctureOrder::Interleaved).unwrap();
    let dp = DePuncturer::<SoftLlr>::new(matrix, &CodeParams::ccsds_r1_3()).unwrap();
    assert_eq!(dp.expected_punctured_len(3), Some(24));

    let mut on_air: Vec<i8> = vec![1, 2, 3, 4, 5, 6]; // 6 kept body samples
    on_air.extend(101..=118); // 18 verbatim tail samples
    assert_eq!(on_air.len(), 24);

    let full = dp.depuncture(&on_air, 3).unwrap();
    assert_eq!(full.len(), 27);
    let mut expected: Vec<i8> = vec![
        1, 2, 0, // t0: C1,C2 kept, C3 erased
        3, 0, 4, // t1: C1 kept, C2 erased, C3 kept
        5, 6, 0, // t2: C1,C2 kept, C3 erased
    ];
    expected.extend(101..=118); // tail verbatim
    assert_eq!(full, expected);
    assert_eq!(&full[9..27], &on_air[6..24]);
}

// --- PunctureOrder::ByColumn is honored (not a silent no-op) -------------------
//
// The on-air emission ORDER must follow `PunctureOrder`, while the reconstructed
// de-punctured stream is ALWAYS interleaved (`t*n+j`, the decoder contract). So a
// ByColumn round-trip must (1) still recover the payload and (2) equal the
// order-independent `expected_depunctured` reconstruction; and ByColumn vs
// Interleaved must produce a DIFFERENT on-air bit order for an order-sensitive
// keep-pattern.

#[test]
fn bycolumn_puncture_depuncture_decode_round_trips() {
    // TX and RX share `body_keep_map`, and the de-punctured stream is emitted in
    // interleaved `t*n+j` order regardless of the on-air PunctureOrder, so it must
    // equal the order-independent `expected_depunctured` reconstruction and decode
    // cleanly. Non-period-aligned `nbits` exercises the cyclic-prefix body split.
    let params = CodeParams::ccsds_r1_2();
    let keep = yasuda_keep(1); // rate 3/4, [[1,0,1],[1,1,0]] — order-sensitive
    let data = vec![0xB4u8, 0x2Cu8];
    let nbits = 13; // 13 mod 3 = 1 → NON-period-aligned
    let n = params.n();

    let matrix = PunctureMatrix::new(keep.clone(), PunctureOrder::ByColumn).unwrap();
    let enc = ViterbiEncoder::new(params.clone()).unwrap();
    let coded = enc.encode_bits(&data, nbits).unwrap();

    let punc = Puncturer::new(matrix.clone(), &params).unwrap();
    let punctured = punc.puncture(&coded, nbits).unwrap();

    let dp = DePuncturer::<HardHamming>::new(matrix, &params).unwrap();
    let full = dp.depuncture(&coded_to_hard(&punctured), nbits).unwrap();
    // The reconstructed stream is interleaved-indexed regardless of on-air order.
    let expected = expected_depunctured(
        &coded_to_hard(&coded),
        &keep,
        n,
        nbits,
        HardHamming::erasure(),
    );
    assert_eq!(
        full, expected,
        "ByColumn depuncture must restore the interleaved t*n+j stream"
    );

    let mut dec = ViterbiDecoder::<64, HardHamming>::new(params.clone(), nbits).unwrap();
    let out = dec.decode(&full, nbits).unwrap();
    assert_eq!(
        first_bits(&out.bytes, nbits),
        first_bits(&data, nbits),
        "ByColumn clean round-trip must recover the payload"
    );
}

#[test]
fn bycolumn_and_interleaved_produce_different_on_air_order() {
    // Same keep-pattern, same coded block: ByColumn vs Interleaved must yield a
    // DIFFERENT on-air bit order (proving ByColumn is honored, not a silent no-op).
    // The kept SET is identical (same pattern), so only the ORDER — hence the packed
    // bytes — differs.
    let params = CodeParams::ccsds_r1_2();
    let keep = yasuda_keep(1); // [[1,0,1],[1,1,0]] — kept positions differ per column
    let data = vec![0xB4u8, 0x2Cu8];
    let nbits = 12;

    let enc = ViterbiEncoder::new(params.clone()).unwrap();
    let coded = enc.encode_bits(&data, nbits).unwrap();

    let m_col = PunctureMatrix::new(keep.clone(), PunctureOrder::ByColumn).unwrap();
    let m_int = PunctureMatrix::new(keep, PunctureOrder::Interleaved).unwrap();
    let out_col = Puncturer::new(m_col, &params)
        .unwrap()
        .puncture(&coded, nbits)
        .unwrap();
    let out_int = Puncturer::new(m_int, &params)
        .unwrap()
        .puncture(&coded, nbits)
        .unwrap();

    // Same number of kept bits (same keep-pattern) but a different EMISSION ORDER.
    assert_eq!(
        out_col.nbits, out_int.nbits,
        "same keep-pattern keeps the same bit count"
    );
    assert_ne!(
        out_col.bytes, out_int.bytes,
        "ByColumn must reorder the on-air bits vs Interleaved"
    );
}

// --- Length-collision search (Balthasar/Caspar) --------------------------------

#[test]
fn expected_punctured_len_strictly_increasing_over_small_n_period_pairs() {
    // Over several (mother code, matrix) pairs — spanning n=2 (all 4 Yasuda rates)
    // and n=3 — assert `expected_punctured_len` is STRICTLY INCREASING across a
    // range of period-aligned `nbits`. Strict monotonicity ⇒ no two distinct
    // aligned `nbits` share a length ⇒ the collision-immunity claim holds.
    let cases: Vec<(CodeParams, PunctureMatrix)> = vec![
        (
            CodeParams::ccsds_r1_2(),
            PunctureMatrix::ccsds_rate(2, PuncturedRate::R2_3).unwrap(),
        ),
        (
            CodeParams::ccsds_r1_2(),
            PunctureMatrix::ccsds_rate(2, PuncturedRate::R3_4).unwrap(),
        ),
        (
            CodeParams::ccsds_r1_2(),
            PunctureMatrix::ccsds_rate(2, PuncturedRate::R5_6).unwrap(),
        ),
        (
            CodeParams::ccsds_r1_2(),
            PunctureMatrix::ccsds_rate(2, PuncturedRate::R7_8).unwrap(),
        ),
        (
            CodeParams::ccsds_r1_3(),
            PunctureMatrix::new(n3_keep(), PunctureOrder::Interleaved).unwrap(),
        ),
    ];
    for (params, matrix) in cases {
        let period = matrix.period();
        let dp = DePuncturer::<SoftLlr>::new(matrix, &params).unwrap();
        let mut prev: Option<usize> = None;
        for k in 1..=20 {
            let nbits = k * period;
            let len = dp
                .expected_punctured_len(nbits)
                .expect("small aligned nbits never overflows");
            if let Some(p) = prev {
                assert!(
                    len > p,
                    "expected_punctured_len must strictly increase: {p} -> {len} at nbits={nbits}"
                );
            }
            prev = Some(len);
        }
    }
}

#[test]
fn depuncture_wrong_nbits_is_flagged_misaligned() {
    // A wrong `nbits` yields a DIFFERENT `expected_punctured_len`, so a stream built
    // for one `nbits` is caught by `depuncture` under a neighbouring `nbits` as
    // `MisalignedInput` — the collision-immunity in action (distinct nbits → distinct
    // lengths, so the mismatch is detected rather than silently mis-decoded).
    let matrix = PunctureMatrix::ccsds_rate(2, PuncturedRate::R3_4).unwrap();
    let dp = DePuncturer::<SoftLlr>::new(matrix, &CodeParams::ccsds_r1_2()).unwrap();
    let nbits = 6;
    let len = dp.expected_punctured_len(nbits).unwrap();
    let on_air = vec![1i8; len];

    // Correct nbits accepts.
    assert!(dp.depuncture(&on_air, nbits).is_ok());

    // Wrong nbits (nbits+1) expects a different length → MisalignedInput.
    let len_next = dp.expected_punctured_len(nbits + 1).unwrap();
    assert_ne!(len, len_next, "distinct nbits must give distinct lengths");
    assert_eq!(
        dp.depuncture(&on_air, nbits + 1).unwrap_err(),
        PunctureError::MisalignedInput {
            got: len,
            expected: len_next,
        }
    );
}