#!/usr/bin/env python3 """ test_dna_codec.py — Tests for the Hachimoji DNA encoding/decoding layer Test categories: 1. Base encoding roundtrip 2. Binary vector encoding 3. QUBO energy computation 4. Tm sort verification (the critical test) 5. SAM/FASTA export """ import json import math import os import random import sys import unittest sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "python")) from dna_codec import ( BASE_TO_BITS, BITS_TO_BASE, HACHIMOJI_BASES, decode_binary_vector, decode_dna_to_bytes, dna_to_greek, encode_binary_vector, encode_bytes_to_dna, gc_content, greek_to_dna, melting_temperature, qubo_energy, raw_gc_content, sequence_stats, ) from dna_qubo_sort import ( QuboDnaCandidate, candidates_to_fasta, candidates_to_sam, demo_max_cut, demo_number_partition, encode_qubo_candidate, generate_all_candidates, generate_random_candidates, run_qubo_dna_sort, sort_by_energy, sort_by_gc, sort_by_tm, spearman_rank_correlation, verify_tm_sort, ) class TestBaseEncoding(unittest.TestCase): """§1: Base encoding roundtrip.""" def test_all_bases_present(self): """All 8 Hachimoji bases are defined.""" self.assertEqual(len(HACHIMOJI_BASES), 8) self.assertEqual(len(BITS_TO_BASE), 8) self.assertEqual(len(BASE_TO_BITS), 8) def test_bijection(self): """BITS_TO_BASE and BASE_TO_BITS are inverses.""" for bits, base in BITS_TO_BASE.items(): self.assertEqual(BASE_TO_BITS[base], bits) for base, bits in BASE_TO_BITS.items(): self.assertEqual(BITS_TO_BASE[bits], base) def test_3bit_coverage(self): """All 3-bit values (0-7) map to a base.""" for i in range(8): self.assertIn(i, BITS_TO_BASE) self.assertEqual(len(BITS_TO_BASE[i]), 1) # single character def test_greek_roundtrip(self): """Latin → Greek → Latin roundtrip.""" for base in HACHIMOJI_BASES: greek = dna_to_greek(base) back = greek_to_dna(greek) self.assertEqual(back, base, f"Failed for {base} → {greek} → {back}") class TestByteEncoding(unittest.TestCase): """§2: Byte → DNA encoding roundtrip.""" def test_single_byte(self): """Single byte encodes and decodes.""" for b in range(256): data = bytes([b]) dna = encode_bytes_to_dna(data) decoded = decode_dna_to_bytes(dna) self.assertEqual(decoded[:1], data, f"Failed for byte {b}") def test_empty(self): """Empty input produces empty output.""" dna = encode_bytes_to_dna(b"") self.assertEqual(dna, "") decoded = decode_dna_to_bytes("") self.assertEqual(decoded, b"") def test_known_sequence(self): """Known byte → DNA mapping (ABCGPSTZ ordering, ASCII-sorted).""" # 0x00 = 00000000 → 000 000 00(pad 0) → A A A (3 bases) dna = encode_bytes_to_dna(b"\x00") self.assertEqual(dna, "AAA") # 0xFF = 11111111 → 111 111 11(pad 0) → Z Z T (3 bases) # With ABCGPSTZ: 111=7→Z, 111=7→Z, 110=6→T dna = encode_bytes_to_dna(b"\xff") self.assertEqual(dna[0], "Z") # 111 = 7 self.assertEqual(dna[1], "Z") # 111 = 7 self.assertEqual(dna[2], "T") # 110 = 6 (padded with 0) # 0b11010001 = 110 100 01(pad 0) = 110 100 010 → T P C # With ABCGPSTZ: 110=6→T, 100=4→P, 010=2→C dna = encode_bytes_to_dna(b"\xd1") self.assertEqual(dna[0], "T") # 110 = 6 self.assertEqual(dna[1], "P") # 100 = 4 self.assertEqual(dna[2], "C") # 010 = 2 def test_random_roundtrip(self): """Random bytes roundtrip through DNA encoding.""" rng = random.Random(42) for _ in range(100): length = rng.randint(1, 50) data = bytes(rng.randint(0, 255) for _ in range(length)) dna = encode_bytes_to_dna(data) decoded = decode_dna_to_bytes(dna) # May have padding zeros at the end self.assertEqual(decoded[:length], data) def test_invalid_base_raises(self): """Invalid base character raises ValueError.""" with self.assertRaises(ValueError): decode_dna_to_bytes("AXG") class TestBinaryVectorEncoding(unittest.TestCase): """§3: Binary vector encoding.""" def test_all_zeros(self): """All-zero vector → all A's (3 bases per var by default).""" x = [0, 0, 0, 0] seq = encode_binary_vector(x) self.assertEqual(seq, "A" * 12) # 4 vars × 3 bases = 12 def test_all_ones(self): """All-one vector → all P's (3 bases per var by default).""" x = [1, 1, 1, 1] seq = encode_binary_vector(x) self.assertEqual(seq, "P" * 12) # 4 vars × 3 bases = 12 def test_mixed(self): """Mixed vector encodes correctly (3 bases per var).""" x = [0, 1, 0, 1] seq = encode_binary_vector(x) self.assertEqual(seq, "AAAPPPAAAPPP") # 0→AAA, 1→PPP, 0→AAA, 1→PPP def test_single_base_per_var(self): """bases_per_var=1 gives one base per variable.""" x = [0, 1, 0, 1] seq = encode_binary_vector(x, bases_per_var=1) self.assertEqual(seq, "APAP") def test_roundtrip(self): """Binary vector roundtrip.""" for _ in range(50): rng = random.Random(42) x = [rng.randint(0, 1) for _ in range(20)] seq = encode_binary_vector(x) decoded = decode_binary_vector(seq) self.assertEqual(decoded, x) class TestMeltingTemperature(unittest.TestCase): """§4: Melting temperature model.""" def test_empty(self): """Empty sequence has Tm = 0.""" self.assertEqual(melting_temperature(""), 0.0) def test_all_a_low_tm(self): """All-A sequence has low Tm.""" tm_a = melting_temperature("A" * 10) tm_g = melting_temperature("G" * 10) self.assertLess(tm_a, tm_g) def test_gc_richer_higher_tm(self): """GC-rich sequences have higher Tm than AT-rich.""" at_rich = "AATT" * 10 gc_rich = "GGCC" * 10 self.assertLess(melting_temperature(at_rich), melting_temperature(gc_rich)) def test_synthetic_bases_intermediate(self): """Synthetic bases have intermediate Tm values.""" tm_a = TM_CONTRIBUTION["A"] tm_g = TM_CONTRIBUTION["G"] tm_p = TM_CONTRIBUTION["P"] self.assertLess(tm_a, tm_p) self.assertLess(tm_p, tm_g + 1) # P is between A and G def test_gc_content_range(self): """GC content is in [0, 1].""" for seq in ["AAAA", "GGGG", "ATGC", "PPZZ", ""]: gc = gc_content(seq) self.assertGreaterEqual(gc, 0.0) self.assertLessEqual(gc, 1.0) def test_tm_monotone_with_ones(self): """Tm is monotonically related to number of 1s in binary vector.""" # For encode_binary_vector: 0→A (Tm=2), 1→P (Tm=3.5) # More 1s = higher Tm for n_ones in range(10): x = [1] * n_ones + [0] * (10 - n_ones) seq = encode_binary_vector(x) tm = melting_temperature(seq) if n_ones > 0: x_prev = [1] * (n_ones - 1) + [0] * (11 - n_ones) seq_prev = encode_binary_vector(x_prev) tm_prev = melting_temperature(seq_prev) self.assertGreaterEqual(tm, tm_prev) # Need TM_CONTRIBUTION for the test from dna_codec import TM_CONTRIBUTION class TestQuboEnergy(unittest.TestCase): """§5: QUBO energy computation.""" def test_zero_matrix(self): """Zero matrix → zero energy for any x.""" Q = [[0.0] * 4 for _ in range(4)] self.assertEqual(qubo_energy([1, 0, 1, 0], Q), 0.0) def test_identity_matrix(self): """Identity matrix → energy = number of 1s.""" n = 5 Q = [[1.0 if i == j else 0.0 for j in range(n)] for i in range(n)] for k in range(n + 1): x = [1] * k + [0] * (n - k) self.assertAlmostEqual(qubo_energy(x, Q), float(k)) def test_known_qubo(self): """Known QUBO with hand-computed energy.""" # Q = [[1, -1], [-1, 1]] # x = [0, 0] → E = 0 # x = [1, 0] → E = 1 # x = [0, 1] → E = 1 # x = [1, 1] → E = 0 Q = [[1.0, -1.0], [-1.0, 1.0]] self.assertAlmostEqual(qubo_energy([0, 0], Q), 0.0) self.assertAlmostEqual(qubo_energy([1, 0], Q), 1.0) self.assertAlmostEqual(qubo_energy([0, 1], Q), 1.0) self.assertAlmostEqual(qubo_energy([1, 1], Q), 0.0) class TestTmSortVerification(unittest.TestCase): """§6: THE CRITICAL TEST — does Tm sorting = energy sorting?""" def test_diagonal_qubo_perfect_correlation(self): """For diagonal Q with positive entries, Tm sort = energy sort exactly. This is the ideal case: E(x) = Σ Q_ii * x_i, and Tm ∝ Σ x_i. When all Q_ii are equal, Tm sort is identical to energy sort. """ n = 6 Q = [[3.0 if i == j else 0.0 for j in range(n)] for i in range(n)] result = verify_tm_sort(Q, n) self.assertTrue(result.tm_sort_matches_energy) self.assertGreater(result.rank_correlation, 0.99) def test_uniform_qubo_rank_correlation(self): """For uniform Q, rank correlation should be very high.""" n = 8 Q = [[2.0 if i == j else 0.5 for j in range(n)] for i in range(n)] result = verify_tm_sort(Q, n) self.assertGreater(result.rank_correlation, 0.8) def test_max_cut_sort(self): """Max-Cut QUBO: Tm sort should find a low-energy solution.""" Q = demo_max_cut(6, seed=42) result = verify_tm_sort(Q, 6) # Tm-optimal should be in the top-k by energy self.assertGreater(result.top_k_agreement, 0) def test_negative_entries_degrade_correlation(self): """Negative Q entries break the Tm-energy correlation. This is expected: Tm encoding assumes non-negative contribution per 1. Negative Q entries mean more 1s can LOWER energy, inverting the sort. """ n = 4 Q = [[-3.0 if i == j else 0.0 for j in range(n)] for i in range(n)] result = verify_tm_sort(Q, n) # Correlation should be negative (inverted!) self.assertLess(result.rank_correlation, 0.5) class TestSamFastaExport(unittest.TestCase): """§7: SAM and FASTA export.""" def test_sam_format(self): """SAM output is valid-ish format.""" x = [1, 0, 1] Q = [[1.0, 0, 0], [0, 1.0, 0], [0, 0, 1.0]] cand = encode_qubo_candidate(x, Q) sam = candidates_to_sam([cand], "test") lines = sam.strip().split("\n") # Header lines start with @ self.assertTrue(lines[0].startswith("@HD")) self.assertTrue(lines[1].startswith("@PG")) # Data line (3rd line after @CO comment) data_line = [l for l in lines if not l.startswith("@")][0] self.assertIn("TM:f:", data_line) self.assertIn("EN:f:", data_line) def test_fasta_format(self): """FASTA output starts with >.""" x = [1, 0, 1] Q = [[1.0, 0, 0], [0, 1.0, 0], [0, 0, 1.0]] cand = encode_qubo_candidate(x, Q) fasta = candidates_to_fasta([cand]) lines = fasta.strip().split("\n") self.assertTrue(lines[0].startswith(">")) # Second line is sequence self.assertTrue(all(c in HACHIMOJI_BASES for c in lines[1])) class TestSequenceStats(unittest.TestCase): """§8: Sequence statistics.""" def test_stats_keys(self): """sequence_stats returns expected keys.""" stats = sequence_stats("ATGCBSPZ") self.assertIn("length", stats) self.assertIn("gc_content", stats) self.assertIn("melting_temperature", stats) self.assertIn("base_counts", stats) self.assertIn("greek", stats) def test_stats_length(self): """Length is correct.""" self.assertEqual(sequence_stats("ATGC")["length"], 4) self.assertEqual(sequence_stats("")["length"], 0) if __name__ == "__main__": unittest.main(verbosity=2)