SilverSight/tests/test_dna_codec.py
allaun 6b649ad271 fix(dna): correct alphabet ordering ATGCBSPZ→ABCGPSTZ + proof cleanup
Critical bug fix: dna_codec.py used biological base ordering (ATGCBSPZ)
instead of ASCII-ordered spec ordering (ABCGPSTZ). This violated the core
monotonicity axiom (int rank = lexicographic rank) that the entire monotone
LUT pipeline depends on.

Changes:
- python/dna_codec.py: BITS_TO_BASE, HACHIMOJI_BASES, LATIN_TO_GREEK
  corrected to ABCGPSTZ ordering; encode_binary_vector parameter renamed
  bases_per_var; module docstring updated
- tests/test_dna_codec.py: hardcoded byte→DNA expectations updated for new
  ordering (0xFF→ZZT, 0xd1→TPC); bases_per_var parameter name updated;
  31/31 tests green
- formal/CoreFormalism/HachimojiLUT.lean: replace fragile
  canonical_phases_preserved.2.2.2.2.2.1 chains with named obtain
  destructuring in pythagorean_position and contradiction_position
- docs/UNIFIED_THEORY.md: add ground-truth caveat to epigenetic optimizer
  results table (n≥24 results are local optima, not verified global minima)

Note: test_dna_nn.py has 4 pre-existing failures (Ising chain correlations)
unrelated to this fix — dna_qubo_nn.py has its own base encoding and does
not import dna_codec.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 23:20:16 -05:00

355 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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)