#!/usr/bin/env python3 """ Validation tests for the LDPC behavioral model (ldpc_sim.py). Tests cover code parameters, H-matrix structure, encoding, saturating arithmetic, min-sum CN update, decoding, and syndrome computation. Run: python3 -m pytest model/test_ldpc.py -v """ import numpy as np import pytest from ldpc_sim import ( N, K, M, Z, N_BASE, M_BASE, H_BASE, Q_BITS, Q_MAX, Q_MIN, OFFSET, build_full_h_matrix, ldpc_encode, poisson_channel, quantize_llr, decode_layered_min_sum, compute_syndrome_weight, cyclic_shift, sat_add_q, sat_sub_q, min_sum_cn_update, ) # ============================================================================= # Fixtures # ============================================================================= @pytest.fixture(scope="module") def H(): """Full expanded H matrix, built once per module.""" return build_full_h_matrix() # ============================================================================= # TestCodeParameters # ============================================================================= class TestCodeParameters: """Verify fundamental code parameters match the design specification.""" def test_codeword_length(self): assert N == 256 def test_info_length(self): assert K == 32 def test_parity_checks(self): assert M == 224 def test_lifting_factor(self): assert Z == 32 def test_base_matrix_columns(self): assert N_BASE == 8 def test_base_matrix_rows(self): assert M_BASE == 7 def test_h_base_shape(self): assert H_BASE.shape == (M_BASE, N_BASE) assert H_BASE.shape == (7, 8) def test_derived_relationships(self): assert N == N_BASE * Z assert M == M_BASE * Z assert K == Z # ============================================================================= # TestHMatrix # ============================================================================= class TestHMatrix: """Validate H-matrix structural properties.""" def test_h_base_col0_connects_all_rows(self): """Column 0 (information bits) should connect to all 7 check rows.""" col0 = H_BASE[:, 0] for r in range(M_BASE): assert col0[r] >= 0, f"Row {r} of column 0 is not connected (value={col0[r]})" def test_h_base_staircase_parity(self): """Parity columns 1-7 have a lower-triangular staircase structure. For the staircase, row r connects to column r+1 (the diagonal) and possibly column r (the sub-diagonal). Entries above the staircase should be -1 (no connection). """ # Check that the parity sub-matrix (cols 1..7) is lower-triangular for r in range(M_BASE): for c in range(1, N_BASE): if c > r + 1: # Strictly above the diagonal+1: must be -1 assert H_BASE[r, c] == -1, ( f"H_BASE[{r},{c}]={H_BASE[r, c]}, expected -1 (above staircase)" ) def test_full_h_dimensions(self, H): """Full H matrix should be (M, N) = (224, 256).""" assert H.shape == (M, N) assert H.shape == (224, 256) def test_full_h_binary(self, H): """Full H matrix should contain only 0s and 1s.""" unique_vals = np.unique(H) assert set(unique_vals).issubset({0, 1}), f"Non-binary values found: {unique_vals}" def test_qc_block_row_weight(self, H): """Each Z-block row within a base row should have the same weight (QC property). For a QC-LDPC code, each Z x Z sub-block is either all-zero or a circulant permutation matrix. So within each base row, every expanded row has the same Hamming weight. """ for r in range(M_BASE): row_start = r * Z weights = [int(H[row_start + z, :].sum()) for z in range(Z)] assert len(set(weights)) == 1, ( f"Base row {r}: inconsistent row weights {set(weights)}" ) def test_full_h_rank(self, H): """H should have full row rank (rank == M == 224) for a valid code.""" rank = np.linalg.matrix_rank(H.astype(float)) assert rank == M, f"H rank = {rank}, expected {M}" # ============================================================================= # TestEncoder # ============================================================================= class TestEncoder: """Validate the LDPC encoder.""" def test_all_zero_info(self, H): """All-zero info bits should encode to all-zero codeword.""" info = np.zeros(K, dtype=np.int8) codeword = ldpc_encode(info, H) assert np.all(codeword == 0), "All-zero info did not produce all-zero codeword" def test_random_info_valid_codeword(self, H): """Random info bits should encode to a valid codeword (syndrome = 0).""" np.random.seed(12345) info = np.random.randint(0, 2, K).astype(np.int8) codeword = ldpc_encode(info, H) syndrome = H @ codeword % 2 assert np.all(syndrome == 0), f"Syndrome weight = {syndrome.sum()}" def test_encoder_preserves_info_bits(self, H): """First K positions of codeword should be the original info bits.""" np.random.seed(99) info = np.random.randint(0, 2, K).astype(np.int8) codeword = ldpc_encode(info, H) assert np.array_equal(codeword[:K], info), "Info bits not preserved in codeword" def test_twenty_random_messages(self, H): """20 random messages should all produce valid codewords.""" np.random.seed(2024) for i in range(20): info = np.random.randint(0, 2, K).astype(np.int8) codeword = ldpc_encode(info, H) syndrome = H @ codeword % 2 assert np.all(syndrome == 0), f"Message {i}: syndrome weight = {syndrome.sum()}" # ============================================================================= # TestSaturatingArithmetic # ============================================================================= class TestSaturatingArithmetic: """Validate saturating add/sub in Q-bit signed arithmetic.""" def test_normal_add(self): assert sat_add_q(5, 3) == 8 assert sat_add_q(-5, 3) == -2 assert sat_add_q(0, 0) == 0 def test_normal_sub(self): assert sat_sub_q(5, 3) == 2 assert sat_sub_q(3, 5) == -2 assert sat_sub_q(0, 0) == 0 def test_positive_overflow_saturation(self): """Addition overflowing Q_MAX should saturate to Q_MAX.""" result = sat_add_q(Q_MAX, 1) assert result == Q_MAX, f"Expected {Q_MAX}, got {result}" result = sat_add_q(20, 20) assert result == Q_MAX, f"Expected {Q_MAX}, got {result}" def test_negative_overflow_saturation(self): """Addition underflowing Q_MIN should saturate to Q_MIN.""" result = sat_add_q(Q_MIN, -1) assert result == Q_MIN, f"Expected {Q_MIN}, got {result}" result = sat_add_q(-20, -20) assert result == Q_MIN, f"Expected {Q_MIN}, got {result}" def test_sub_positive_overflow(self): """Subtracting a large negative from a positive should saturate.""" result = sat_sub_q(Q_MAX, Q_MIN) assert result == Q_MAX def test_sub_negative_overflow(self): """Subtracting a large positive from a negative should saturate.""" result = sat_sub_q(Q_MIN, Q_MAX) assert result == Q_MIN # ============================================================================= # TestMinSumCN # ============================================================================= class TestMinSumCN: """Validate offset min-sum check node update.""" def test_all_positive_inputs(self): """All positive inputs should produce all positive outputs.""" msgs_in = [10, 5, 8] msgs_out = min_sum_cn_update(msgs_in) for j, val in enumerate(msgs_out): assert val >= 0, f"Output {j} = {val}, expected non-negative" def test_one_negative_input_sign_flip(self): """One negative input among positives should flip signs of other outputs. The sign of output j is the XOR of all OTHER input signs. With one negative at index i, output j (j != i) gets sign=1 (negative), and output i gets sign=0 (positive, since XOR of all-positive others = 0). """ msgs_in = [10, -5, 8] msgs_out = min_sum_cn_update(msgs_in) # Output at index 1 (the negative input) should be positive (sign of others XOR = 0) assert msgs_out[1] >= 0, f"Output[1] = {msgs_out[1]}, expected non-negative" # Outputs at indices 0 and 2 should be negative (one negative among others) assert msgs_out[0] <= 0, f"Output[0] = {msgs_out[0]}, expected non-positive" assert msgs_out[2] <= 0, f"Output[2] = {msgs_out[2]}, expected non-positive" def test_magnitude_is_min_of_others_minus_offset(self): """Output magnitude = min of OTHER magnitudes - OFFSET, clamped to 0.""" msgs_in = [10, 5, 8] msgs_out = min_sum_cn_update(msgs_in, offset=OFFSET) mags_in = [abs(m) for m in msgs_in] # For index 0: min of others = min(5, 8) = 5, output mag = 5 - OFFSET = 4 assert abs(msgs_out[0]) == max(0, 5 - OFFSET) # For index 1 (has min magnitude): min of others = min(10, 8) = 8, output mag = 8 - OFFSET assert abs(msgs_out[1]) == max(0, 8 - OFFSET) # For index 2: min of others = min(10, 5) = 5, output mag = 5 - OFFSET = 4 assert abs(msgs_out[2]) == max(0, 5 - OFFSET) def test_offset_clamps_to_zero(self): """When min magnitude equals offset, output magnitude should be 0.""" # Use offset=1 (default OFFSET) and magnitude=1 for the minimum msgs_in = [10, 1, 8] msgs_out = min_sum_cn_update(msgs_in, offset=1) # For indices 0 and 2: min of others includes mag=1, so 1-1=0 assert abs(msgs_out[0]) == 0 assert abs(msgs_out[2]) == 0 # ============================================================================= # TestDecoder # ============================================================================= class TestDecoder: """Validate the layered min-sum decoder.""" def test_noiseless_channel(self, H): """Noiseless channel (LLR = +-20) should decode perfectly in 1 iteration.""" np.random.seed(42) info = np.random.randint(0, 2, K).astype(np.int8) codeword = ldpc_encode(info, H) # Create noiseless LLRs: positive for bit=0, negative for bit=1 # Convention: positive LLR -> bit 0 more likely llr_q = np.where(codeword == 0, 20, -20).astype(np.int8) decoded, converged, iters, syn_wt = decode_layered_min_sum(llr_q, max_iter=30) assert converged, "Decoder did not converge on noiseless channel" assert iters == 1, f"Expected 1 iteration, got {iters}" assert np.array_equal(decoded, info), "Decoded bits do not match info bits" assert syn_wt == 0 def test_high_snr_decoding(self, H): """High SNR (lambda_s=10) should decode correctly.""" np.random.seed(7777) info = np.random.randint(0, 2, K).astype(np.int8) codeword = ldpc_encode(info, H) lam_s = 10.0 lam_b = 0.1 llr_float, _ = poisson_channel(codeword, lam_s, lam_b) llr_q = quantize_llr(llr_float) decoded, converged, iters, syn_wt = decode_layered_min_sum(llr_q, max_iter=30) assert converged, f"Decoder did not converge (syn_wt={syn_wt}, iters={iters})" assert np.array_equal(decoded, info), ( f"Decoded bits do not match info bits ({np.sum(decoded != info)} errors)" ) # ============================================================================= # TestSyndrome # ============================================================================= class TestSyndrome: """Validate syndrome weight computation.""" def test_valid_codeword_zero_syndrome(self, H): """A valid codeword should have syndrome weight 0.""" np.random.seed(555) info = np.random.randint(0, 2, K).astype(np.int8) codeword = ldpc_encode(info, H) sw = compute_syndrome_weight(codeword.tolist()) assert sw == 0, f"Valid codeword has syndrome weight {sw}, expected 0" def test_flipped_bit_nonzero_syndrome(self, H): """Flipping one bit in a valid codeword should give nonzero syndrome weight.""" np.random.seed(555) info = np.random.randint(0, 2, K).astype(np.int8) codeword = ldpc_encode(info, H) # Flip a bit corrupted = codeword.copy() corrupted[0] = 1 - corrupted[0] sw = compute_syndrome_weight(corrupted.tolist()) assert sw > 0, "Flipped bit did not produce nonzero syndrome weight"